Sticky subnav
Navigation inside one long page. It knows which section you read, jumps there on a click and stays in reach. Pills, a product bar, a table of contents, chapter segments and edge dots, each with its own phone layout.
One of 2 components in Sub navigation
Variants
Details
What Sticky subnav brings along, read from the registry and the source.
- Category
- Sub navigation
- Area
- Layout
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/sticky-subnav.tsx
- npm packages
- None
- Languages
- English and German, 6 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The StickySubnavProps interface from components/ui/sticky-subnav.tsx, with the comments from the code.
variantoptionalStickySubnavVariantpillsproducttocchaptersdots
itemsrequiredSubnavItem[]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
Built-in texts
Sticky subnav writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 6 texts
| Key | English | German |
|---|---|---|
| nav | English On this page | German Auf dieser Seite |
| open | English Show sections | German Abschnitte zeigen |
| close | English Hide sections | German Abschnitte ausblenden |
| prev | English Previous section | German Voriger Abschnitt |
| next | English Next section | German Nächster Abschnitt |
| of | English of | German von |
Accessibility
Read from the source of the component.
- Sets aria-controls, aria-current, aria-expanded and aria-label, so screen readers announce purpose and state.
- Adds text for screen readers where icons and graphics stand alone.
- 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. 7 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, 441 lines
'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} />;
}Frequent questions
How do I install Sticky subnav?
With aiw add @ai-website/sticky-subnav. The command puts components/ui/sticky-subnav.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 Sticky subnav have?
5 variants, Pills, Product bar, Table of contents, Chapters and Edge dots. You pick one with the variant prop, for example variant="product".
Can I translate the texts of Sticky subnav?
Yes. Sticky subnav carries 6 texts of its own in English and German, such as “On this page”, “Show sections” and “Hide sections”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Sticky subnav?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sticky subnav with aiw_components and install it with aiw_add @ai-website/sticky-subnav. Name the variant in your prompt, for example variant="pills", and the assistant writes your content straight into the props.
Can I adjust Sticky subnav without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching StickySubnav code to copy.