Skip to content
UI Kit
Accent color

Floating actions

Back to top, reading progress, a mobile action bar, a sticky call to action and a contact dock. They follow the scroll of the page.

One of 3 components in Menu bars and switchers

5 variants

Variants

Back to top

Appears after the first screen. The ring fills with the page, hover shows the percent read.

Reading progress

A thin line at the top fills while you read, a bar with the title and the minutes left slides in.

Open in Studio

Action bar

Call, route and booking for a local business. A full-width bar on phones, a floating pill with the live status on larger screens. Hides while reading down.

Open in Studio

Sticky call to action

Slides in once the hero is gone. At the top on wide screens, at the bottom near the thumb on phones. Can be closed.

Open in Studio

Contact dock

A labelled button that folds to its icon while reading and opens the ways to reach you, one after another. Escape closes it.

Open in Studio

Details

What Floating actions brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.0.0
File
components/ui/floating-actions.tsx
Brings along
utilskitbuttonicon
npm packages
None
Languages
English and German, 9 texts of its own

Props

The FloatingActionsProps interface from components/ui/floating-actions.tsx, with the comments from the code.

  • variantoptional
    FloatingActionsVariant

    back-to-topreading-progressmobile-action-barsticky-ctacontact-dock

  • thresholdoptional
    number

    back-to-top, sticky-cta: pixels of scroll before it appears

  • showPercentoptional
    boolean

    back-to-top: show the percent read next to the arrow on hover and focus

  • titleoptional
    string

    reading-progress: the article title in the bar that slides in

  • minutesoptional
    number

    reading-progress: reading time of the whole page in minutes

  • textoptional
    string

    sticky-cta: a second line under the title

  • actionoptional
    { label: string; href: string }

    sticky-cta: the main and an optional second action

  • secondaryActionoptional
    { label: string; href: string }
  • dismissibleoptional
    boolean

    sticky-cta: show a close button

  • actionsoptional
    FloatingAction[]

    mobile-action-bar, contact-dock: the actions

  • statusoptional
    string

    mobile-action-bar, contact-dock: a live status, e.g. Open until 18:00

  • labeloptional
    string

    contact-dock: the label of the closed button, collapses to the icon while scrolling down

  • headingoptional
    string

    contact-dock: the heading of the open panel

Built-in texts

Floating actions writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 9 texts
backToTopEnglish Back to topGerman Nach oben
percentEnglish {n}%German {n} %
minLeftEnglish {n} min leftGerman noch {n} Min.
doneEnglish FinishedGerman Fertig gelesen
readingEnglish Reading progressGerman Lesefortschritt
closeEnglish CloseGerman Schließen
openContactEnglish Open contact optionsGerman Kontaktwege öffnen
closeContactEnglish Close contact optionsGerman Kontaktwege schließen
actionsEnglish Quick actionsGerman Schnellaktionen

Accessibility

Read from the source of the component.

  • Floating actions handles the keys Escape itself.
  • Sets aria-controls, aria-expanded, aria-label, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
  • Uses the roles dialog, progressbar and region.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 485 lines
components/ui/floating-actions.tsx
'use client';

