Contents Nav
A list of links to the sections of the page, with the one you are reading marked.
$npx shadcn@latest add https://framebrick.co/r/contents-nav.jsonFrom the KorDent template, see it in context.
Included
A full check-up, a clean and a short plan for the year ahead, in one visit.
Process
Book a time, arrive ten minutes early and we take care of the rest.
Prices
One fixed price, shown before you book, with nothing added afterwards.
Questions
Anything else? Ask us at the desk or by message, any day of the week.
import { ContentsNav } from "@/components/ui/contents-nav";
const sections = [
{ id: "cn-demo-whats-included", label: "Included", text: "A full check-up, a clean and a short plan for the year ahead, in one visit." },
{ id: "cn-demo-how-it-works", label: "Process", text: "Book a time, arrive ten minutes early and we take care of the rest." },
{ id: "cn-demo-prices", label: "Prices", text: "One fixed price, shown before you book, with nothing added afterwards." },
{ id: "cn-demo-questions", label: "Questions", text: "Anything else? Ask us at the desk or by message, any day of the week." },
];
/* Scroll the text beside the list: the entry whose section has reached the top of the box is marked, with an arrow. A click scrolls to its section. */
export default function ContentsNavDemo() {
return (
<div className="flex w-full max-w-xl items-start gap-4">
<ContentsNav label="On this page" numbered offset={12} className="w-36 shrink-0" items={sections.map(({ id, label }) => ({ id, label }))} />
<div role="region" aria-label="Article" tabIndex={0} className="h-44 min-w-0 flex-1 overflow-y-auto rounded-lg border p-3 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
{sections.map((section) => (
<section key={section.id} id={section.id} className="pb-24">
<h3 className="font-medium">{section.label}</h3>
<p className="mt-1 text-foreground/75">{section.text}</p>
</section>
))}
</div>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/contents-nav.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/contents-nav.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Contents Nav — a list of links to the sections of the page, with the one you are reading marked: its label takes the primary * colour and an arrow comes in at its right. A link scrolls smoothly to its section (from the KorDent service page "On this * page" card, and its Home "Outcomes" index; Metuo's why and values are the same idea, written in the block). * * Which entry is active: the last section whose top has passed a line. `line="top"` puts the line `offset` px from the top of * the window (KorDent's service page: 50, under its fixed header; nothing is active before the first section gets there); * `line="third"` puts it a third of the way down (the Outcomes index: the card that crosses the top third of the window). When * the sections are in a box that scrolls, the line is measured from that box's own top. The state only changes when the * active entry does, and it is read from a passive scroll listener (one frame at a time), never from the render. The server sends * the list of links and no active entry, so the markup does not depend on where the page is scrolled; without script the links * are plain anchors. * * A click scrolls its section into view, with a smooth scroll that jumps when the visitor asked for reduced motion, and the * section keeps `offset` px above it: the component sets scroll-margin-top on the sections it finds, and puts it back when it * goes. The colour change and the arrow are KorDent's spring (bounce 0.2, 0.4s) as a CSS linear() curve sampled from the spring * (tools/ui/spring-curve.mjs, within 1%), with cubic-bezier(0.22, 1, 0.36, 1) for a browser without linear(); the arrow's small * slide is off with reduced motion. The active entry has aria-current="location". * * `sticky` (px from the top) holds the nav while the page scrolls, with CSS alone: an ancestor with overflow hidden or auto * stops position: sticky from working (overflow: clip does not). Inactive entries are foreground at 65%, 5.1:1 or better in * the four sample themes, light and dark, and the active one is the primary colour on the card. */ const ease = "[--cn-ease:cubic-bezier(0.22,1,0.36,1)] supports-[transition-timing-function:linear(0,1)]:[--cn-ease:linear(0,0.041,0.138,0.262,0.393,0.518,0.631,0.727,0.806,0.869,0.917,0.953,0.978,0.995,1.006,1.012,1.015,1.015,1.014,1.013,1.011,1.009,1.007,1.005,1.004,1.003,1.002,1.001,1.001,1)]"; export type ContentsNavItem = { /** The id of the section it links to. */ id: string; label: React.ReactNode; /** Shown before the label when `numbered` is on; 01, 02… by default. */ number?: React.ReactNode; }; export type ContentsNavProps = { items: ContentsNavItem[]; /** Accessible name of the navigation. */ label: string; /** Where the line sits: `offset` px from the top, or a third of the way down. */ line?: "top" | "third"; /** Px from the top for line="top", and what a section keeps above it after a click. */ offset?: number; /** Px from the top at which the nav sticks while the page scrolls. Left out, it scrolls with the page. */ sticky?: number; /** A number before each label. */ numbered?: boolean; className?: string; }; /* The nearest ancestor that scrolls, or null for the window. */ function scrollParent(element: HTMLElement | null) { for (let parent = element?.parentElement ?? null; parent; parent = parent.parentElement) { const overflow = getComputedStyle(parent).overflowY; if (overflow === "auto" || overflow === "scroll") return parent; } return null; } export function ContentsNav({ items, label, line = "top", offset = 50, sticky, numbered = false, className }: ContentsNavProps) { const [active, setActive] = React.useState<string | null>(null); const ids = items.map((item) => item.id).join("|"); React.useEffect(() => { const sections = ids.split("|").map((id) => document.getElementById(id)); const previous = sections.map((section) => section?.style.scrollMarginTop ?? ""); sections.forEach((section) => { if (section) section.style.scrollMarginTop = `${offset}px`; }); const root = scrollParent(sections.find(Boolean) ?? null); let frame = 0; const update = () => { frame = 0; const box = root ? root.getBoundingClientRect() : { top: 0, height: window.innerHeight }; const lineY = box.top + (line === "third" ? box.height * 0.33 : offset); let found: string | null = null; sections.forEach((section, index) => { if (section && section.getBoundingClientRect().top <= lineY + 1) found = ids.split("|")[index]; }); setActive((current) => (current === found ? current : found)); }; const schedule = () => { if (!frame) frame = requestAnimationFrame(update); }; update(); /* A scroll does not bubble, so listen in the capture phase: that also hears a box that scrolls. */ document.addEventListener("scroll", schedule, { capture: true, passive: true }); window.addEventListener("resize", schedule); return () => { cancelAnimationFrame(frame); document.removeEventListener("scroll", schedule, { capture: true }); window.removeEventListener("resize", schedule); sections.forEach((section, index) => { if (section) section.style.scrollMarginTop = previous[index]; }); }; }, [ids, line, offset]); function go(event: React.MouseEvent, id: string) { const section = document.getElementById(id); if (!section) return; event.preventDefault(); const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; section.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" }); } return ( <nav aria-label={label} style={sticky !== undefined ? { ["--contents-sticky" as string]: `${sticky}px` } : undefined} className={cn("w-full max-w-xs", ease, sticky !== undefined && "sticky top-(--contents-sticky) self-start", className)} > <ul className="divide-y overflow-hidden rounded-xl border bg-card text-card-foreground"> {items.map((item, index) => ( <li key={item.id}> <a href={`#${item.id}`} onClick={(event) => go(event, item.id)} aria-current={active === item.id ? "location" : undefined} className="group flex items-center gap-3 px-4 py-3 text-sm font-medium text-foreground/65 outline-none transition-colors duration-400 ease-(--cn-ease) hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset aria-[current=location]:text-primary motion-reduce:transition-none" > {numbered && <span className="w-7 shrink-0 tabular-nums">{item.number ?? String(index + 1).padStart(2, "0")}</span>} <span className="flex-1">{item.label}</span> <svg aria-hidden viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="size-[18px] shrink-0 -translate-x-1 opacity-0 transition-[opacity,translate] duration-400 ease-(--cn-ease) group-aria-[current=location]:translate-x-0 group-aria-[current=location]:opacity-100 motion-reduce:translate-x-0 motion-reduce:transition-none" > <path d="M4 9h10M10 5l4 4-4 4" /> </svg> </a> </li> ))} </ul> </nav> ); }
Usage
import { ContentsNav } from "@/components/ui/contents-nav";
// the sections have these ids
<ContentsNav
label="On this page"
numbered
sticky={50}
items={[
{ id: "overview", label: "Overview" },
{ id: "pricing", label: "Pricing" },
{ id: "faq", label: "Questions" },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id, label, number? }[] | — | The sections, in page order. id is the id of the section on the page; number replaces the 01, 02… shown with numbered. |
| label | string | — | Accessible name of the navigation. |
| line | "top" | "third" | "top" | Where the line sits that a section must pass to be the active one: offset px from the top (the service page), or a third of the way down (the Outcomes index). |
| offset | number | 50 | Px from the top for line="top", and what a section keeps above it after a click (scroll-margin-top, set on the sections the component finds). |
| sticky | number | — | Px from the top at which the nav sticks while the page scrolls. Left out, it scrolls with the page. |
| numbered | boolean | false | A number before each label. |
| className | string | — | Classes for the nav: its width, say (max-w-xs by default). |
- It is KorDent's "On this page" card (a 300 px card, sticky 50 px from the top, entries with a number and a label) and its Home "Outcomes" index (the row that crosses the top third of the window). The active entry takes the primary colour and an arrow comes in at its right, both on spring bounce 0.2, 0.4 s (a CSS linear() curve, within 1% of the spring, with cubic-bezier(0.22, 1, 0.36, 1) for a browser without linear()). Metuo's scroll-spy list is the same idea, written in the block; this is the nav that points at sections, not the list that lights up beside photos.
- The active entry is the last section whose top has passed the line, and nothing is active before the first one gets there. When the sections are in a box that scrolls the line is measured from that box's own top (the demo does this). The state changes only when the active entry does; it is read from a passive scroll listener, one animation frame at a time, never from the render.
- The server sends the list of links and no active entry, so the markup does not depend on where the page is scrolled, and without script the links are plain anchors. The active entry has aria-current="location"; the arrow is decoration.
- A click scrolls its section into view, smoothly, and jumps when the visitor asked for reduced motion; the arrow's small slide is off with reduced motion too. The component sets scroll-margin-top on the sections it finds and puts it back when it unmounts.
- sticky is CSS position: sticky and nothing else: an ancestor with overflow hidden or auto stops it from working (overflow: clip does not), the same as Index List.
- Inactive entries are foreground at 65%, 5.1:1 or better in the four sample themes, light and dark; the active one is the primary colour on the card.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called ContentsNav, a client component: a list of links to the sections of the page with the one you are reading marked.
- Props: items ({ id, label, number? }[]), label (aria-label of the nav), line ("top" | "third", default "top"), offset (px, 50), sticky (px, optional), numbered (boolean), className.
- A nav (w-full max-w-xs; with sticky: sticky, top set by a CSS variable, self-start) holding a ul: divide-y overflow-hidden rounded-xl border bg-card. Each li holds an a to #id: group flex items-center gap-3 px-4 py-3 text-sm font-medium text-foreground/65, hover:text-foreground, a focus ring ring-[3px] ring-ring/50 ring-inset, aria-[current=location]:text-primary, transition-colors 400ms. With numbered, a w-7 tabular-nums number first (01, 02…). The label takes the rest. Last, an 18px arrow svg (path M4 9h10M10 5l4 4-4 4) that is -translate-x-1 opacity-0 and goes to 0 and 1 under group-aria-[current=location], transition-[opacity,translate] 400ms; motion-reduce turns the transitions off and the slide.
- The easing is a CSS custom property --cn-ease: cubic-bezier(0.22,1,0.36,1), replaced by a 30-stop linear() list sampled from a spring with bounce 0.2 and duration 0.4s inside supports-[transition-timing-function:linear(0,1)].
- State: the active id, null at first (so the server markup has no active entry). In an effect, find the sections by id, set their scroll-margin-top to offset px (and put it back on cleanup), find the nearest scrolling ancestor of the first one (or the window), and on a passive scroll listener on the document in the capture phase (plus resize), throttled with requestAnimationFrame, take the last section whose top is at or above the line: the box's top plus offset, or plus a third of its height for line="third". Set the state only when it changes.
- A click on a link, if its section exists, calls preventDefault and section.scrollIntoView({ behavior: "smooth", block: "start" }), with behavior "auto" when matchMedia("(prefers-reduced-motion: reduce)") matches. Without script the links are plain anchors. The active link has aria-current="location".










