ScrollSpy

Section navigation for long pages — triggers scroll to their section and track the one being read.

Quick Preview

Overview

A three-bedroom terrace in Lekki Phase 1, finished and ready to move into.

Amenities

Borehole, 24/7 power, gated estate with security, and a shared playground.

Payment Plan

20% down payment, then equal monthly instalments across 24 months.

Location

Eight minutes from the Lekki–Epe expressway, with two schools within a kilometre.

Composition

Use this public composition when building ScrollSpy:

ScrollSpy
├── ScrollSpyListContainer (optional)
│   └── ScrollSpyList
│       └── ScrollSpyTrigger
│           └── ScrollSpyCount (optional)
└── ScrollSpyContent

The parts mirror Tabs one for one — ScrollSpyListContainer is TabsListContainer, ScrollSpyCount is TabsCount — so the two families are interchangeable in markup and identical on screen. Only the behaviour differs: sections all stay visible, and the active trigger follows the reader instead of being chosen by one.

Usage

ScrollSpy is Tabs for a page that shows everything at once. Every section stays mounted and visible; the triggers scroll to their section, and the active trigger tracks whichever section the reader is currently on.

<ScrollSpy defaultValue="overview" scrollOffset={64}><ScrollSpyList><ScrollSpyTrigger value="overview">Overview</ScrollSpyTrigger></ScrollSpyList><ScrollSpyContent value="overview">Overview</ScrollSpyContent></ScrollSpy>

Sections are matched to triggers by value, exactly as tabs are. ScrollSpy spies on the nearest scrolling ancestor — the page, or the pane the sections live in — so it works inside a scrollable panel without extra configuration.

Multiple instances

Instances are fully independent, so a page-level ScrollSpy and one inside an open Dialog or Sheet do not interfere. Each resolves its own scrollport on mount and listens only to that element, so scrolling the sheet leaves the page's active section alone and vice versa. Section ids and trigger ids are generated per instance, so two spies can use the same value names without colliding.

A spy inside a position: fixed overlay that has no scroll container of its own is treated as static rather than falling back to the page — it will not react to how far the page behind it happens to be scrolled.

The scrollport is resolved from a section, not from the root, so the root is free to sit outside the scrolling region. That is what lets a Sheet keep the list in its non-scrolling header while the sections scroll in SheetBody.

Examples

Variants

ScrollSpyList takes the same variant as TabsList, so section navigation and tabs read as one family. Pin the list with sticky when the sections are taller than the scrollport.

Summary

Summary content.

Documents

Documents content.

Timeline

Timeline content.

Summary

Summary content.

Documents

Documents content.

Timeline

Timeline content.

Bar container

ScrollSpyListContainer is TabsListContainer for the spy: wrap ScrollSpyList in one when the bar needs padding the triggers shouldn't inherit, or when something other than a trigger has to sit in it — the "More" menu below, for instance. It owns the underline variant's baseline rule, so the rule spans the whole bar while the triggers stay inset.

It also takes over the nav landmark, so give the container the aria-label and the region stays a single landmark however many controls join it. Without a container ScrollSpyList is still the nav, so existing markup is unaffected.

Documents

Proof of identity, proof of address, and two months of bank statements.

Guarantors

Both guarantors have confirmed and returned their signed forms.

Payment Plan

20% down payment, then equal monthly instalments across 24 months.

<ScrollSpyListContainer className="sticky top-0 z-10 bg-canvas px-4">
  <ScrollSpyList>
    <ScrollSpyTrigger value="documents">
      Documents
      <ScrollSpyCount count={4} />
    </ScrollSpyTrigger>
  </ScrollSpyList>
</ScrollSpyListContainer>

Pad the container along the rule, not across it. The active indicator is pinned to the list's edge — the bottom when horizontal, the right when vertical — so padding on that edge lifts the indicator off the rule. px is safe on a horizontal bar, py on a vertical one.

Counts

ScrollSpyCount is TabsCount under this family's name — same chip, same abbreviate, max and loading props, and it tracks the list's variant and size the same way. Place it inside a ScrollSpyTrigger, after the label.

Orientation

Use orientation="vertical" for a rail beside long-form content, such as a multi-part application or a legal document. The underline variant draws the rail down the right of the list, with the active indicator riding it.

Identity

Identity details for this application.

Income

Income details for this application.

Guarantor

