Zum Inhalt springen
UI Kit
Accent

Sticky Subnav

Navigation innerhalb einer langen Seite. Sie weiß, welchen Abschnitt du liest, springt per Klick dorthin und bleibt in Reichweite. Pills, eine Produktleiste, ein Inhaltsverzeichnis, Kapitel-Segmente und Randpunkte, jede mit eigenem Telefon-Layout.

Eine von 2 Components in Unternavigation

5 Variants

Variants

Pills

Eine Reihe Pills unter dem Seitenkopf, die oben stehen bleibt. Das Licht gleitet zum Abschnitt, den du liest, auf dem Telefon rollt die Reihe mit.

Im Studio öffnen

Produktleiste

Name und Preis links, Links mit gleitender Unterstreichung, die Hauptaktion rechts. Sobald der erste Abschnitt vorbei ist, füllt sich die Aktion.

Im Studio öffnen

Inhaltsverzeichnis

Eine Spalte neben dem Text mit einer Schiene, die sich beim Lesen füllt, und einer Marke am aktuellen Teil. Unter 1024 px wird sie zur Leiste oben, die die Liste aufklappt.

Im Studio öffnen

Kapitel

Eine Leiste aus Segmenten, jedes füllt sich, während du sein Kapitel liest. Nummern und Namen auf großen Bildschirmen, auf dem Telefon das aktuelle Kapitel und seine Stelle.

Im Studio öffnen

Randpunkte

Punkte am rechten Rand für Onepager, ein Etikett gleitet bei Hover und Fokus heraus. Telefone bekommen unten einen Chip mit Abschnittsnamen und Pfeilen.

Im Studio öffnen

Details

Was Sticky Subnav mitbringt, aus Registry und Quellcode gelesen.

Bereich
Layout
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/sticky-subnav.tsx
Bringt mit
utilskitbuttonicon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 6 eigene Texte

Props

Die Schnittstelle StickySubnavProps aus components/ui/sticky-subnav.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    StickySubnavVariant

    pillsproducttocchaptersdots

  • itemsPflicht
    SubnavItem[]
  • titleoptional
    string

    product: name and a short line next to it, e.g. a price

  • subtitleoptional
    string
  • actionoptional
    { label: string; href: string }

    product: the main action

  • revealAfteroptional
    string

    product: the id of the element after which the action fills. Default: the first section.

  • offsetoptional
    number

    Pixels between the top of the window and a section after a jump, for a site header above. Default: the height of the bar.

  • headingoptional
    string

    toc: the heading above the list

  • showLabelsoptional
    boolean

    dots: show every label next to its dot, not only on hover and focus

Eingebaute Texte

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

Alle 6 Texte anzeigen
navEnglisch On this pageDeutsch Auf dieser Seite
openEnglisch Show sectionsDeutsch Abschnitte zeigen
closeEnglisch Hide sectionsDeutsch Abschnitte ausblenden
prevEnglisch Previous sectionDeutsch Voriger Abschnitt
nextEnglisch Next sectionDeutsch Nächster Abschnitt
ofEnglisch ofDeutsch von

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-controls, aria-current, aria-expanded und aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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. 7 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 441 Zeilen
components/ui/sticky-subnav.tsx
'use client';

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

/*
 * Navigation inside one long page. It knows which section you are reading
 * (scroll spy), jumps there on a click and stays in reach while you scroll.
 *
 *   pills     a row of pills under the page head, the light glides to the section you read
 *   product   a bar with the product name, links with a gliding underline and the main action,
 *             which turns into a filled button once the head has scrolled away
 *   toc       a table of contents in a side column with a reading rail. Below 1024 px it
 *             becomes a bar at the top that opens the list
 *   chapters  one bar of segments that fill while you read each chapter
 *   dots      dots at the right edge with labels on hover. Phones get a chip at the
 *             bottom with the current section and arrows
 *
 * Give every section an id and pass the same ids in items. The server renders
 * the first item as active, the browser takes over from there.
 */

export type StickySubnavVariant = 'pills' | 'product' | 'toc' | 'chapters' | 'dots';

export interface SubnavItem {
  /** The id of the section on the page, without # */
  id: string;
  label: string;
  /** toc: a level for sub sections (1 or 2) */
  level?: 1 | 2;
}

