Command Palette

Search for a command to run...

Hectal
PHASE 24Advanced ~32 min· topic 3 of 10

Topic 24.3

Scroll Behaviour

In one line

Scrolling is part of the UX you build: new pages start at the top, Back returns to where the user was, links and buttons scroll smoothly to the right place below sticky headers, a table of contents highlights the section in view, chats stay pinned to the newest message only when the reader is there, and modals stop the page behind them from scrolling.

Think of it like this

A bookmark in a book. When you open a new book you start on page one. When you go back to the book you were reading, you expect to land on your bookmark, not on page one. A single-page app has to do both jobs by hand: scroll to the top when you open a new page, and restore your bookmark when you press Back.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Scroll restoration
Putting the scroll position back where it was when the user returns to a page.
PUSH / POP navigation
PUSH: a new entry added by a link. POP: moving through existing entries with Back or Forward.
location.key
A unique id React Router gives each history entry, handy for saving per-entry data like scroll positions.
scrollIntoView
A DOM method that scrolls the page (and any scroll boxes) until an element is visible.
scroll-margin-top
A CSS property that makes an element stop a set distance below the top when scrolled to, so a sticky header doesn't cover it.
Scroll spy
Highlighting the table-of-contents link for the section currently on screen.
Scroll chaining
When an inner scroll box reaches its end and further scrolling moves the page behind it.
overscroll-behavior
A CSS property that stops scroll chaining and pull-to-refresh for a scroll box.
Scroll lock
Stopping the page behind a modal from scrolling while the modal is open.

Step by step

01Decide what each navigation should do

A link to a new page starts at the top. Back and Forward restore the saved position. Some navigations, like switching a tab that's stored in the URL, should not scroll at all.

Decide what each navigation should dodiagram
Rendering diagram…

02Save and restore by location.key

Save scrollTop on scroll under the current location.key, then restore it in useLayoutEffect when the type is POP. If the page's data loads asynchronously, restore after it has rendered (the list must be tall enough to scroll to the old position) or keep the data cached (Topic 24.2).

root.jsxwhole filejsx
import { Outlet, ScrollRestoration } from "react-router";

// With a data router, this one line handles window scrolling for every route.
export function Root() {
  return (
    <>
      <Outlet />
      <ScrollRestoration />
    </>
  );
}

03Scroll to a target below a sticky header

Give targets scroll-margin-top equal to the header height, then call scrollIntoView. Use smooth scrolling unless the user prefers reduced motion.

styles.csswhole filecss
header { position: sticky; top: 0; height: 56px; }
section { scroll-margin-top: 56px; }

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

04Spy on sections with an observer

Observe every section with a thin band near the top. Whichever section enters the band becomes active.

05Pin a chat to the bottom only when the user is there

Track "was at the bottom" in a ref on every scroll. After new messages commit, scroll to the bottom if it's true, otherwise show a counter.

Pin a chat to the bottom only when the user is therediagram
Rendering diagram…

06Lock the page behind a modal

On open, remember the old overflow and padding-right, set overflow: hidden and pad by the scrollbar width. On close, restore them exactly. Use overscroll-behavior: contain on the modal's own scroll area.

Try it yourself

  1. 1

    Restore on every navigation

    In the router example, change the condition to always restore: box.scrollTop = saved.get(location.key) ?? 0. Predict the second line. (Still 0: a PUSH creates a new key with nothing saved.) Now make every navigation keep the old position by deleting the effect: the product opens scrolled to 450px.

  2. 2

    Remove scroll-margin

    Delete scroll-margin-top: 40px from the section rule. Predict the last log line before running. The heading now hides under the sticky header.

  3. 3

    Change "near the bottom"

    In the chat, set NEAR to 0. Why is a small tolerance needed in real apps? (Fractional scroll positions on zoomed pages and high-density screens can leave you 0.5px from the bottom.)

Code & diagrams

Scroll to top on links, restore on Back (memory router) New tab