Guarantor details for this application.

Review

Review details for this application.

Following the active trigger

When the list scrolls, set scrollActiveIntoView on ScrollSpyList. Without it, a strip narrower than its triggers looks frozen the moment the active one drifts past the edge — the reader is scrolling, the highlight is moving, and none of it is visible.

It only moves the list's own scroll position, never the page's, so it can't fight the scroll that is driving the active section in the first place. It is a no-op when nothing overflows, and it honors prefers-reduced-motion.

Overview

Scroll down and watch the strip follow along — the active trigger stays in view instead of drifting off the edge.

Amenities

Scroll down and watch the strip follow along — the active trigger stays in view instead of drifting off the edge.

Payment Plan

Scroll down and watch the strip follow along — the active trigger stays in view instead of drifting off the edge.

Documents

Scroll down and watch the strip follow along — the active trigger stays in view instead of drifting off the edge.

Neighbourhood

Scroll down and watch the strip follow along — the active trigger stays in view instead of drifting off the edge.

About The Developer

Scroll down and watch the strip follow along — the active trigger stays in view instead of drifting off the edge.

Frequently Asked

Scroll down and watch the strip follow along — the active trigger stays in view instead of drifting off the edge.

Note that the shared Tabs list styling is w-fit shrink-0, so a scrolling list needs w-full max-w-full shrink overflow-x-auto to have somewhere to scroll. See Scrollable lists for the planned affordances that will make this a first-class option.

Inside a sheet or dialog

Two spies can share a page without interfering. Open the sheet, scroll it, and the spy behind it keeps its own active section — each tracks only its own scrollport, even though both use the same section names.

Put the spy root around the header and body so the list can stay pinned in SheetHeader while the sections scroll in SheetBody.

Overview

Overviewpage. Scroll here and watch the other spy's active trigger stay exactly where you left it.

Amenities

Amenitiespage. Scroll here and watch the other spy's active trigger stay exactly where you left it.

Payment Plan

Payment Planpage. Scroll here and watch the other spy's active trigger stay exactly where you left it.

Sticky headers

Set scrollOffset to the height of a sticky header. It moves the line a section has to cross to become active, and it becomes each section's scroll-margin-top, so clicking a trigger lands the heading below the header instead of under it.

<ScrollSpy scrollOffset={72}>
  <ScrollSpyList className="sticky top-0 z-10 bg-canvas" />
</ScrollSpy>

Overflow

Overflow is the consuming app's job, as it is with Tabs. Decide per breakpoint how many triggers to render, and put the rest behind a DropdownMenu. Import scrollSpyTriggerClassName so the "More" button matches the real triggers.

Highlights

Scroll past the third section and the underline moves to "More" instead of vanishing. Open the menu to jump straight to one.

Amenities

Scroll past the third section and the underline moves to "More" instead of vanishing. Open the menu to jump straight to one.

About

Scroll past the third section and the underline moves to "More" instead of vanishing. Open the menu to jump straight to one.

Location

Scroll past the third section and the underline moves to "More" instead of vanishing. Open the menu to jump straight to one.

Purchase Options

Scroll past the third section and the underline moves to "More" instead of vanishing. Open the menu to jump straight to one.

Finishing Levels

Scroll past the third section and the underline moves to "More" instead of vanishing. Open the menu to jump straight to one.

Land Documents

Scroll past the third section and the underline moves to "More" instead of vanishing. Open the menu to jump straight to one.

Investment Forecast

Scroll past the third section and the underline moves to "More" instead of vanishing. Open the menu to jump straight to one.

The active indicator does not follow an overflowed section on its own — and it does not merely fail to highlight "More", it disappears. ScrollSpyList measures the bar from the one trigger inside it carrying data-active; a trigger sitting in a portaled menu is not in the list (and is unmounted while the menu is closed), so there is nothing to measure and no bar is rendered.

Give the "More" button the trigger slot and data-active, and the real bar slides under it:

const isOverflowActive = overflow.some((section) => section.value === activeValue);

<DropdownMenuTrigger
  render={
    <button
      type="button"
      data-slot="scroll-spy-trigger"
      data-active={isOverflowActive || undefined}
      className={cn(scrollSpyTriggerClassName, "cursor-pointer")}
    />
  }
>
  More
</DropdownMenuTrigger>;

