Composites
Scroll Spy
An in-page table of contents that follows the reader: as sections scroll past, the link for the one in view is marked current, and a click on a link scrolls its section into place. For long settings pages and record pages — a CRM contact with its deals, activity and notes.
Import and origin. From the main entry — import { ScrollSpy, ScrollSpyNav, … } from '@comitor/ui' — with no optional peer to install. The file is a client component ("use client" inside), but every prop an uncontrolled spy on a window-scrolled page needs is plain data, so that one can sit directly in a Server Component; scrollContainer (an element) and onValueChange (a function) need a client component. Ported in 2.1.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package's own primitives and tokens — the fixes are listed below and in the changelog.
Anatomy
A link and a section share a value, which becomes the section's id and the link's href. The nav and the viewport are laid out by the root, a flex container, so either can be restyled or wrapped.
import { ScrollSpy, ScrollSpyLink, ScrollSpyNav, ScrollSpySection, ScrollSpyViewport } from '@comitor/ui'
const [container, setContainer] = useState<HTMLDivElement | null>(null)
<ScrollSpy defaultValue="general" scrollContainer={container} className="h-96">
<ScrollSpyNav> {/* <nav aria-label="On this page"> */}
<ScrollSpyLink value="general">General</ScrollSpyLink> {/* <a href="#general">, aria-current when in view */}
<ScrollSpyLink value="members">Members</ScrollSpyLink>
</ScrollSpyNav>
<ScrollSpyViewport ref={setContainer} className="overflow-y-auto"> {/* the content column */}
<ScrollSpySection value="general">…</ScrollSpySection> {/* <div id="general"> */}
<ScrollSpySection value="members">…</ScrollSpySection>
</ScrollSpyViewport>
</ScrollSpy>A long settings page
Six sections in a 24rem scroll area with the nav beside it. Scroll the content and the current link follows; click a link and its section scrolls up to the top. Only this box scrolls — the page around it stays put.
General
How the workspace appears to its members.
- Workspace name
- Comitor Studio
- Address
- comitor.app/studio
- Default language
- English — members can pick their own
- Time zone
- (GMT+07:00) Ho Chi Minh City
Members
24 people, 3 invitations pending.
- Linh Tran
- Owner
- Sam Okafor
- Admin
- Minh Pham
- Member
- Anna Nguyen
- Invited 22 Sep
Billing
Team plan, billed yearly.
- Plan
- Team — 25 seats
- Next invoice
- 1 Oct 2026 · $1,500.00
- Billing email
- [email protected]
Notifications
What the workspace tells people about.
- Task assigned to me
- Email and in-app
- Mentions in Chat
- In-app only
- Weekly digest
- Mondays at 08:00
Security
Sign-in rules for everyone in the workspace.
- Two-factor authentication
- Required for admins
- Single sign-on
- Google Workspace
- Session length
- 14 days
Danger zone
Actions that cannot be undone.
- Transfer ownership
- Hand the workspace to another admin
- Delete workspace
- Removes 1,284 tasks and 312 contacts for good
scrollContainertakes the element itself, not a ref object. Keep it in state with a callback ref —ref={setContainer}— so the spy re-renders once the element exists. Without it the spy watches the window.- The spacer at the end is not decoration. A section becomes current by entering a band at the top of the scroll area, and the last one can only get there if there is room to scroll it up.
- Values are DOM ids, so they must be unique on the page — this page prefixes each demo's (
ws-general,ctl-general…) because four spies share it.
How the current section is chosen
An IntersectionObserver watches every section — including one that mounts later — with scrollContainer as its root (the viewport when there is none).
- The band. The default
rootMarginis-{offset}px 0px -70% 0px: fromoffsetbelow the top of the scroll area down to 30% of its height. - The rule. A section counts once
thresholdof it (10% by default) lies inside the band, and the section that most recently entered becomes current — the topmost, if several enter at once. - Clicks win. A link click sets the value at once and scrolls; for the next 500 ms the observer is ignored, so a smooth scroll past other sections does not flicker the highlight.
offsetis for sticky headers. A link scroll stops that many pixels short of the section, and the band starts below the header. ArootMarginof your own replaces the band entirely;offsetthen only affects scrolling.
Controlled
With value and onValueChange, anything else on the page can drive it — here, the buttons above the frame. The spy scrolls only when value changes from outside to something it does not already hold: the value it just reported, handed straight back through state, is recognised as its own echo and scrolls nothing. Jump, then scroll by hand and watch the read-out.
General
How the workspace appears to its members.
- Workspace name
- Comitor Studio
- Address
- comitor.app/studio
- Default language
- English — members can pick their own
- Time zone
- (GMT+07:00) Ho Chi Minh City
Members
24 people, 3 invitations pending.
- Linh Tran
- Owner
- Sam Okafor
- Admin
- Minh Pham
- Member
- Anna Nguyen
- Invited 22 Sep
Billing
Team plan, billed yearly.
- Plan
- Team — 25 seats
- Next invoice
- 1 Oct 2026 · $1,500.00
- Billing email
- [email protected]
Notifications
What the workspace tells people about.
- Task assigned to me
- Email and in-app
- Mentions in Chat
- In-app only
- Weekly digest
- Mondays at 08:00
Security
Sign-in rules for everyone in the workspace.
- Two-factor authentication
- Required for admins
- Single sign-on
- Google Workspace
- Session length
- 14 days
Danger zone
Actions that cannot be undone.
- Transfer ownership
- Hand the workspace to another admin
- Delete workspace
- Removes 1,284 tasks and 312 contacts for good
value = "ctl-general" · onValueChange calls = 0
onValueChange also fires for your own jump (the spy reports the value it just took on) and, when a long smooth scroll outlasts the 500 ms window, for the sections it passes on the way. Nothing happens on mount: the first value is adopted without scrolling.
Nav above the content
orientation describes the frame, not the list of links — the source keeps upstream's meaning. The default, horizontal, sets the nav beside the content with its links stacked and the active bar on their leading edge. vertical stacks the nav above the content: the links run in a row and the bar moves to their bottom edge, like tabs. That suits a record page, where the width belongs to the content.
Mai Anh Vo
Head of Operations · Lotus Logistics
Overview
Work email
Sam Okafor
Account owner
Customer since March 2025
Stage
Deals
Warehouse robotics pilot · $48,000
Negotiation · closes 15 Oct
Annual support renewal · $12,500
Won · 2 Sep
Activity
Call with Mai Anh — pilot scope
24 Sep · Sam Okafor
Proposal v3 sent
19 Sep · Sam Okafor
Site visit, Binh Duong warehouse
11 Sep · Minh Pham
Notes
Wants the pilot live before Tet; procurement signs off anything over $40k.
Pinned by Sam Okafor
Files
Pilot proposal v3.pdf
2.4 MB · 19 Sep
Site survey photos.zip
38 MB · 11 Sep
Inside AppShell
In a Comitor app the page scrolls inside AppShell's <main>, never in the window. The spy has to be told: without scrollContainer the links still light up as the page scrolls, but a click only moves the highlight — nothing scrolls.
'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import { ScrollSpy, ScrollSpyLink, ScrollSpyNav, ScrollSpySection, ScrollSpyViewport } from '@comitor/ui'
// Inside AppShell the WINDOW never scrolls: the frame is h-dvh overflow-hidden and the
// page scrolls in <main>. The observer would still see the sections (its default root
// is the viewport), but a link click calls window.scrollTo — and nothing moves.
export function SettingsPage() {
const anchorRef = useRef<HTMLDivElement>(null)
const [main, setMain] = useState<HTMLElement | null>(null)
useLayoutEffect(() => {
setMain(anchorRef.current?.closest('main') ?? null)
}, [])
return (
<div ref={anchorRef}>
<ScrollSpy scrollContainer={main} offset={24} defaultValue="general">
<ScrollSpyNav aria-label="Settings sections" className="sticky top-6 w-48 shrink-0 self-start">
<ScrollSpyLink value="general">General</ScrollSpyLink>
<ScrollSpyLink value="members">Members</ScrollSpyLink>
{/* … */}
</ScrollSpyNav>
<ScrollSpyViewport>
<ScrollSpySection value="general">…</ScrollSpySection>
<ScrollSpySection value="members">…</ScrollSpySection>
{/* … */}
</ScrollSpyViewport>
</ScrollSpy>
</div>
)
}Sections that load later
A record page rarely has all of its parts at once: the contact renders, and its deals and activity arrive from their own requests. Each section is watched from the moment it mounts and dropped when it unmounts — the observer itself is not rebuilt — so the spy can mount with the page and the sections can follow.
import { ScrollSpy, ScrollSpyLink, ScrollSpyNav, ScrollSpySection, ScrollSpyViewport, Skeleton } from '@comitor/ui'
// The record renders at once; deals and activity arrive from their own requests.
// Nothing waits for them and nothing remounts the spy: a section is watched from the
// moment it mounts and dropped when it unmounts.
<ScrollSpy scrollContainer={main} defaultValue="overview">
<ScrollSpyNav aria-label="Contact sections">
<ScrollSpyLink value="overview">Overview</ScrollSpyLink>
{deals && deals.length > 0 && <ScrollSpyLink value="deals">Deals</ScrollSpyLink>}
<ScrollSpyLink value="activity">Activity</ScrollSpyLink>
</ScrollSpyNav>
<ScrollSpyViewport>
<ScrollSpySection value="overview">…</ScrollSpySection>
{/* Mounts once the deals have loaded, and only if there are any. */}
{deals && deals.length > 0 && <ScrollSpySection value="deals">…</ScrollSpySection>}
{/* asChild: when the skeleton is swapped for the list, the spy follows the new element. */}
<ScrollSpySection value="activity" asChild>
{activity ? <section>…</section> : <Skeleton className="h-40" />}
</ScrollSpySection>
</ScrollSpyViewport>
</ScrollSpy>- A section that appears inside the band becomes current, as though the reader had scrolled to it, and
onValueChangereports it. One that appears outside the band changes nothing until it is scrolled into it. - With
asChildthe section is the element its child renders. Swap a skeleton for the content and the spy follows the new element. - Before 3.1.0 only the sections present when the observer was built were watched, so apps put a
keyonScrollSpyor waited for the data before mounting it. Neither is needed now.
What the port guarantees
- The current link says so to screen readers:
aria-current="location". Upstream set onlydata-state="active", which assistive technology does not read. It islocation, notpage, because the link points at a place within this page. - The nav is a named landmark — “On this page” unless you pass
aria-label. An app shell already has a navigation landmark (the sidebar), and two unnamed ones cannot be told apart in a screen reader's landmark list. - The current link is visible without colour. Upstream marked it with
bg-accentalone — #FBF5DC on white, 1.09:1 (1.17:1 in dark) — plus a text colour change: under the 3:1 a meaningful indicator needs (WCAG 1.4.11), and colour as the only signal (1.4.1). It now carries a 2px bar in--primary-ink, the same bar as the default Tabs, drawn with logical properties so it sits on the correct edge in RTL. The focus ring is Tabs' too. - Ids are escaped. Inside a scroll container the section is found with
querySelector, and upstream built the selector raw — an id that starts with a digit (2024-contract) or holdsuseId()'s colons threw aSyntaxErroron click. Ids now go throughCSS.escape. - It scrolls on command only. Upstream scrolled whenever its effect re-ran after mount. Under StrictMode — on by default in Next's dev server — the second mount pass scrolled the whole page to the section (measured:
scrollY0 → ~5050), because the app'sscrollContainerwas still null at that moment; and in controlled mode every report went observer →onValueChange→value→ scroll, yanking the reader back to the top of the section mid-scroll. Mounting never scrolls now, and a value the spy already holds is its own echo. - Sections that mount later are watched. Upstream observed only the sections present when its observer was built, and rebuilt it only when
offset,rootMargin,thresholdorscrollContainerchanged: Deals that loaded after the contact never lit up, and a spy mounted before its sections watched nothing. Sections now join and leave the running observer as they mount and unmount — anasChildsection whose element is replaced too. Since 3.1.0. - A click cannot freeze it. For 500 ms after a link click the observer is ignored. Upstream cancelled the timer that ends that pause whenever the observer was rebuilt, so a change to
offsetorscrollContainerduring those 500 ms — a sticky header that shrinks as the smooth scroll starts — left the spy deaf until the next click. Since 3.1.0. - Nothing reads
windowduring render: the scroll behaviour is resolved when a link is clicked, so a reduced-motion setting changed since the last render is honoured.
Language
The spy has a single string of its own: the nav's name, key navLabel in the scrollSpy namespace. It is invisible — it is what a screen reader's landmark list shows — so the demo reads it back from the DOM. The link texts and sections are yours.
General
The workspace's name, address and time zone.
Members
Invite people, change roles, remove people who have left.
Billing
Plan, seats and invoices.
Security
Two-factor authentication and single sign-on.
<nav aria-label> = “On this page”
| Key | English | Tiếng Việt | Where |
|---|---|---|---|
navLabel | On this page | Các mục trong trang | aria-label of ScrollSpyNav |
- Precedence:
aria-labelonScrollSpyNav→ the nearestComitorLocaleProvider(itsmessagescan rename it app-wide) → English. - Unlike most components here, there is no
labelsprop and noDEFAULT_*_LABELSconstant: one string does not need them. The Vietnamese value iscomitorMessagesVi.scrollSpy.navLabel, and no key is a function. - Setting the language once for the whole app: Get started → Set the language.
import { ComitorLocaleProvider, ScrollSpyNav, comitorMessagesVi } from '@comitor/ui'
comitorMessagesVi.scrollSpy.navLabel // 'Các mục trong trang'
// One nav — the prop wins over everything:
<ScrollSpyNav aria-label="Contact sections">…</ScrollSpyNav>
// Every nav in the app — through the provider's messages (keep the object stable):
const MESSAGES = { scrollSpy: { navLabel: 'Sections' } }
<ComitorLocaleProvider locale="en" messages={MESSAGES}>…</ComitorLocaleProvider>ScrollSpy props
On top of every <div> prop.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | The current section. Changing it from outside scrolls there; handing back the value the spy just reported does not. |
defaultValue | string | — | The starting section when uncontrolled. Without one, the first observer report picks it. |
onValueChange | (value: string) => void | — | Every change of the current section — from scrolling, a link click, or your own value arriving. Never with an empty string. |
scrollContainer | HTMLElement | null | null (the window) | The element that scrolls — the element, not a ref object: keep it in state through a callback ref. Changing it rebuilds the observer. |
offset | number | 0 | Where a link scroll stops, in px below the top of the scroll area, and where the observer's band starts. The height of a sticky header. |
rootMargin | string | -{offset}px 0px -70% 0px | IntersectionObserver rootMargin. The default is a band from offset down to 30% of the scroll area's height. Passing one replaces it, and offset then only affects scrolling. |
threshold | number | number[] | 0.1 | How much of a section must sit in the band to count. An array works — keep it a stable reference, or the observer is rebuilt on every render. |
scrollBehavior | ScrollBehavior | 'smooth' | 'auto' instead when the user asks for reduced motion — read at click time, not at render. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Describes the frame, not the links: horizontal puts the nav beside the content with links stacked; vertical puts it above with links in a row. Also moves the active bar. |
dir | 'ltr' | 'rtl' | from DirectionProvider | Reading direction. The active bar uses logical properties, so it sits on the correct edge. |
asChild | boolean | false | Render the root as its only child. |
The other parts
| Prop | Type | Default | Description |
|---|---|---|---|
ScrollSpyNav | nav props · asChild | aria-label from the provider | A <nav> landmark named “On this page” unless you pass aria-label. Stacks or rows its links by orientation. |
ScrollSpyLink | value: string · anchor props · asChild | — | An <a href='#value'> — the href is always derived from value. A click is prevented, then your onClick runs, then the spy scrolls. aria-current='location' and data-state='active' while current. |
ScrollSpyViewport | div props · asChild | — | The content column: flex-1, a column with gap-8. Give it a height and overflow-y-auto when it is the scroll area itself. |
ScrollSpySection | value: string · div props · asChild | — | value becomes the element's id — an id you pass is overwritten — so it must be unique on the whole page. |
Known limitations
- The last section needs room. It becomes current only by reaching the band at the top, so leave enough space below it (the demos use a spacer) or make it tall.
- Values are ids. A section's
idis always itsvalue— one you pass is overwritten — and must be unique on the page.
Accessibility
ScrollSpyNavis a<nav>landmark with a name; the current link carriesaria-current="location".- Links are real anchors to
#section: each is a Tab stop, Enter follows it, and copying or middle-clicking one yields a working URL. Space does not activate a link — that is the browser's rule, not the component's. - The current link is marked by a 2px
--primary-inkbar and a text colour change together, never by the tint alone. Scrolling is instant for people who ask for reduced motion. - Focus stays on the link. A click scrolls the section into view but does not move focus into it, so keyboard and screen-reader users are still in the nav. If they should land in the section, make it focusable and move focus yourself:
// A link click scrolls, but focus stays on the link. To take keyboard and screen
// reader users along, make the section focusable and move focus yourself —
// preventScroll leaves the scrolling to the spy.
<ScrollSpyLink
value="billing"
onClick={() => document.getElementById('billing')?.focus({ preventScroll: true })}
>
Billing
</ScrollSpyLink>
<ScrollSpySection value="billing" tabIndex={-1} className="outline-none">…</ScrollSpySection>- Give every section a real heading. The spy is a shortcut; people who navigate by headings should reach the same places without it.
- It is navigation, not tabs: every section stays in the DOM and in reading order. When each part deserves its own URL and only one should show at a time, use Route Tabs instead.
Related
- Web Application — its settings page splits the same kind of content across Route Tabs, one page per section.
- CRM & Sales — the contact record, the long page a table of contents like the one above helps with.
- Route Tabs — one URL per section instead of one long page.
- Tabs — the active bar and focus ring the links share.