Skip to content
    Style: Wireframe

    Components · Navigation

    On this page

    A page's sections as links; the one you are reading is marked while you scroll.

    13% of the 2,073 sites in The UI Index have a sidebar (261 sites).

    Before you start

    You need a repository with a build command and an account on the free plan.

    Connect a repository

    Pick the repository and the branch to deploy. Every push to it builds a new version.

    Preview links

    Each pull request gets its own link, so a reviewer sees the change before it ships.

    Go live

    Promote a build to production. The previous one stays a click away if you need it back.

    Custom domains

    Point a CNAME at your project; the certificate is issued for you within a minute.

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/toc.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/toc.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.

    toc.client.ts
    // "On this page" for Astro, HTML and React: every [data-toc] marks the entry of the section you are reading with
    // aria-current="true": the last section whose heading has passed the top of the screen (data-toc-offset, default
    // 96px, clears a sticky header). Nothing is marked before the first section; at the end of the page, the last.
    declare global {
      interface Window {
        __caseroomToc?: boolean;
      }
    }
    
    const update = () => {
      const atEnd = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
      for (const nav of document.querySelectorAll<HTMLElement>('[data-toc]')) {
        const offset = Number(nav.dataset.tocOffset ?? 96);
        const links = [...nav.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')];
        let current: HTMLAnchorElement | undefined;
        for (const a of links) {
          const target = document.getElementById(decodeURIComponent(a.hash.slice(1)));
          if (!target) continue;
          const top = target.getBoundingClientRect().top;
          if (top - offset <= 1 || (atEnd && top < window.innerHeight)) current = a;
        }
        for (const a of links) {
          if (a === current) a.setAttribute('aria-current', 'true');
          else a.removeAttribute('aria-current');
        }
      }
    };
    
    if (typeof window !== 'undefined' && !window.__caseroomToc) {
      window.__caseroomToc = true;
      let queued = false;
      const schedule = () => {
        if (queued) return;
        queued = true;
        requestAnimationFrame(() => {
          queued = false;
          update();
        });
      };
      addEventListener('scroll', schedule, { passive: true });
      addEventListener('resize', schedule);
      addEventListener('hashchange', schedule);
      addEventListener('load', schedule);
      // React and other late renders: look again once they have mounted.
      setTimeout(schedule, 300);
      schedule();
    }
    
    export {};
    toc.tsx
    import { cn } from '@/lib/utils';
    import { toc, type TocEntry } from './toc.variants';
    import './toc.client';
    
    export type { TocEntry };
    
    export interface TocProps {
      entries: TocEntry[];
      /** The visible heading and the nav's name. */
      label?: string;
      /** Pixels a heading must pass the top of the screen to count as read: the height of a sticky header, plus a little. */
      offset?: number;
      className?: string;
    }
    
    /** "On this page": the sections as links, the one you are reading marked while you scroll. */
    export function Toc({ entries, label = 'On this page', offset, className }: TocProps) {
      return (
        <nav aria-label={label} data-toc="" data-toc-offset={offset} className={cn(toc.root, className)}>
          <p className={toc.heading} aria-hidden="true">{label}</p>
          <ul className={toc.list}>
            {entries.map((e) => (
              <li key={e.href}><a href={e.href} className={cn(toc.link, e.level === 3 && toc.nested)}>{e.label}</a></li>
            ))}
          </ul>
        </nav>
      );
    }
    toc.variants.ts
    /** "On this page": the sections of a page as links, the one you are reading marked (aria-current) while you scroll.
     *  Level 3 entries sit indented under their level 2 section. */
    export const toc = {
      root: 'w-full max-w-56 text-sm',
      heading: 'mb-2 px-2 text-xs font-medium text-muted-foreground',
      list: 'm-0 grid list-none gap-0.5 p-0',
      // The current entry: full ink, medium weight and a short bar at its start; no rail behind the whole list.
      link: 'relative flex min-h-7 items-center rounded-md px-2 py-1 leading-snug text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed aria-[current=true]:font-medium aria-[current=true]:text-foreground before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-foreground before:opacity-0 before:transition-opacity aria-[current=true]:before:opacity-100',
      nested: 'pl-5 before:left-3',
    };
    
    export type TocEntry = { label: string; href: string; level?: 2 | 3 };