export interface StickySubnavProps {
  variant?: StickySubnavVariant;
  items: SubnavItem[];
  /** product: name and a short line next to it, e.g. a price */
  title?: string;
  subtitle?: string;
  /** product: the main action */
  action?: { label: string; href: string };
  /** product: the id of the element after which the action fills. Default: the first section. */
  revealAfter?: string;
  /** Pixels between the top of the window and a section after a jump, for a site header above. Default: the height of the bar. */
  offset?: number;
  /** toc: the heading above the list */
  heading?: string;
  /** dots: show every label next to its dot, not only on hover and focus */
  showLabels?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: { nav: 'On this page', open: 'Show sections', close: 'Hide sections', prev: 'Previous section', next: 'Next section', of: 'of' },
  de: { nav: 'Auf dieser Seite', open: 'Abschnitte zeigen', close: 'Abschnitte ausblenden', prev: 'Voriger Abschnitt', next: 'Nächster Abschnitt', of: 'von' },
};

const useIsoLayout = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/* ------------------------------------------------------------------ */
/* Scroll spy                                                          */
/* ------------------------------------------------------------------ */

/**
 * Which section is read right now, and how far each one is read (0 to 1).
 * A section counts as read from the moment its top passes a line at 30 percent
 * of the window. Written once per frame at most.
 */