import { createContext, useContext, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';

/*
 * Things that float over a page and follow the scroll: back to top, reading
 * progress, the action bar of a local business, a sticky call to action and
 * a contact dock. They sit `fixed` over the window. Inside a <ScrollViewport>
 * (a framed page that scrolls on its own, for previews and mockups) they sit
 * `absolute` over that frame and follow its scroll instead.
 *
 * Server and no-JS render the shown state, the browser hides what the scroll
 * position does not need yet. Only transform and opacity move.
 */

export type FloatingActionsVariant = 'back-to-top' | 'reading-progress' | 'mobile-action-bar' | 'sticky-cta' | 'contact-dock';

export interface FloatingAction {
  icon: IconName;
  label: string;
  href: string;
  /** The one main action, filled with the accent */
  primary?: boolean;
  /** contact-dock: a second line, e.g. the number or the reply time */
  detail?: string;
}

export interface FloatingActionsProps {
  variant?: FloatingActionsVariant;
  /** back-to-top, sticky-cta: pixels of scroll before it appears */
  threshold?: number;
  /** back-to-top: show the percent read next to the arrow on hover and focus */
  showPercent?: boolean;
  /** reading-progress: the article title in the bar that slides in */
  title?: string;
  /** reading-progress: reading time of the whole page in minutes */
  minutes?: number;
  /** sticky-cta: a second line under the title */
  text?: string;
  /** sticky-cta: the main and an optional second action */
  action?: { label: string; href: string };
  secondaryAction?: { label: string; href: string };
  /** sticky-cta: show a close button */
  dismissible?: boolean;
  /** mobile-action-bar, contact-dock: the actions */
  actions?: FloatingAction[];
  /** mobile-action-bar, contact-dock: a live status, e.g. Open until 18:00 */
  status?: string;
  /** contact-dock: the label of the closed button, collapses to the icon while scrolling down */
  label?: string;
  /** contact-dock: the heading of the open panel */
  heading?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    backToTop: 'Back to top',
    percent: '{n}%',
    minLeft: '{n} min left',
    done: 'Finished',
    reading: 'Reading progress',
    close: 'Close',
    openContact: 'Open contact options',
    closeContact: 'Close contact options',
    actions: 'Quick actions',
  },
  de: {
    backToTop: 'Nach oben',
    percent: '{n} %',
    minLeft: 'noch {n} Min.',
    done: 'Fertig gelesen',
    reading: 'Lesefortschritt',
    close: 'Schließen',
    openContact: 'Kontaktwege öffnen',
    closeContact: 'Kontaktwege schließen',
    actions: 'Schnellaktionen',
  },
};

type Texts = (typeof TEXTS)['en'];

/* ------------------------------------------------------------------ */
/* Scroll source: the window or a ScrollViewport                       */
/* ------------------------------------------------------------------ */

const ViewportContext = createContext<React.RefObject<HTMLDivElement | null> | null>(null);

/**
 * A framed page that scrolls on its own. Floating actions inside `children`
 * sit over the frame and follow its scroll. Fixed pixel height, never vh, so
 * it can live inside an auto-sized iframe.
 *   <ScrollViewport height={620} page={<YourPage />}><FloatingActions … /></ScrollViewport>
 */
export function ScrollViewport({ page, height = 620, initialScroll = 0, className, children }: { page: React.ReactNode; height?: number; initialScroll?: number; className?: string; children?: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (ref.current && initialScroll) ref.current.scrollTop = initialScroll;
  }, [initialScroll]);
  return (
    <ViewportContext.Provider value={ref}>
      <div data-clip-ok className={cn('relative isolate overflow-hidden rounded-2xl border border-border bg-background shadow-[var(--highlight),var(--shadow-lg)]', className)} style={{ height }}>
        <div ref={ref} tabIndex={0} className="kit-scrollbar absolute inset-0 overflow-y-auto overscroll-contain focus-visible:outline-none">
          {page}
        </div>
        {children}
      </div>
    </ViewportContext.Provider>
  );
}

interface ScrollState {
  y: number;
  progress: number;
  down: boolean;
  /** false on the server and before the first measurement: show everything */
  ready: boolean;
}

function useScroll() {
  const vp = useContext(ViewportContext);
  const [state, setState] = useState<ScrollState>({ y: 0, progress: 1, down: false, ready: false });
  useEffect(() => {
    const el = vp?.current ?? null;
    const source: HTMLElement | Window = el ?? window;
    let last = el ? el.scrollTop : window.scrollY;
    let frame = 0;
    let settled = false;
    const read = () => {
      frame = 0;
      const y = el ? el.scrollTop : window.scrollY;
      const max = el ? el.scrollHeight - el.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
      const down = !settled || Math.abs(y - last) < 4 ? undefined : y > last;
      last = y;
      setState((s) => ({ y, progress: max > 0 ? Math.min(1, Math.max(0, y / max)) : 1, down: down ?? s.down, ready: true }));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(read);
    };
    read();
    // A viewport may set its start position after this effect: read once more, without a direction
    const t = window.setTimeout(() => {
      last = el ? el.scrollTop : window.scrollY;
      settled = true;
      read();
    }, 120);
    source.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.clearTimeout(t);
      source.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
    };
  }, [vp]);
  const scrollTop = () => {
    const behavior: ScrollBehavior = prefersReducedMotion() ? 'auto' : 'smooth';
    (vp?.current ?? window).scrollTo({ top: 0, behavior });
  };
  return { ...state, contained: !!vp, scrollTop };
}

