Skip to content

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.json

From 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.

Installation

$ npx shadcn@latest add https://framebrick.co/r/contents-nav.json

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

PropTypeDefaultDescription
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.
labelstring—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).
offsetnumber50Px 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).
stickynumber—Px from the top at which the nav sticks while the page scrolls. Left out, it scrolls with the page.
numberedbooleanfalseA number before each label.
classNamestring—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".

Be the first to know

Get new templates and components first

One short email when we release a template or a free component. No spam — unsubscribe anytime.

We use your email only for Framebrick updates. Privacy policy