# On this page (Caseroom component)

A page's sections as links; the one you are reading is marked while you scroll. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/toc.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/toc/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### toc.astro

```astro
---
import { cn } from '@/lib/utils';
import { toc, type TocEntry } from './toc.variants';

interface Props {
  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;
  class?: string;
}

const { entries, label = 'On this page', offset, class: className } = Astro.props;
---

<nav aria-label={label} data-toc data-toc-offset={offset} class={cn(toc.root, className)}>
  <p class={toc.heading} aria-hidden="true">{label}</p>
  <ul class={toc.list}>
    {entries.map((e) => <li><a href={e.href} class={cn(toc.link, e.level === 3 && toc.nested)}>{e.label}</a></li>)}
  </ul>
</nav>

<script>
  import './toc.client';
</script>
```

### toc.client.ts

```tsx
// "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

```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

```tsx
/** "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 };
```