const MOVE = 'transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none';

/* ------------------------------------------------------------------ */
/* back-to-top                                                         */
/* ------------------------------------------------------------------ */

function BackToTop(p: FloatingActionsProps & { t: Texts }) {
  const { t } = p;
  const s = useScroll();
  const shown = !s.ready || s.y > (p.threshold ?? 400);
  const pct = Math.round(s.progress * 100);
  const R = 21;
  const C = round(2 * Math.PI * R);
  return (
    <a
      href="#top"
      onClick={(e) => {
        e.preventDefault();
        s.scrollTop();
      }}
      aria-label={t.backToTop}
      tabIndex={shown ? undefined : -1}
      aria-hidden={shown ? undefined : true}
      className={cn(
        s.contained ? 'absolute' : 'fixed',
        'group/top bottom-5 right-5 z-40 flex h-12 items-center rounded-full border border-border bg-card/90 text-foreground shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md sm:bottom-6 sm:right-6',
        MOVE,
        shown ? 'translate-y-0 scale-100 opacity-100' : 'pointer-events-none translate-y-4 scale-90 opacity-0',
        p.className,
      )}
    >
      {p.showPercent && (
        <span className="grid max-w-0 overflow-hidden whitespace-nowrap text-[13px] font-medium tabular-nums text-muted-foreground opacity-0 transition-[max-width,opacity,padding] duration-500 ease-[var(--ease-kit)] group-hover/top:max-w-24 group-hover/top:pl-4 group-hover/top:opacity-100 group-focus-visible/top:max-w-24 group-focus-visible/top:pl-4 group-focus-visible/top:opacity-100 max-sm:hidden">
          {t.percent.replace('{n}', String(pct))}
        </span>
      )}
      <span className="relative grid size-12 place-items-center">
        <svg viewBox="0 0 48 48" className="absolute inset-0 -rotate-90" aria-hidden>
          <circle cx="24" cy="24" r={R} fill="none" stroke="var(--border)" strokeWidth="2" />
          <circle cx="24" cy="24" r={R} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" strokeDasharray={C} strokeDashoffset={round(C * (1 - s.progress))} className="transition-[stroke-dashoffset] duration-200" />
        </svg>
        <Icon name="arrow-up" size={18} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/top:-translate-y-0.5" />
      </span>
    </a>
  );
}

/* ------------------------------------------------------------------ */
/* reading-progress                                                    */
/* ------------------------------------------------------------------ */

function ReadingProgress(p: FloatingActionsProps & { t: Texts }) {
  const { t } = p;
  const s = useScroll();
  const shown = !s.ready || s.y > (p.threshold ?? 240);
  const left = p.minutes ? Math.max(0, Math.ceil(p.minutes * (1 - s.progress))) : null;
  const pos = s.contained ? 'absolute' : 'fixed';
  return (
    <>
      <div role="progressbar" aria-label={t.reading} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(s.progress * 100)} className={cn(pos, 'inset-x-0 top-0 z-50 h-[3px] bg-transparent')}>
        <div className="h-full origin-left bg-primary shadow-[0_0_12px_var(--glow)] transition-transform duration-150 motion-reduce:transition-none" style={{ transform: `scaleX(${round(s.progress, 4)})` }} />
      </div>
      {p.title && (
        <div aria-hidden={shown ? undefined : true} className={cn(pos, 'inset-x-0 top-0 z-40 border-b border-border bg-background/85 backdrop-blur-md', MOVE, shown ? 'translate-y-0 opacity-100' : 'pointer-events-none -translate-y-full opacity-0', p.className)}>
          <div className="mx-auto flex h-14 max-w-[calc(var(--container-kit)+4rem)] items-center gap-4 px-4 sm:px-6 lg:px-8">
            <p className="min-w-0 flex-1 truncate text-[14px] font-medium text-foreground">{p.title}</p>
            {left !== null && (
              <span className="flex shrink-0 items-center gap-2 text-[13px] tabular-nums text-muted-foreground">
                <span key={left} className="kit-rise">
                  {left === 0 ? t.done : t.minLeft.replace('{n}', String(left))}
                </span>
              </span>
            )}
            <a
              href="#top"
              onClick={(e) => {
                e.preventDefault();
                s.scrollTop();
              }}
              tabIndex={shown ? undefined : -1}
              aria-label={t.backToTop}
              className="grid size-10 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:size-11"
            >
              <Icon name="arrow-up" size={17} />
            </a>
          </div>
        </div>
      )}
    </>
  );
}

