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.
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.
// "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 {};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>
);
}/** "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 };---
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>// "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 {};/** "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 };<div class="@container w-full max-w-3xl">
<div class="grid gap-8 @xl:grid-cols-[12rem_1fr]">
<nav aria-label="On this page" data-toc class="w-full max-w-56 text-sm">
<p class="mb-2 px-2 text-xs font-medium text-muted-foreground" aria-hidden="true">On this page</p>
<ul class="m-0 grid list-none gap-0.5 p-0">
<li>
<a href="#ex-toc-start" class="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">
Before you start
</a>
</li>
<li>
<a href="#ex-toc-connect" class="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">
Connect a repository
</a>
</li>
<li>
<a href="#ex-toc-previews" class="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:w-0.5 before:rounded-full before:bg-foreground before:opacity-0 before:transition-opacity aria-[current=true]:before:opacity-100 pl-5 before:left-3">
Preview links
</a>
</li>
<li>
<a href="#ex-toc-live" class="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">
Go live
</a>
</li>
<li>
<a href="#ex-toc-domains" class="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:w-0.5 before:rounded-full before:bg-foreground before:opacity-0 before:transition-opacity aria-[current=true]:before:opacity-100 pl-5 before:left-3">
Custom domains
</a>
</li>
</ul>
</nav>
<div class="max-w-prose text-sm">
<section class="mt-6 first:mt-0">
<h2 id="ex-toc-start" class="scroll-mt-20 text-base font-semibold tracking-[-0.01em]">Before you start</h2>
<p class="mt-1.5 text-pretty text-muted-foreground">You need a repository with a build command and an account on the free plan.</p>
</section>
<section class="mt-6 first:mt-0">
<h2 id="ex-toc-connect" class="scroll-mt-20 text-base font-semibold tracking-[-0.01em]">Connect a repository</h2>
<p class="mt-1.5 text-pretty text-muted-foreground">Pick the repository and the branch to deploy. Every push to it builds a new version.</p>
</section>
<section class="mt-4">
<h3 id="ex-toc-previews" class="scroll-mt-20 font-medium">Preview links</h3>
<p class="mt-1 text-pretty text-muted-foreground">Each pull request gets its own link, so a reviewer sees the change before it ships.</p>
</section>
<section class="mt-6 first:mt-0">
<h2 id="ex-toc-live" class="scroll-mt-20 text-base font-semibold tracking-[-0.01em]">Go live</h2>
<p class="mt-1.5 text-pretty text-muted-foreground">Promote a build to production. The previous one stays a click away if you need it back.</p>
</section>
<section class="mt-4">
<h3 id="ex-toc-domains" class="scroll-mt-20 font-medium">Custom domains</h3>
<p class="mt-1 text-pretty text-muted-foreground">Point a CNAME at your project; the certificate is issued for you within a minute.</p>
</section>
</div>
</div>
</div><script type="module" src="/caseroom/r/html/toc.js"></script>