The preview can't use the real browser history, so this uses `MemoryRouter` and a scroll box instead of the window; the logic is the same as for the window. `ScrollManager` sits after the box because layout effects run child-first, and the box's ref must already be set.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

List scrolled to 450
Opened Product 13, scrollTop 0
Back on the list, scrollTop 450
scrollIntoView, scroll-margin and a scroll spy New tab

Clicking API scrolls smoothly, and the spy marks each section as it passes the band under the header. `scroll-margin-top: 40px` makes the heading stop below the sticky header instead of under it. Click the links yourself.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Active section: Intro
Active section: Install
Active section: Usage
Active section: API
API heading clear of the 40px sticky header: true
A chat that follows new messages only when you're at the bottom New tab

While at the bottom, new messages scroll into view. After scrolling up, two new messages leave the reader alone and show a button instead. The decision is taken in `useLayoutEffect`, so there's never a visible jump.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Reading the latest, new message arrives -> at bottom: true
Reading history -> at bottom: false, scrollTop 0, pill: ↓ 2 new messages
After the pill -> at bottom: true, pill shown: false
Locking page scroll behind a modal New tab

The hook saves the old inline styles and restores them in its cleanup, so nested modals unlock in the right order. Open the modal and try to scroll the page: it doesn't move, and scrolling past the end of the terms doesn't chain to the page.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Closed: page overflow is ''
Open: page overflow is 'hidden'
Closed again: page overflow is ''

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Scrolling to the bottom with useEffect

In the chat, change useLayoutEffect to useEffect.

terminal
$ Preview
── what you'll see ──
Each new message first appears below the visible area for one frame, then the box jumps down: a visible flicker on every message.

Break #2

Forgetting to restore the page's overflow

In useScrollLock, remove the return () => Object.assign(el.style, before); cleanup.

terminal
$ Preview
── what you'll see ──
Closed again: page overflow is 'hidden'
(the page can no longer scroll after the modal closes)

Myth vs fact

Myth

Browsers restore scroll in SPAs by themselves.

Fact

They try for the window on Back, but the SPA usually re-renders the page after data loads, too late for the browser. And new pages don't go to the top at all. You (or the router) must manage it.

Myth

scrollIntoView only scrolls the nearest scroll box.

Fact

It scrolls every scrollable ancestor, including the page. In a nested layout that can move the whole page unexpectedly; scroll the container with scrollTo when you want only that.

Myth

Smooth scrolling is always nicer.

Fact