The measurement re-runs whenever the active section changes, so the flag lands on the same render as the highlight. data-active also picks up the shared active affordance from scrollSpyTriggerClassName, so the label darkens with the bar. Keep the "More" label fixed — swapping it for the active section's name changes the button's width mid-scroll and drops the one stable landmark in the strip.

The menu items are plain DropdownMenuItems driven by useScrollSpy, so they scroll without needing to be triggers, and the menu can also say which collapsed section "More" is standing in for:

const { activeValue, scrollToSection } = useScrollSpy();

<DropdownMenuItem
  data-active={section.value === activeValue || undefined}
  className="data-active:text-fg-brand"
  onClick={() => scrollToSection(section.value)}
>
  {section.label}
</DropdownMenuItem>;

Two things to keep right:

  • Compute the visible/overflow split from available width only, never from which section is active — otherwise sections pop in and out of the strip as the reader scrolls.
  • An overflowed section has no trigger of its own, so its aria-labelledby points at an id that is not in the document. Pass aria-label on those ScrollSpyContents instead.

This is also the alternative to scrollActiveIntoView: a "More" menu keeps every label reachable at a fixed width, where a scrolling strip keeps them inline and moves the strip instead. Pick one — a list that both scrolls and collapses fights itself.

useScrollSpy

Read and drive the spy from anywhere inside a ScrollSpy — an overflow menu, a floating progress rail, a heading that echoes the section being read. Context reaches through portals, so a consumer inside an open DropdownMenu, Popover or Sheet works without threading anything down by hand.

import { useScrollSpy } from "@giddaa-housing/ui/scroll-spy";

function SectionEcho() {
  const { activeValue, scrollToSection } = useScrollSpy();

  return <p>Reading: {activeValue}</p>;
}
ReturnsTypeDescription
activeValuestring | undefinedThe section currently being read. undefined before the first measurement when no defaultValue was given.
scrollToSection(value: string) => voidScrolls to a section and lights its trigger, exactly as clicking a ScrollSpyTrigger does. Honors prefers-reduced-motion.
getTriggerId(value: string) => stringThis instance's trigger id, for wiring aria-controls on a custom control.
getSectionId(value: string) => stringThis instance's section id.

It throws when called outside a ScrollSpy, so a misplaced consumer fails loudly rather than silently never updating. Registering sections stays private to ScrollSpyContent.

Props

Prop/APITypeDefaultDescription
valuestring-Controlled active section.
defaultValuestring-Active section before the first scroll measurement lands.
onValueChange(value: string) => void-Fires when the reader scrolls into a new section or clicks a trigger.
scrollOffsetnumber0Pixels between the top of the scrollport and the activation line. Also applied as each section's scroll-margin-top.
orientation`"horizontal""vertical"`"horizontal"
ScrollSpyListContainernav element-Optional bar around ScrollSpyList; owns the underline rule and the nav landmark.
ScrollSpyCountspan element-Optional count badge; place inside ScrollSpyTrigger after the label. Matches TabsCount.
variant on ScrollSpyList`"default""line""secondary"
size on ScrollSpyList`"sm""md""lg"`
scrollActiveIntoView on ScrollSpyListbooleanfalseKeeps the active trigger visible when the list itself scrolls. Moves only the list, never the page.
value on ScrollSpyTriggerstring-Required; matches a ScrollSpyContent.
value on ScrollSpyContentstring-Required; matches a ScrollSpyTrigger.
scrollSpyTriggerClassNamestring-Trigger styling, for overflow buttons rendered outside the list.
useScrollSpy(){ activeValue, scrollToSection, getTriggerId, getSectionId }-Reads and drives the nearest spy from any descendant. See useScrollSpy.
classNamestring-Local layout or spacing overrides.
native/root propsReact component props-Passed through to the underlying root or primitive.

Accessibility

  • The list renders as a nav; give it an aria-label when a page has more than one. With a ScrollSpyListContainer, the container is the nav — label that instead, and the list drops out of the landmark list rather than announcing the same navigation twice.
  • The active trigger carries aria-current="location", so state is not communicated by color alone.
  • Each section is a labelled region tied to its trigger, and takes focus (without a second scroll) when its trigger is activated.
  • Scrolling honors prefers-reduced-motion — the jump is instant rather than smooth when reduced motion is requested.
  • Triggers are ordinary buttons in the tab order; unlike Tabs, there is no roving focus to preserve.

On this page