Skip to content
Caseroom

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,062 sites in The UI Index have a sidebar (259 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/caseroom/r/toc.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/toc.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom 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 };