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
Variants
Details
Was Sticky Subnav mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Unternavigation
- Bereich
- Layout
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/sticky-subnav.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 6 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle StickySubnavProps aus components/ui/sticky-subnav.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalStickySubnavVariantpillsproducttocchaptersdots
itemsPflichtSubnavItem[]titleoptionalstringproduct: name and a short line next to it, e.g. a price
subtitleoptionalstringactionoptional{ label: string; href: string }product: the main action
revealAfteroptionalstringproduct: the id of the element after which the action fills. Default: the first section.
offsetoptionalnumberPixels between the top of the window and a section after a jump, for a site header above. Default: the height of the bar.
headingoptionalstringtoc: the heading above the list
showLabelsoptionalbooleandots: 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| nav | Englisch On this page | Deutsch Auf dieser Seite |
| open | Englisch Show sections | Deutsch Abschnitte zeigen |
| close | Englisch Hide sections | Deutsch Abschnitte ausblenden |
| prev | Englisch Previous section | Deutsch Voriger Abschnitt |
| next | Englisch Next section | Deutsch Nächster Abschnitt |
| of | Englisch of | Deutsch 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
'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.