Skip to content
UI Kit
Accent color

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

5 variants

Variants

Pills

A row of pills under the page head that sticks to the top. The light glides to the section you read, on phones the row follows it sideways.

Open in Studio

Product bar

Name and price on the left, links with a gliding underline, the main action on the right. Once the first section passes, the action fills.

Open in Studio

Table of contents

A column beside the text with a rail that fills as you read and a mark on the current part. Below 1024 px it turns into a bar at the top that opens the list.

Open in Studio

Chapters

One bar of segments, each fills while you read its chapter. Numbers and names on larger screens, on phones the current chapter and its place.

Open in Studio

Edge dots

Dots at the right edge for one-pagers, a label slides out on hover and focus. Phones get a chip at the bottom with the section name and arrows.

Open in Studio

Details

What Sticky subnav brings along, read from the registry and the source.

Area
Layout
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/sticky-subnav.tsx
Brings along
utilskitbuttonicon
npm packages
None
Languages
English and German, 6 texts of its own

Props

The StickySubnavProps interface from components/ui/sticky-subnav.tsx, with the comments from the code.

  • variantoptional
    StickySubnavVariant

    pillsproducttocchaptersdots

  • itemsrequired
    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

Built-in texts

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

Show all 6 texts
navEnglish On this pageGerman Auf dieser Seite
openEnglish Show sectionsGerman Abschnitte zeigen
closeEnglish Hide sectionsGerman Abschnitte ausblenden
prevEnglish Previous sectionGerman Voriger Abschnitt
nextEnglish Next sectionGerman Nächster Abschnitt
ofEnglish ofGerman 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
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} />;
}

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.

Often searched

Jump to

Copied to the clipboard