Scrollspy
Mark the link to the section being read in a navigation, as the page or a box scrolls.
Overview
<Scrollspy> wraps a navigation whose links point to sections of the same page, such as a table
of contents, and marks the link to the section being read. It observes the sections with an
IntersectionObserver, in the viewport or in an element that scrolls, and follows chassis-css's
scrollspy: the link gets .active, and the
links it is nested under do too.
It renders no element of its own. The links it marks are Links
and the components built on it: NavLink and NavItem in a
Nav, an interactive ListItem in a
List, and a MenuItem in a Menu.
A link takes part when its href points to an element's id on the current page, whether the
href is on the component or, under asChild, on the element it renders. A disabled link takes
no part.
The active section is the last one, in document order, whose top has scrolled past a line near the
bottom of the scrolling area. rootMargin sets that line: by default a quarter of the area's
height above its bottom edge. Before any section has reached it, no link is marked. Once the area
is scrolled to its end the last section is the active one, wherever its top is: a short last
section never reaches the line.
Basic usage
Wrap the navigation, and give root the element that scrolls, as a ref. Leave root out when the
page itself scrolls. The box below is focusable, so it scrolls from the keyboard too.
First heading
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Second heading
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Third heading
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Fourth heading
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Fifth heading
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
import { useRef } from 'react'
import {
Container,
Menu,
MenuDivider,
MenuItem,
MenuList,
MenuToggle,
Navbar,
NavbarBrand,
NavbarNav,
NavItem,
NavLink,
Scrollspy
} from '@chassis-ui/react'
const text =
'This is some placeholder content for the scrollspy example. As the box scrolls, the link to ' +
'the section being read is marked. It is repeated in every section, to give the box enough ' +
'to scroll.'
const sections = ['First', 'Second', 'Third', 'Fourth', 'Fifth']
export const Example = () => {
const box = useRef<HTMLDivElement>(null)
return (
<>
<Navbar expand className="bg-evident px-md mb-md rounded-sm" aria-label="Scrollspy example">
<Container fluid>
<NavbarBrand href="#">Navbar</NavbarBrand>
<Scrollspy root={box} rootMargin="0px 0px -40%" smoothScroll>
<NavbarNav>
<NavItem href="#navbar-first">First</NavItem>
<NavItem href="#navbar-second">Second</NavItem>
<Menu component="li" className="nav-item">
<MenuToggle component={NavLink}>More</MenuToggle>
<MenuList>
<MenuItem href="#navbar-third">Third</MenuItem>
<MenuItem href="#navbar-fourth">Fourth</MenuItem>
<MenuDivider />
<MenuItem href="#navbar-fifth">Fifth</MenuItem>
</MenuList>
</Menu>
</NavbarNav>
</Scrollspy>
</Container>
</Navbar>
<div
ref={box}
role="region"
aria-label="Sections"
tabIndex={0}
className="bg-evident p-md rounded-sm"
style={{ height: 200, overflowY: 'auto', position: 'relative' }}
>
{sections.map((title) => (
<div key={title}>
<h4 id={`navbar-${title.toLowerCase()}`}>{title} heading</h4>
<p>{text}</p>
</div>
))}
</div>
</>
)
}With smoothScroll, a click on a link scrolls to its section smoothly instead of jumping, and the
page's address doesn't change. The section lands where a jump would put it, below the scrolling
element's scroll-padding-top and its own scroll-margin-top, so set those for a sticky header. A
reader who asks for reduced motion gets the jump.
Nested navigation
A link just before a nested Nav is marked while one of the nested links is. So is the toggle of
a menu while one of its items is, as in the navbar above. Those links look active; only the link to
the section itself is the current one for assistive technology.
Item 1
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
Item 1-1
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
Item 1-2
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
Item 2
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
Item 3
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
Item 3-1
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
Item 3-2
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked, and so is the link its list is nested under.
import { useRef } from 'react'
import { Grid, GridItem, Nav, NavLink, Scrollspy } from '@chassis-ui/react'
const text =
'This is some placeholder content for the scrollspy example. As the box scrolls, the link to ' +
'the section being read is marked, and so is the link its list is nested under.'
const Section = ({ id, title, level }: { id: string; title: string; level: 4 | 5 }) => {
const Heading = level === 4 ? 'h4' : 'h5'
return (
<div id={id}>
<Heading>{title}</Heading>
<p>{text}</p>
<p>{text}</p>
</div>
)
}
export const Example = () => {
const box = useRef<HTMLDivElement>(null)
return (
<Grid gap="md">
<GridItem span={4}>
<Scrollspy root={box} smoothScroll>
<Nav
component="nav"
variant="segments"
className="flex-column"
aria-label="Nested sections"
>
<NavLink href="#nested-1">Item 1</NavLink>
<Nav component="nav" variant="segments" className="flex-column" aria-label="Item 1">
<NavLink href="#nested-1-1" className="ms-md my-2xs">
Item 1-1
</NavLink>
<NavLink href="#nested-1-2" className="ms-md my-2xs">
Item 1-2
</NavLink>
</Nav>
<NavLink href="#nested-2">Item 2</NavLink>
<NavLink href="#nested-3">Item 3</NavLink>
<Nav component="nav" variant="segments" className="flex-column" aria-label="Item 3">
<NavLink href="#nested-3-1" className="ms-md my-2xs">
Item 3-1
</NavLink>
<NavLink href="#nested-3-2" className="ms-md my-2xs">
Item 3-2
</NavLink>
</Nav>
</Nav>
</Scrollspy>
</GridItem>
<GridItem span={8}>
<div
ref={box}
role="region"
aria-label="Nested sections content"
tabIndex={0}
style={{ height: 300, overflowY: 'auto', position: 'relative' }}
>
<Section id="nested-1" title="Item 1" level={4} />
<Section id="nested-1-1" title="Item 1-1" level={5} />
<Section id="nested-1-2" title="Item 1-2" level={5} />
<Section id="nested-2" title="Item 2" level={4} />
<Section id="nested-3" title="Item 3" level={4} />
<Section id="nested-3-1" title="Item 3-1" level={5} />
<Section id="nested-3-2" title="Item 3-2" level={5} />
</div>
</GridItem>
</Grid>
)
}Lists
Item 1
This is some placeholder content for the scrollspy example. As the box scrolls, the item for the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Item 2
This is some placeholder content for the scrollspy example. As the box scrolls, the item for the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Item 3
This is some placeholder content for the scrollspy example. As the box scrolls, the item for the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Item 4
This is some placeholder content for the scrollspy example. As the box scrolls, the item for the section being read is marked. It is repeated in every section, to give the box enough to scroll.
import { useRef } from 'react'
import { Grid, GridItem, List, ListItem, Scrollspy } from '@chassis-ui/react'
const text =
'This is some placeholder content for the scrollspy example. As the box scrolls, the item ' +
'for the section being read is marked. It is repeated in every section, to give the box ' +
'enough to scroll.'
const items = [1, 2, 3, 4]
export const Example = () => {
const box = useRef<HTMLDivElement>(null)
return (
<Grid gap="md">
<GridItem span={4}>
<Scrollspy root={box} smoothScroll>
<List>
{items.map((item) => (
<ListItem key={item} href={`#list-item-${item}`}>
Item {item}
</ListItem>
))}
</List>
</Scrollspy>
</GridItem>
<GridItem span={8}>
<div
ref={box}
role="region"
aria-label="List sections"
tabIndex={0}
style={{ height: 200, overflowY: 'auto', position: 'relative' }}
>
{items.map((item) => (
<div key={item}>
<h4 id={`list-item-${item}`}>Item {item}</h4>
<p>{text}</p>
</div>
))}
</div>
</GridItem>
</Grid>
)
}Links
Any Link inside the Scrollspy takes part, in any markup around it.
Item 1
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Item 2
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Item 3
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Item 4
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
Item 5
This is some placeholder content for the scrollspy example. As the box scrolls, the link to the section being read is marked. It is repeated in every section, to give the box enough to scroll.
import { useRef } from 'react'
import { Grid, GridItem, Link, Scrollspy } from '@chassis-ui/react'
const text =
'This is some placeholder content for the scrollspy example. As the box scrolls, the link to ' +
'the section being read is marked. It is repeated in every section, to give the box enough ' +
'to scroll.'
const items = [1, 2, 3, 4, 5]
export const Example = () => {
const box = useRef<HTMLDivElement>(null)
return (
<Grid gap="md">
<GridItem span={4}>
<Scrollspy root={box} smoothScroll>
<nav aria-label="Sections" className="d-flex flex-column gap-xs text-center">
{items.map((item) => (
<Link key={item} href={`#links-item-${item}`} className="p-2xs rounded">
Item {item}
</Link>
))}
</nav>
</Scrollspy>
</GridItem>
<GridItem span={8}>
<div
ref={box}
role="region"
aria-label="Link sections"
tabIndex={0}
style={{ height: 200, overflowY: 'auto', position: 'relative' }}
>
{items.map((item) => (
<div key={item}>
<h4 id={`links-item-${item}`}>Item {item}</h4>
<p>{text}</p>
</div>
))}
</div>
</GridItem>
</Grid>
)
}Reading the active section
onActiveChange receives the id of the section that became active, or null when none is. To
build a navigation of your own, or to show the section anywhere else, use the
useScrollspy hook, which returns the id.
Server rendering
The section being read depends on a scroll position the server doesn't have, so the server's HTML
marks no link. The first observation after hydration marks the active one. A link given active
as a prop is rendered active on the server, as anywhere. See
what the server renders.
Accessibility
The link to the active section has aria-current="true": the current place in the page. A link
given active as a prop keeps aria-current="page", the current page. Links marked because a
nested link is active only look active.
The marks change as the page scrolls and aren't announced, as the plugin's aren't. With
smoothScroll, focus stays on the link after a click, where a jump would move the starting point
of the Tab key to the section; the plugin behaves the same way. A box that
scrolls needs tabIndex={0} when nothing inside it can take focus, so a keyboard can scroll it,
and then a role and a name, as in the examples.
Scope
Sections, not scroll positions. A section is found by the id a link points to, in root
when it is set. Sections rendered after the links, a link whose href changes, and a root whose
element mounts later or is replaced are all followed, so the plugin's refresh method has no
equivalent. A section that isn't rendered, such as one under
display: none, takes no part.
No threshold. chassis-css's plugin passes threshold to its observer and picks a section
by the direction of the scroll. Here the choice depends only on where each section is, so the
same position always marks the same link, and rootMargin is the only setting.
Links, not every element with href. Only Link and the components built on it are marked.
A plain <a> isn't, since its markup belongs to React: render it with asChild on a Link or a
NavLink instead.
API
Scrollspy
| Prop | Type | Default |
|---|---|---|
children? | ReactNode | — |
The navigation whose links are marked: a Nav, a List, Links, or anything holding them. | ||
onActiveChange? | ((id: string | null) => void) | — |
Fires when the active section changes, with its id, or null when no section is active. | ||
root? | Element | RefObject<Element | null> | null | — |
| The element that scrolls, or a ref to it. The viewport when unset. | ||
rootMargin? | string | '0px 0px -25%' |
The margin around root, as IntersectionObserver takes it: one to four lengths in px or
%. Its bottom edge is the line a section's top has to pass to become the active one;
-25% puts it a quarter of the root's height above the root's bottom. | ||
smoothScroll? | boolean | false |
| Scrolls smoothly to a section when one of the links is clicked, instead of jumping to it. The page's address doesn't change. When the reader asks for reduced motion, it jumps. | ||