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.
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).
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.
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.
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
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
Remove scroll-margin
Delete
scroll-margin-top: 40pxfrom the section rule. Predict the last log line before running. The heading now hides under the sticky header. - 3
Change "near the bottom"
In the chat, set
NEARto 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
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
Console
Nothing logged yet.Expected console output
List scrolled to 450
Opened Product 13, scrollTop 0
Back on the list, scrollTop 450Clicking 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
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: trueWhile 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
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: falseThe 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
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.
Break #2
Forgetting to restore the page's overflow
In useScrollLock, remove the return () => Object.assign(el.style, before); cleanup.
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: bylocation.key(default) every history entry has its own position; by pathname, every visit to/listshares 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'sviewTransitionprop onLink) 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: hiddenon<body>for touch scrolling, which is why scroll-lock libraries setposition: fixed; top: -scrollYon the body and restorescrollYon close. Test scroll locks on a real phone.
Remember this
- 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.scrollRestorationis"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
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 ornavigate("/x")),"REPLACE"or"POP"(Back/Forward). Each history entry has a uniquelocation.key. Save the scroll position under that key while the user scrolls, and inuseLayoutEffect(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 insessionStorage;<Link preventScrollReset>opts a link out (for tabs that change the URL but shouldn't jump). - 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. CSSscroll-behavior: smoothmakes all scrolling in a container smooth, including#anchorlinks. A sticky header (position: stickyorfixed) covers the top of the viewport, so the target lands hidden under it; **scroll-margin-top** on the target (orscroll-padding-topon the scroll container) leaves room. Respect users who asked for less motion: checkmatchMedia("(prefers-reduced-motion: reduce)")and usebehavior: "auto"(instant) for them. - 4
Scroll spy (highlighting the current section in a table of contents) uses an
IntersectionObserverinstead of scroll maths: observe every section with a thinrootMarginband 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 witharia-current="true"so screen readers hear it too. - 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, inuseLayoutEffect, 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 toscrollTop(Topic 24.1).role="log"tells screen readers the region gets new entries. - 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: containstops both. While a modal is open, the page behind it shouldn't scroll at all: setoverflow: hiddenon<html>(or<body>) and restore the previous value on close. Removing the scrollbar makes the page wider, so content shifts sideways; addpadding-rightequal to the scrollbar width, or reserve space permanently withscrollbar-gutter: stable. Note that<dialog>'sshowModal()(Topic 24.8) makes the page inert but does not stop it scrolling.
Explain it without notes
Why does an SPA need its own scroll restoration, and how would you implement it?
How do you scroll to a section without it ending up under a sticky header?
How would you implement a scroll spy for a table of contents?
Describe the scroll rules a chat window needs.
How do you stop the page from scrolling behind a modal, and what are the gotchas?
Practice
Write a useScrollToTopOnPush(ref) hook for a router app that scrolls a container to the top on PUSH navigations only.
Add a "Back to top" button to a long page that appears after scrolling 400px and respects reduced motion.
Trade-offs
- ↔
Restoring by
location.keygives 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: hiddenscroll locks are simple but need scrollbar compensation and iOS testing; theposition: fixedtechnique works everywhere but must restorescrollYcarefully and can breakposition: stickychildren.
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.