/* ------------------------------------------------------------------ */
/* mobile-action-bar                                                   */
/* ------------------------------------------------------------------ */

function StatusDot({ text }: { text: string }) {
  return (
    <span className="flex items-center gap-2 whitespace-nowrap text-[13px] font-medium text-foreground">
      <span className="relative flex size-2">
        <span className="absolute inset-0 rounded-full bg-success opacity-60 motion-safe:animate-ping" />
        <span className="relative size-2 rounded-full bg-success" />
      </span>
      {text}
    </span>
  );
}

function MobileActionBar(p: FloatingActionsProps & { t: Texts }) {
  const { t } = p;
  const s = useScroll();
  const actions = p.actions ?? [];
  // Hides while reading down, comes back on the first move up
  const hidden = s.ready && s.down && s.y > 160;
  return (
    <nav
      aria-label={t.actions}
      className={cn(
        s.contained ? 'absolute' : 'fixed',
        'z-40 inset-x-0 bottom-0 sm:inset-x-auto sm:bottom-6 sm:left-1/2 sm:-translate-x-1/2',
        MOVE,
        hidden ? 'translate-y-[120%] sm:translate-y-[calc(100%+2rem)]' : 'translate-y-0',
        p.className,
      )}
    >
      {/* Phones: a full-width bar with big targets. Wider screens: a floating pill with the status. */}
      <div className="border-t border-border bg-background/92 pb-[env(safe-area-inset-bottom)] shadow-[0_-8px_24px_-12px_hsl(var(--shadow-color)/0.25)] backdrop-blur-md sm:flex sm:items-center sm:gap-2 sm:rounded-2xl sm:border sm:bg-card/92 sm:p-2 sm:shadow-[var(--highlight),var(--shadow-lg)]">
        {p.status && (
          <span className="hidden pl-3 pr-2 sm:block">
            <StatusDot text={p.status} />
          </span>
        )}
        <ul className="grid sm:flex sm:gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.max(1, actions.length)}, minmax(0, 1fr))` }}>
          {actions.map((a, i) => (
            <li key={i}>
              <a
                href={a.href}
                className={cn(
                  'group/act flex h-16 flex-col items-center justify-center gap-1 text-[12px] font-medium transition-colors sm:h-11 sm:flex-row sm:gap-2 sm:rounded-xl sm:px-4 sm:text-[14px]',
                  a.primary ? 'bg-primary text-primary-foreground sm:shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)]' : 'text-foreground hover:bg-accent',
                )}
              >
                <Icon name={a.icon} size={19} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/act:-translate-y-0.5 sm:size-[17px] sm:group-hover/act:translate-y-0 sm:group-hover/act:scale-110" />
                <span className="whitespace-nowrap">{a.label}</span>
              </a>
            </li>
          ))}
        </ul>
      </div>
    </nav>
  );
}

/* ------------------------------------------------------------------ */
/* sticky-cta                                                          */
/* ------------------------------------------------------------------ */