For people with vestibular disorders, large animated motion can cause dizziness. Use smooth scrolling only under prefers-reduced-motion: no-preference.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    React Router's <ScrollRestoration getKey={(location) => location.pathname} /> changes what a position is saved under: by location.key (default) every history entry has its own position; by pathname, every visit to /list shares one. Pick per product: a feed wants per-entry, a docs page wants per-path.

  • ▸

    Set history.scrollRestoration = "manual" when you take over restoration, otherwise the browser's own attempt may fight yours (it restores early, then your code restores again, causing a visible jump).

  • ▸

    The View Transitions API (document.startViewTransition, and React Router's viewTransition prop on Link) animates between routes; scroll changes made in the update callback happen inside the transition, so do the scroll reset before the new state is captured.

  • ▸

    Older iOS Safari ignored overflow: hidden on <body> for touch scrolling, which is why scroll-lock libraries set position: fixed; top: -scrollY on the body and restore scrollY on close. Test scroll locks on a real phone.

Remember this

  1. 1

    Why SPAs need help. On a classic website every link loads a new document, and the browser handles scrolling itself: new pages start at the top and Back restores the old position (the browser's history.scrollRestoration is "auto"). In a single-page app (SPA), the router only swaps components in the same document (Topic 13.1), so the scroll position stays wherever it was: you click a product at the bottom of a list and the product page opens scrolled halfway down. And when you go Back, the list is rendered again from scratch, often before its data arrives, so the browser's own restoration has nothing tall enough to scroll to.

  2. 2

    The rule: PUSH goes to the top, POP restores. React Router's useNavigationType() tells you how you got to the current location: "PUSH" (a link or navigate("/x")), "REPLACE" or "POP" (Back/Forward). Each history entry has a unique location.key. Save the scroll position under that key while the user scrolls, and in useLayoutEffect (before paint, so nobody sees a jump) either scroll to 0 or restore the saved value. With a data router (createBrowserRouter + RouterProvider), React Router's <ScrollRestoration /> component does exactly this for the window, storing positions in sessionStorage; <Link preventScrollReset> opts a link out (for tabs that change the URL but shouldn't jump).

  3. 3

    Scrolling to things. element.scrollIntoView({ behavior: "smooth", block: "start" }) scrolls every scrollable ancestor until the element is in view; container.scrollTo({ top, behavior }) scrolls one container to a number. CSS scroll-behavior: smooth makes all scrolling in a container smooth, including #anchor links. A sticky header (position: sticky or fixed) covers the top of the viewport, so the target lands hidden under it; **scroll-margin-top** on the target (or scroll-padding-top on the scroll container) leaves room. Respect users who asked for less motion: check matchMedia("(prefers-reduced-motion: reduce)") and use behavior: "auto" (instant) for them.

  4. 4

    Scroll spy (highlighting the current section in a table of contents) uses an IntersectionObserver instead of scroll maths: observe every section with a thin rootMargin band near the top of the viewport (for example "-40px 0px -75% 0px": start below a 40px header and ignore the bottom three quarters), and mark a section active when it enters the band. Mark the active link with aria-current="true" so screen readers hear it too.

  5. 5

    Chat: stick to the bottom, but only if the user is there. A chat log should follow new messages when you are reading the newest one, and leave you alone when you have scrolled up to read history. Before each update, remember whether the user was within a few pixels of the bottom (scrollHeight - scrollTop - clientHeight < 40). After new messages are committed, in useLayoutEffect, scroll to the bottom only if they were; otherwise count unread messages and show a "new messages" button. Loading older messages at the top needs the opposite fix: keep the reader's place by adding the new height to scrollTop (Topic 24.1). role="log" tells screen readers the region gets new entries.

  6. 6

    Overscroll and scroll locking. When a scrollable box inside a page reaches its end, extra scrolling chains to the page behind it, and on phones pulling down past the top can reload the page. CSS overscroll-behavior: contain stops both. While a modal is open, the page behind it shouldn't scroll at all: set overflow: hidden on <html> (or <body>) and restore the previous value on close. Removing the scrollbar makes the page wider, so content shifts sideways; add padding-right equal to the scrollbar width, or reserve space permanently with scrollbar-gutter: stable. Note that <dialog>'s showModal() (Topic 24.8) makes the page inert but does not stop it scrolling.

Explain it without notes

01

Why does an SPA need its own scroll restoration, and how would you implement it?

02

How do you scroll to a section without it ending up under a sticky header?

03

How would you implement a scroll spy for a table of contents?

04

Describe the scroll rules a chat window needs.

05

How do you stop the page from scrolling behind a modal, and what are the gotchas?

Practice

01

Write a useScrollToTopOnPush(ref) hook for a router app that scrolls a container to the top on PUSH navigations only.

02

Add a "Back to top" button to a long page that appears after scrolling 400px and respects reduced motion.

Trade-offs

  • ↔

    Restoring by location.key gives each history entry its own position (best for feeds); restoring by pathname shares one position per page (best for docs). Neither is right everywhere.

  • ↔

    overflow: hidden scroll locks are simple but need scrollbar compensation and iOS testing; the position: fixed technique works everywhere but must restore scrollY carefully and can break position: sticky children.

Done when you can

  • I can scroll to the top on new pages and restore the position on Back.

  • I can scroll to an element smoothly and keep it clear of a sticky header.

  • I can build a scroll spy with IntersectionObserver and aria-current.

  • I can make a chat follow new messages only when the reader is at the bottom.

  • I can lock and restore page scrolling for a modal and stop scroll chaining.