function useScrollSpy(ids: string[]) {
  const [active, setActive] = useState(0);
  // After a jump the picked section stays active, even if the page cannot scroll it
  // up to the line (short last sections). The visitor's own scrolling releases it.
  const pinned = useRef<number | null>(null);
  const [progress, setProgress] = useState<number[]>(() => ids.map(() => 0));
  const key = ids.join('|');
  useEffect(() => {
    let frame = 0;
    const update = () => {
      frame = 0;
      const line = window.innerHeight * 0.3;
      const els = ids.map((id) => document.getElementById(id));
      let current = 0;
      const prog = els.map((el, i) => {
        if (!el) return 0;
        const r = el.getBoundingClientRect();
        if (r.top <= line) current = i;
        return Math.min(1, Math.max(0, (line - r.top) / Math.max(1, r.height)));
      });
      // At the very end of the page the last section counts, even if it is short, and every section in view is read
      const atEnd = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
      if (atEnd) current = els.length - 1;
      setActive(pinned.current ?? current);
      setProgress(prog.map((p, i) => (atEnd && els[i] && els[i]!.getBoundingClientRect().top < window.innerHeight ? 1 : round(p, 3))));
    };
    const release = () => {
      pinned.current = null;
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    window.addEventListener('wheel', release, { passive: true });
    window.addEventListener('touchmove', release, { passive: true });
    window.addEventListener('keydown', release);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      window.removeEventListener('wheel', release);
      window.removeEventListener('touchmove', release);
      window.removeEventListener('keydown', release);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [key]);
  const pin = useCallback((i: number) => {
    pinned.current = i;
    setActive(i);
  }, []);
  return { active, progress, pin };
}

/** Smooth jump to a section, below whatever sticks at the top */
function jump(id: string, offset: number, e?: React.MouseEvent) {
  const el = document.getElementById(id);
  if (!el) return;
  e?.preventDefault();
  const y = el.getBoundingClientRect().top + window.scrollY - offset;
  window.scrollTo({ top: y, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  history.replaceState(null, '', `#${id}`);
}

/** A marker measured from the active link inside a scrolling row */
function useMarker(row: React.RefObject<HTMLElement | null>, active: number) {
  const [box, setBox] = useState<{ x: number; w: number; total: number; glide: boolean } | null>(null);
  const measure = useCallback(() => {
    const r = row.current;
    const el = r?.querySelector<HTMLElement>('[aria-current="location"]');
    if (!r || !el) return;
    setBox((b) => ({ x: round(el.offsetLeft), w: round(el.offsetWidth), total: r.scrollWidth, glide: b !== null }));
  }, [row]);
  useIsoLayout(() => {
    measure();
    const r = row.current;
    const el = r?.querySelector<HTMLElement>('[aria-current="location"]');
    // Keep the active link in view inside a row that scrolls sideways
    if (r && el && r.scrollWidth > r.clientWidth) {
      const left = el.offsetLeft - r.clientWidth / 2 + el.offsetWidth / 2;
      r.scrollTo({ left, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
    }
  }, [active, measure]);
  useEffect(() => {
    window.addEventListener('resize', measure);
    document.fonts?.ready.then(measure).catch(() => {});
    return () => window.removeEventListener('resize', measure);
  }, [measure]);
  return box;
}

/** Fade the side of a row that still has more */
function useRowFade(row: React.RefObject<HTMLElement | null>) {
  const [fade, setFade] = useState('');
  useEffect(() => {
    const r = row.current;
    if (!r) return;
    const update = () => {
      const max = r.scrollWidth - r.clientWidth;
      setFade(max <= 1 ? '' : r.scrollLeft <= 1 ? 'kit-fade-end' : r.scrollLeft >= max - 1 ? 'kit-fade-start' : 'kit-fade-x');
    };
    update();
    r.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    return () => {
      r.removeEventListener('scroll', update);
      window.removeEventListener('resize', update);
    };
  }, [row]);
  return fade;
}

/* ------------------------------------------------------------------ */
/* Variants                                                            */
/* ------------------------------------------------------------------ */

function Pills({ items, active, go, t, className }: { items: SubnavItem[]; active: number; go: Go; t: Texts; className?: string }) {
  const row = useRef<HTMLDivElement>(null);
  const box = useMarker(row, active);
  const fade = useRowFade(row);
  return (
    <nav aria-label={t.nav} className={cn('sticky top-0 z-30 border-b border-border bg-[color-mix(in_oklab,var(--background)_84%,transparent)] backdrop-blur-md', className)}>
      <div className="kit-container py-2.5">
        <div ref={row} className={cn('kit-no-scrollbar relative -mx-1 flex gap-1 overflow-x-auto px-1 [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]', fade)}>
          {box && (
            <span
              aria-hidden
              className={cn('pointer-events-none absolute inset-y-0 left-0 bg-accent', box.glide && 'transition-[clip-path] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
              style={{ width: box.total, clipPath: `inset(0 ${round(box.total - box.x - box.w)}px 0 ${box.x}px round 999px)` }}
            />
          )}
          {items.map((it, i) => (
            <a
              key={it.id}
              href={`#${it.id}`}
              aria-current={i === active ? 'location' : undefined}
              onClick={(e) => go(it.id, e)}
              className={cn(
                'relative h-11 shrink-0 whitespace-nowrap rounded-full px-4 text-sm font-medium leading-[2.75rem] outline-none transition-colors duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ring)] sm:h-10 sm:leading-10',
                i === active ? cn('text-foreground', !box && 'bg-accent') : 'text-muted-foreground hover:text-foreground',
              )}
            >
              {it.label}
            </a>
          ))}
        </div>
      </div>
    </nav>
  );
}

function Product({ items, active, go, offset, title, subtitle, action, revealAfter, t, className }: { items: SubnavItem[]; active: number; go: Go; offset: number; title?: string; subtitle?: string; action?: StickySubnavProps['action']; revealAfter?: string; t: Texts; className?: string }) {
  const row = useRef<HTMLDivElement>(null);
  const box = useMarker(row, active);
  const fade = useRowFade(row);
  const [past, setPast] = useState(false);
  useEffect(() => {
    const id = revealAfter ?? items[0]?.id;
    const el = id ? document.getElementById(id) : null;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => setPast(e.boundingClientRect.top < offset + 1), { rootMargin: `-${offset + 1}px 0px 0px 0px`, threshold: [0, 1] });
    io.observe(el);
    return () => io.disconnect();
  }, [revealAfter, items, offset]);
  return (
    <nav aria-label={t.nav} className={cn('sticky top-0 z-30 border-b border-border bg-[color-mix(in_oklab,var(--background)_84%,transparent)] backdrop-blur-md', className)}>
      <div className="kit-container flex h-14 items-center gap-4 sm:h-16 sm:gap-8">
        {title && (
          <div className="min-w-0 shrink-0 max-md:hidden">
            <p className="truncate text-[15px] font-semibold tracking-[-0.015em] text-foreground">{title}</p>
            {subtitle && <p className="truncate text-[12px] tabular-nums text-muted-foreground">{subtitle}</p>}
          </div>
        )}
        <div ref={row} className={cn('kit-no-scrollbar relative flex min-w-0 flex-1 items-center gap-1 self-stretch overflow-x-auto md:justify-center [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]', fade)}>
          {items.map((it, i) => (
            <a
              key={it.id}
              href={`#${it.id}`}
              aria-current={i === active ? 'location' : undefined}
              onClick={(e) => go(it.id, e)}
              className={cn(
                'relative flex h-full shrink-0 items-center whitespace-nowrap px-3 text-[13px] font-medium outline-none transition-colors duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-[var(--ring)] sm:text-sm',
                i === active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
              )}
            >
              {it.label}
              {!box && i === active && <span aria-hidden className="absolute inset-x-3 bottom-0 h-0.5 rounded-full bg-foreground" />}
            </a>
          ))}
          {box && (
            <span
              aria-hidden
              className={cn('pointer-events-none absolute bottom-0 left-0 h-0.5 w-px origin-left bg-foreground', box.glide && 'transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
              style={{ transform: `translateX(${round(box.x + 12)}px) scaleX(${Math.max(0, round(box.w - 24))})` }}
            />
          )}
        </div>
        {action && (
          <Button href={action.href} size="sm" variant={past ? 'primary' : 'outline'} className="shrink-0 transition-[background-color,color,box-shadow,border-color] duration-500">
            {action.label}
          </Button>
        )}
      </div>
    </nav>
  );
}

function Toc({ items, active, progress, go, heading, t, className }: { items: SubnavItem[]; active: number; progress: number[]; go: Go; heading?: string; t: Texts; className?: string }) {
  const [open, setOpen] = useState(false);
  const listId = useId();
  const total = round(progress.reduce((a, b) => a + b, 0) / Math.max(1, items.length), 3);
  const link = (it: SubnavItem, i: number, onPick?: () => void) => (
    <li key={it.id}>
      <a
        href={`#${it.id}`}
        aria-current={i === active ? 'location' : undefined}
        onClick={(e) => {
          go(it.id, e);
          onPick?.();
        }}
        className={cn(
          'group/toc relative flex min-h-11 items-center rounded-md py-1.5 pr-2 text-[14px] leading-snug outline-none transition-colors duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)] lg:min-h-9',
          it.level === 2 ? 'pl-8' : 'pl-4',
          i === active ? 'font-medium text-foreground' : 'text-muted-foreground hover:text-foreground',
        )}
      >
        <span aria-hidden className={cn('absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-primary transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', i === active ? 'scale-y-100 opacity-100' : 'scale-y-0 opacity-0')} />
        <span className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/toc:translate-x-0.5 motion-reduce:transition-none">{it.label}</span>
      </a>
    </li>
  );
  return (
    <>
      {/* Desktop: a column next to the text */}
      <nav aria-label={heading ?? t.nav} className={cn('sticky top-24 max-lg:hidden', className)}>
        <p className="mb-3 pl-4 text-[12px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">{heading ?? t.nav}</p>
        <div className="relative">
          <span aria-hidden className="absolute inset-y-0 left-0 w-px bg-border" />
          <span aria-hidden className="absolute inset-x-0 left-0 top-0 h-full w-px origin-top bg-[color-mix(in_oklab,var(--primary)_45%,transparent)]" style={{ transform: `scaleY(${total})` }} />
          <ul>{items.map((it, i) => link(it, i))}</ul>
        </div>
      </nav>
      {/* Tablet and phone: a bar at the top that opens the list */}
      <nav aria-label={heading ?? t.nav} className="sticky top-0 z-30 -mx-6 border-b border-border bg-[color-mix(in_oklab,var(--background)_88%,transparent)] px-6 backdrop-blur-md lg:hidden">
        <button type="button" aria-expanded={open} aria-controls={listId} onClick={() => setOpen((o) => !o)} className="flex h-12 w-full items-center gap-3 text-left outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ring)]">
          <span className="shrink-0 text-[13px] text-muted-foreground">{heading ?? t.nav}</span>
          <span aria-hidden className="h-3.5 w-px shrink-0 bg-border-strong" />
          <span key={active} className="kit-rise min-w-0 flex-1 truncate text-[14px] font-medium text-foreground [--kit-delay:0ms]">
            {items[active]?.label}
          </span>
          <Icon name="chevron-down" size={16} className={cn('shrink-0 text-muted-foreground transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', open && 'rotate-180')} />
          <span className="sr-only">{open ? t.close : t.open}</span>
        </button>
        <span aria-hidden className="absolute inset-x-0 bottom-0 h-0.5 origin-left bg-primary" style={{ transform: `scaleX(${total})` }} />
        <div id={listId} hidden={!open} className="pb-3">
          <ul>{items.map((it, i) => link(it, i, () => setOpen(false)))}</ul>
        </div>
      </nav>
    </>
  );
}

function Chapters({ items, active, progress, go, t, className }: { items: SubnavItem[]; active: number; progress: number[]; go: Go; t: Texts; className?: string }) {
  return (
    <nav aria-label={t.nav} className={cn('sticky top-0 z-30 border-b border-border bg-[color-mix(in_oklab,var(--background)_88%,transparent)] backdrop-blur-md', className)}>
      <div className="kit-container">
        {/* Phones: the chapter you read and where it sits */}
        <div className="flex h-11 items-center justify-between gap-3 text-[13px] sm:hidden" aria-hidden>
          <span key={active} className="kit-rise truncate font-medium text-foreground [--kit-delay:0ms]">{items[active]?.label}</span>
          <span className="shrink-0 tabular-nums text-muted-foreground">
            {active + 1} {t.of} {items.length}
          </span>
        </div>
        <ol className="grid gap-1.5 pb-2.5 sm:gap-2 sm:py-3" style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}>
          {items.map((it, i) => (
            <li key={it.id} className="min-w-0">
              <a href={`#${it.id}`} aria-current={i === active ? 'location' : undefined} onClick={(e) => go(it.id, e)} className="group/ch relative block rounded-md outline-none before:absolute before:inset-x-0 before:-inset-y-3 before:content-[''] focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)] max-sm:py-2">
                <span className="relative block h-1 overflow-hidden rounded-full bg-track">
                  <span className="absolute inset-0 origin-left rounded-full bg-primary" style={{ transform: `scaleX(${progress[i] ?? 0})` }} />
                </span>
                <span className={cn('mt-2 block truncate text-[12px] font-medium transition-colors duration-300 max-sm:sr-only', i === active ? 'text-foreground' : i < active ? 'text-muted-foreground' : 'text-subtle-foreground group-hover/ch:text-muted-foreground')}>
                  <span className="mr-1.5 tabular-nums text-subtle-foreground max-sm:hidden">{String(i + 1).padStart(2, '0')}</span>
                  {it.label}
                </span>
              </a>
            </li>
          ))}
        </ol>
      </div>
    </nav>
  );
}

function Dots({ items, active, go, showLabels, t, className }: { items: SubnavItem[]; active: number; go: Go; showLabels?: boolean; t: Texts; className?: string }) {
  const step = (i: number) => {
    const n = Math.min(items.length - 1, Math.max(0, i));
    go(items[n].id);
  };
  return (
    <>
      <nav aria-label={t.nav} className={cn('fixed right-4 top-1/2 z-30 -translate-y-1/2 max-sm:hidden lg:right-6', className)}>
        <ul className="flex flex-col items-end gap-1">
          {items.map((it, i) => (
            <li key={it.id}>
              <a href={`#${it.id}`} aria-current={i === active ? 'location' : undefined} onClick={(e) => go(it.id, e)} className="group/dot flex h-9 items-center gap-3 rounded-full pl-3 outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)]">
                <span
                  className={cn(
                    'pointer-events-none whitespace-nowrap rounded-md border border-border bg-popover px-2.5 py-1 text-[12px] font-medium text-foreground shadow-[var(--highlight),var(--shadow-md)] transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] group-hover/dot:translate-x-0 group-hover/dot:opacity-100 group-focus-visible/dot:translate-x-0 group-focus-visible/dot:opacity-100 motion-reduce:transition-none',
                    showLabels ? (i === active ? 'opacity-100' : 'opacity-70') : 'translate-x-1 opacity-0',
                  )}
                >
                  {it.label}
                </span>
                <span className="grid size-5 place-items-center">
                  <span className={cn('rounded-full transition-[transform,background-color] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', i === active ? 'size-2.5 bg-primary shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_22%,transparent)]' : 'size-2 bg-border-strong group-hover/dot:scale-125 group-hover/dot:bg-muted-foreground')} />
                </span>
              </a>
            </li>
          ))}
        </ul>
      </nav>
      {/* Phones: a chip at the bottom with the current section and arrows */}
      <nav aria-label={t.nav} className="fixed inset-x-0 bottom-4 z-30 flex justify-center px-4 sm:hidden">
        <div className="flex max-w-full items-center gap-1 rounded-full border border-border bg-[color-mix(in_oklab,var(--popover)_90%,transparent)] p-1 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md">
          <button type="button" aria-label={t.prev} disabled={active === 0} onClick={() => step(active - 1)} className="grid size-11 shrink-0 place-items-center rounded-full text-foreground transition-colors hover:bg-accent disabled:opacity-35">
            <Icon name="arrow-up" size={18} />
          </button>
          <div className="min-w-0 px-2 text-center">
            <p key={active} className="kit-rise truncate text-[13px] font-medium text-foreground [--kit-delay:0ms]">
              {items[active]?.label}
            </p>
            <div className="mt-1 flex justify-center gap-1" aria-hidden>
              {items.map((it, i) => (
                <span key={it.id} className={cn('h-1 rounded-full transition-colors duration-500', i === active ? 'w-4 bg-primary' : 'w-1 bg-border-strong')} />
              ))}
            </div>
          </div>
          <button type="button" aria-label={t.next} disabled={active === items.length - 1} onClick={() => step(active + 1)} className="grid size-11 shrink-0 place-items-center rounded-full text-foreground transition-colors hover:bg-accent disabled:opacity-35">
            <Icon name="arrow-down" size={18} />
          </button>
        </div>
      </nav>
    </>
  );
}

type Texts = (typeof TEXTS)['en'];
type Go = (id: string, e?: React.MouseEvent) => void;

/**
 * Navigation inside a long page with scroll spy.
 *   <StickySubnav variant="pills" items={[{ id: 'overview', label: 'Overview' }, …]} />
 *   <section id="overview">…</section>
 */
export function StickySubnav({ variant = 'pills', items, title, subtitle, action, revealAfter, offset, heading, showLabels, labels, className }: StickySubnavProps) {
  const t = useTexts(TEXTS, labels);
  const { active, progress, pin } = useScrollSpy(items.map((i) => i.id));
  const off = offset ?? (variant === 'product' ? 72 : variant === 'pills' ? 72 : variant === 'chapters' ? 64 : variant === 'toc' ? 24 : 0);
  // A jump pins the picked section, see useScrollSpy
  const go: Go = (id, e) => {
    pin(Math.max(0, items.findIndex((x) => x.id === id)));
    // The table of contents is a bar at the top below 1024 px, a jump then lands under it
    jump(id, offset === undefined && variant === 'toc' && window.innerWidth < 1024 ? 64 : off, e);
  };
  if (variant === 'product') return <Product items={items} active={active} go={go} offset={off} title={title} subtitle={subtitle} action={action} revealAfter={revealAfter} t={t} className={className} />;
  if (variant === 'toc') return <Toc items={items} active={active} progress={progress} go={go} heading={heading} t={t} className={className} />;
  if (variant === 'chapters') return <Chapters items={items} active={active} progress={progress} go={go} t={t} className={className} />;
  if (variant === 'dots') return <Dots items={items} active={active} go={go} showLabels={showLabels} t={t} className={className} />;
  return <Pills items={items} active={active} go={go} t={t} className={className} />;
}

Häufige Fragen

Wie installiere ich Sticky Subnav?

Mit aiw add @ai-website/sticky-subnav. Der Befehl legt die Datei components/ui/sticky-subnav.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 Sticky Subnav?

5 Varianten, Pills, Produktleiste, Inhaltsverzeichnis, Kapitel und Randpunkte. Du wählst sie mit der Prop variant, zum Beispiel variant="product".

Kann ich die Texte von Sticky Subnav übersetzen?

Ja. Sticky Subnav bringt 6 eigene Texte auf Deutsch und Englisch mit, etwa „Auf dieser Seite“, „Abschnitte zeigen“ und „Abschnitte ausblenden“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Sticky Subnav einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Sticky Subnav mit aiw_components und installieren sie mit aiw_add @ai-website/sticky-subnav. Nenn im Prompt die Variante, etwa variant="pills", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Sticky Subnav ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert