Skip to main contentSkip to docs navigation

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.

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.

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.

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.

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.

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.

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

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

JSX
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>
  )
}

Any Link inside the Scrollspy takes part, in any markup around it.

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.

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.

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.

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.

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.

JSX
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

PropTypeDefault
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?booleanfalse
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.