function StickyCta(p: FloatingActionsProps & { t: Texts }) {
  const { t } = p;
  const s = useScroll();
  const [closed, setClosed] = useState(false);
  const shown = !closed && (!s.ready || s.y > (p.threshold ?? 520));
  const pos = s.contained ? 'absolute' : 'fixed';
  return (
    <div
      role="region"
      aria-label={p.title}
      aria-hidden={shown ? undefined : true}
      inert={!shown}
      className={cn(
        pos,
        'z-40 inset-x-0 bottom-0 border-t border-border bg-background/90 shadow-[0_-10px_30px_-14px_hsl(var(--shadow-color)/0.3)] backdrop-blur-md lg:bottom-auto lg:top-0 lg:border-b lg:border-t-0 lg:shadow-[0_10px_30px_-18px_hsl(var(--shadow-color)/0.35)]',
        MOVE,
        shown ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-full opacity-0 lg:-translate-y-full',
        p.className,
      )}
    >
      <div className="mx-auto flex max-w-[calc(var(--container-kit)+4rem)] items-center gap-3 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:gap-5 sm:px-6 lg:px-8 lg:py-3">
        <div className="min-w-0 flex-1">
          <p className="line-clamp-2 text-[14px] font-semibold leading-snug text-foreground sm:truncate sm:text-[14.5px]">{p.title}</p>
          {p.text && <p className="truncate text-[13px] text-muted-foreground max-sm:hidden">{p.text}</p>}
        </div>
        {p.secondaryAction && (
          <Button href={p.secondaryAction.href} variant="ghost" size="sm" className="max-md:hidden">
            {p.secondaryAction.label}
          </Button>
        )}
        {p.action && (
          <Button href={p.action.href} size="sm" icon="arrow-right" className="shrink-0">
            {p.action.label}
          </Button>
        )}
        {p.dismissible && (
          <button type="button" onClick={() => setClosed(true)} aria-label={t.close} className="-mr-1 grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:size-9">
            <Icon name="x" size={17} />
          </button>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* contact-dock                                                        */
/* ------------------------------------------------------------------ */

function ContactDock(p: FloatingActionsProps & { t: Texts }) {
  const { t } = p;
  const s = useScroll();
  const [open, setOpen] = useState(false);
  const id = useId();
  const rootRef = useRef<HTMLDivElement>(null);
  const toggleRef = useRef<HTMLButtonElement>(null);
  const actions = p.actions ?? [];
  // The label folds away while reading down and returns at the top or on the way up
  const compact = s.ready && s.y > 120 && s.down && !open;

  useEffect(() => {
    if (!open) return;
    rootRef.current?.querySelector<HTMLElement>('[data-dock-item]')?.focus();
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      setOpen(false);
      toggleRef.current?.focus();
    };
    const onDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('keydown', onKey);
    document.addEventListener('pointerdown', onDown);
    return () => {
      document.removeEventListener('keydown', onKey);
      document.removeEventListener('pointerdown', onDown);
    };
  }, [open]);

  return (
    <div ref={rootRef} className={cn(s.contained ? 'absolute' : 'fixed', 'bottom-5 right-5 z-40 flex flex-col items-end gap-3 sm:bottom-6 sm:right-6', p.className)}>
      <div
        id={id}
        role="dialog"
        aria-label={p.heading ?? p.label}
        inert={!open}
        className={cn(
          'w-[min(20rem,calc(100vw-2.5rem))] origin-bottom-right rounded-2xl border border-border bg-popover p-2 shadow-[var(--highlight),var(--shadow-lg)]',
          MOVE,
          open ? 'translate-y-0 scale-100 opacity-100' : 'pointer-events-none translate-y-3 scale-95 opacity-0',
        )}
      >
        {(p.heading || p.status) && (
          <div className="px-3 pb-2 pt-2.5">
            {p.heading && <p className="text-[15px] font-semibold text-foreground">{p.heading}</p>}
            {p.status && (
              <div className="mt-1.5">
                <StatusDot text={p.status} />
              </div>
            )}
          </div>
        )}
        <ul className="space-y-0.5">
          {actions.map((a, i) => (
            <li key={i} className={cn(MOVE, open ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0')} style={{ transitionDelay: open ? `${80 + i * 50}ms` : '0ms' }}>
              <a data-dock-item href={a.href} className="group/item flex min-h-14 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-accent focus-visible:bg-accent">
                <span className={cn('grid size-10 shrink-0 place-items-center rounded-xl transition-transform duration-300 ease-[var(--ease-kit)] group-hover/item:scale-105', a.primary ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}>
                  <Icon name={a.icon} size={18} />
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block text-[14.5px] font-medium text-foreground">{a.label}</span>
                  {a.detail && <span className="block truncate text-[13px] text-muted-foreground">{a.detail}</span>}
                </span>
                <Icon name="arrow-right" size={15} className="text-subtle-foreground opacity-0 transition-[opacity,transform] duration-300 group-hover/item:translate-x-0.5 group-hover/item:opacity-100" />
              </a>
            </li>
          ))}
        </ul>
      </div>
      <button
        ref={toggleRef}
        type="button"
        aria-expanded={open}
        aria-controls={id}
        aria-label={open ? t.closeContact : p.label ? undefined : t.openContact}
        onClick={() => setOpen((v) => !v)}
        className="kit-shine group/dock relative flex h-14 items-center rounded-full bg-primary pl-4 pr-4 text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-lg)] transition-transform duration-200 ease-[var(--ease-kit)] active:scale-95"
      >
        <span className="relative grid size-6 place-items-center">
          <Icon name="chat" size={22} className={cn('absolute transition-[transform,opacity] duration-300 ease-[var(--ease-kit)]', open ? 'rotate-90 scale-50 opacity-0' : 'opacity-100')} />
          <Icon name="x" size={22} className={cn('absolute transition-[transform,opacity] duration-300 ease-[var(--ease-kit)]', open ? 'opacity-100' : '-rotate-90 scale-50 opacity-0')} />
        </span>
        {p.label && (
          <span className={cn('grid overflow-hidden whitespace-nowrap text-[15px] font-medium transition-[max-width,opacity,padding] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', compact || open ? 'max-w-0 pl-0 opacity-0' : 'max-w-48 pl-2.5 pr-1 opacity-100')}>{p.label}</span>
        )}
      </button>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* FloatingActions                                                     */
/* ------------------------------------------------------------------ */

const VARIANTS: Record<FloatingActionsVariant, (p: FloatingActionsProps & { t: Texts }) => React.ReactNode> = {
  'back-to-top': BackToTop,
  'reading-progress': ReadingProgress,
  'mobile-action-bar': MobileActionBar,
  'sticky-cta': StickyCta,
  'contact-dock': ContactDock,
};

/**
 * Helpers that float over the page and follow the scroll.
 *   <FloatingActions variant="back-to-top" showPercent />
 *   <FloatingActions variant="mobile-action-bar" actions={[{ icon: 'phone', label: 'Call', href: 'tel:…' }]} />
 */
export function FloatingActions({ variant = 'back-to-top', labels, ...props }: FloatingActionsProps) {
  const t = useTexts(TEXTS, labels);
  const View = VARIANTS[variant] ?? BackToTop;
  return <View {...props} t={t} />;
}

Frequent questions

How do I install Floating actions?

With aiw add @ai-website/floating-actions. The command puts components/ui/floating-actions.tsx into your project, the code is yours from then on. It brings along button and icon. It needs no further npm packages.

Which variants does Floating actions have?

5 variants, Back to top, Reading progress, Action bar, Sticky call to action and Contact dock. You pick one with the variant prop, for example variant="reading-progress".

Can I translate the texts of Floating actions?

Yes. Floating actions carries 9 texts of its own in English and German, such as “Back to top”, “Finished” and “Reading progress”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Floating actions?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Floating actions with aiw_components and install it with aiw_add @ai-website/floating-actions. Name the variant in your prompt, for example variant="back-to-top", and the assistant writes your content straight into the props.

Can I adjust Floating actions without code?

Yes, in the Studio. 4 of 5 variants are editable there, texts, lists and switches. The Studio writes the matching FloatingActions code to copy.

Often searched

Jump to

Copied to the clipboard