Zum Inhalt springen
UI Kit
Accent

Schwebende Aktionen

Nach oben, Lesefortschritt, eine mobile Aktionsleiste, ein klebender Call to Action und ein Kontakt-Dock. Sie folgen dem Scrollen der Seite.

Eine von 3 Components in Menüleisten und Umschalter

5 Variants

Variants

Nach oben

Erscheint nach dem ersten Screen. Der Ring füllt sich mit der Seite, beim Hovern steht der gelesene Anteil daneben.

Lesefortschritt

Eine feine Linie oben füllt sich beim Lesen, eine Leiste mit Titel und verbleibenden Minuten gleitet herein.

Im Studio öffnen

Aktionsleiste

Anrufen, Route und Termin für ein lokales Geschäft. Auf dem Telefon eine volle Leiste, auf größeren Screens eine schwebende Pille mit Live-Status. Weicht beim Runterlesen aus.

Im Studio öffnen

Klebender Call to Action

Gleitet herein, sobald der Hero weg ist. Oben auf breiten Screens, unten am Daumen auf dem Telefon. Lässt sich schließen.

Im Studio öffnen

Kontakt-Dock

Ein beschrifteter Knopf, der beim Lesen zum Symbol wird und die Kontaktwege nacheinander öffnet. Escape schließt ihn.

Im Studio öffnen

Details

Was Schwebende Aktionen mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
5
Version
1.0.0 · seit 1.0.0
Datei
components/ui/floating-actions.tsx
Bringt mit
utilskitbuttonicon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 9 eigene Texte

Props

Die Schnittstelle FloatingActionsProps aus components/ui/floating-actions.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Schwebende Aktionen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 9 Texte anzeigen
backToTopEnglisch Back to topDeutsch Nach oben
percentEnglisch {n}%Deutsch {n} %
minLeftEnglisch {n} min leftDeutsch noch {n} Min.
doneEnglisch FinishedDeutsch Fertig gelesen
readingEnglisch Reading progressDeutsch Lesefortschritt
closeEnglisch CloseDeutsch Schließen
openContactEnglisch Open contact optionsDeutsch Kontaktwege öffnen
closeContactEnglisch Close contact optionsDeutsch Kontaktwege schließen
actionsEnglisch Quick actionsDeutsch Schnellaktionen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Schwebende Aktionen reagiert selbst auf die Tasten Escape.
  • Setzt aria-controls, aria-expanded, aria-label, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen dialog, progressbar und region.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 485 Zeilen
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} />;
}

Häufige Fragen

Wie installiere ich Schwebende Aktionen?

Mit aiw add @ai-website/floating-actions. Der Befehl legt die Datei components/ui/floating-actions.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button und icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Schwebende Aktionen?

5 Varianten, Nach oben, Lesefortschritt, Aktionsleiste, Klebender Call to Action und Kontakt-Dock. Du wählst sie mit der Prop variant, zum Beispiel variant="reading-progress".

Kann ich die Texte von Schwebende Aktionen übersetzen?

Ja. Schwebende Aktionen bringt 9 eigene Texte auf Deutsch und Englisch mit, etwa „Nach oben“, „Fertig gelesen“ und „Lesefortschritt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Schwebende Aktionen einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Schwebende Aktionen mit aiw_components und installieren sie mit aiw_add @ai-website/floating-actions. Nenn im Prompt die Variante, etwa variant="back-to-top", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Schwebende Aktionen ohne Code anpassen?

Ja, im Studio. 4 von 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden FloatingActions-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert