Warning
This is an internal project, and is not intended for public use. No support or stability guarantees are provided.
The useScrollAnchor hook keeps an anchor element visually fixed in the viewport while a nearby container element changes size. It is most useful when content is being inserted or expanded above the element the user is focused on — that's the case where they would otherwise lose their place.
Expanding a section, switching tabs, or revealing collapsed content above the user's focus pushes everything below the change down (or pulls it up). When the user's eye is fixed on a piece of content that lives below the change, it slides out of view — which feels jarring and forces them to re-find their place.
useScrollAnchor fixes this by observing the container, measuring the chosen anchor's position before the layout change, and scrolling the page to compensate as the layout settles.
Modern browsers ship a CSS feature called scroll anchoring (CSS-Tricks has a good interactive walkthrough) that handles a narrow set of cases for free.
useScrollAnchor fills these gaps: pick the anchor element explicitly, control the duration, and the hook keeps compensating frame-by-frame for the entire animation.
A timeline of release events, with the most recent at the bottom under a "Currently viewing" divider. Click Load earlier events to insert older entries above your current scroll position. Toggle Animate to control whether the insertion is instant or transitions over a few hundred milliseconds.
Relies on the browser’s built-in overflow-anchor. The events you were already reading get pushed down off-screen as older history loads in above them.
CI picked up commit a1b2c3d on the release branch.
Sharding 1,284 specs across 8 runners.
No new warnings introduced; 0 errors across 482 files.
1,284 specs, 0 failures, 17 skipped.
Artifact pushed to staging at s3://artifacts/release/2.4.0.tar.gz.
Spawning 12 browsers across 3 regions.
All regions green. Ready for canary rollout.
5% of traffic routed to release-2.4.0. Monitoring error budget.
100% of traffic on release-2.4.0. No regression detected over 13m window.
'use client';
import * as React from 'react';
import { BrowserApproach } from './BrowserApproach';
import { HookApproach } from './HookApproach';
import styles from './Comparison.module.css';
type Mode = 'browser' | 'hook';
const modeQuality: Record<Mode, 'bad' | 'good'> = {
browser: 'bad',
hook: 'good',
};
const modeLabels: Record<Mode, string> = {
browser: 'Without the hook',
hook: 'With useScrollAnchor',
};
const modeBadges: Record<Mode, string> = {
browser: 'Problem',
hook: 'Fix',
};
const modeDescriptions: Record<Mode, string> = {
browser:
'Relies on the browser’s built-in overflow-anchor. The events you were already reading get pushed down off-screen as older history loads in above them.',
hook: 'useScrollAnchor pins the topmost visible event before the layout change, so the events you were already reading stay exactly where they were.',
};
const approaches: Record<Mode, React.ComponentType<{ animate: boolean }>> = {
browser: BrowserApproach,
hook: HookApproach,
};
export function Comparison() {
const [mode, setMode] = React.useState<Mode>('browser');
const [animate, setAnimate] = React.useState(true);
const Approach = approaches[mode];
return (
<div className={styles.root} data-mode={mode}>
<div className={styles.controls}>
<fieldset className={styles.modePicker}>
<legend className={styles.modeLegend}>Anchoring strategy</legend>
{(Object.keys(modeLabels) as Mode[]).map((value) => (
<label
key={value}
className={styles.modeOption}
data-active={value === mode}
data-quality={modeQuality[value]}
>
<input
type="radio"
name="anchor-mode"
value={value}
checked={value === mode}
onChange={() => setMode(value)}
/>
<span className={styles.modeBadge}>{modeBadges[value]}</span>
<span>{modeLabels[value]}</span>
</label>
))}
</fieldset>
<label className={styles.switch}>
<input
type="checkbox"
checked={animate}
onChange={(event) => setAnimate(event.target.checked)}
/>
<span>Animate</span>
</label>
</div>
<p className={styles.hint} data-quality={modeQuality[mode]}>
{modeDescriptions[mode]}
</p>
<Approach key={mode} animate={animate} />
</div>
);
}
The previous demo's Animate off case looked fine in Browser scroll anchoring mode — but only on Chromium and Firefox. Safari doesn't ship CSS scroll anchoring, so any non-user-initiated layout change above the viewport pushes the reader off-screen with nothing to compensate.
A common case is a chat thread streaming older history in from the server. Each older message arrives asynchronously and prepends to the top — the user didn't click anything, but their conversation slides down by one message every time. This demo simulates that on every browser by disabling native anchoring. Switch to useScrollAnchor hook to see the topmost message stay pinned regardless.
Safari has no native CSS scroll anchoring. Each older message that streams in pushes the rest of the conversation down — what the reader was looking at slides out of view.
'use client';
import * as React from 'react';
import { HookApproach } from './HookApproach';
import { SafariApproach } from './SafariApproach';
import styles from './Safari.module.css';
type Mode = 'safari' | 'hook';
const modeQuality: Record<Mode, 'bad' | 'good'> = {
safari: 'bad',
hook: 'good',
};
const modeLabels: Record<Mode, string> = {
safari: 'Safari behaviour',
hook: 'useScrollAnchor hook',
};
const modeBadges: Record<Mode, string> = {
safari: 'Problem',
hook: 'Fix',
};
const modeDescriptions: Record<Mode, string> = {
safari:
'Safari has no native CSS scroll anchoring. Each older message that streams in pushes the rest of the conversation down — what the reader was looking at slides out of view.',
hook: 'useScrollAnchor pins the topmost visible message before each prepend, so the conversation the reader is following stays put on every browser.',
};
const approaches: Record<Mode, React.ComponentType> = {
safari: SafariApproach,
hook: HookApproach,
};
export function Safari() {
const [mode, setMode] = React.useState<Mode>('safari');
const Approach = approaches[mode];
return (
<div className={styles.root} data-mode={mode}>
<div className={styles.controls}>
<fieldset className={styles.modePicker}>
<legend className={styles.modeLegend}>Anchoring strategy</legend>
{(Object.keys(modeLabels) as Mode[]).map((value) => (
<label
key={value}
className={styles.modeOption}
data-active={value === mode}
data-quality={modeQuality[value]}
>
<input
type="radio"
name="safari-mode"
value={value}
checked={value === mode}
onChange={() => setMode(value)}
/>
<span className={styles.modeBadge}>{modeBadges[value]}</span>
<span>{modeLabels[value]}</span>
</label>
))}
</fieldset>
</div>
<p className={styles.hint} data-quality={modeQuality[mode]}>
{modeDescriptions[mode]}
</p>
<Approach key={mode} />
</div>
);
}
Attach containerRef to the element whose layout is about to change, then call anchorScroll(anchor, duration) just before the layout change. The page will scroll to keep anchor.getBoundingClientRect().top constant for duration ms.
The expanding content goes above the anchor — that's the case where the user would otherwise lose their place.
import { useScrollAnchor } from '@mui/internal-docs-infra/useScrollAnchor';
function Section() {
const [expanded, setExpanded] = React.useState(false);
const { containerRef, anchorScroll } = useScrollAnchor<HTMLDivElement>();
const anchorRef = React.useRef<HTMLDivElement>(null);
return (
<div ref={containerRef}>
<button
type="button"
onClick={() => {
// Anchor before the layout-changing state update.
// The duration must match the CSS transition on the expanding element.
anchorScroll(anchorRef.current, 350);
setExpanded((prev) => !prev);
}}
>
Toggle
</button>
{expanded && <LongContent />}
<div ref={anchorRef}>{/* the element that should stay put */}</div>
</div>
);
}
anchorScroll accepts null and quietly no-ops, and it also no-ops when containerRef hasn't attached yet — so passing anchorRef.current directly is safe even on first render or in SSR scenarios.
For a code-block-specific wrapper around this hook, see useCodeWindow.
By default the hook compensates the page's scroll position. Pass scrollContainerRef to a scrollable element instead and the hook will adjust that container's scrollTop — useful for chat windows, activity feeds, or any list rendered inside its own overflow region.
The demo below loads three pages of older messages on click. The topmost message in the viewport stays anchored to the same screen position while history fills in above it; the surrounding page never scrolls.
'use client';
import * as React from 'react';
import { useScrollAnchor } from '@mui/internal-docs-infra/useScrollAnchor';
import styles from './Chat.module.css';
type Message = {
id: string;
author: string;
avatar: string;
time: string;
body: string;
};
// Pages of older messages, served oldest-page-first so the most recent
// page lands closest to the divider when prepended.
const olderPages: Message[][] = [
[
{
id: 'p3-1',
author: 'Priya',
avatar: 'P',
time: '09:02',
body: 'Morning! Pushing the migration script for review in a sec.',
},
{
id: 'p3-2',
author: 'Marco',
avatar: 'M',
time: '09:05',
body: "Nice. I'll spin up a staging DB so we can dry-run it.",
},
{
id: 'p3-3',
author: 'Priya',
avatar: 'P',
time: '09:11',
body: 'PR is up: #4821. Tests are green.',
},
],
[
{
id: 'p2-1',
author: 'Sam',
avatar: 'S',
time: '09:18',
body: 'Reviewed - left two comments about the rollback path.',
},
{
id: 'p2-2',
author: 'Priya',
avatar: 'P',
time: '09:24',
body: 'Good catch on the FK constraint. Pushing a fix.',
},
{
id: 'p2-3',
author: 'Marco',
avatar: 'M',
time: '09:27',
body: 'Staging dry-run took 42s. No errors.',
},
],
[
{
id: 'p1-1',
author: 'Sam',
avatar: 'S',
time: '09:33',
body: 'Re-reviewed. Looks good to me.',
},
{
id: 'p1-2',
author: 'Priya',
avatar: 'P',
time: '09:35',
body: 'Merging now. Will kick off the prod migration after lunch.',
},
],
];
const recentMessages: Message[] = [
{
id: 'r1',
author: 'Priya',
avatar: 'P',
time: '12:48',
body: 'Migration is running on prod. ETA about 3 minutes.',
},
{
id: 'r2',
author: 'Marco',
avatar: 'M',
time: '12:51',
body: 'Watching the dashboard. p99 looks normal.',
},
{
id: 'r3',
author: 'Sam',
avatar: 'S',
time: '12:53',
body: 'Done. Schema version bumped to 47.',
},
];
export function Chat() {
const [loadedPages, setLoadedPages] = React.useState(0);
const [freshIds, setFreshIds] = React.useState<ReadonlySet<string>>(() => new Set());
// The thread scrolls inside its own overflow region, so attach
// `scrollContainerRef` to that scrollable element. The hook will
// compensate that container's scroll instead of the page.
const { containerRef, scrollContainerRef, anchorScroll } = useScrollAnchor<
HTMLDivElement,
HTMLDivElement
>();
// Track the rendered messages so we can anchor on the topmost one in
// the viewport - that's the message the user is reading right now.
const messageRefs = React.useRef(new Map<string, HTMLLIElement | null>());
const loadOlder = () => {
// Pick the topmost message currently inside the scroll container.
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const viewportRect = scrollContainer.getBoundingClientRect();
let topVisible: HTMLLIElement | null = null;
let topVisibleY = Infinity;
messageRefs.current.forEach((node) => {
if (!node) {
return;
}
const rect = node.getBoundingClientRect();
if (
rect.bottom > viewportRect.top &&
rect.top < viewportRect.bottom &&
rect.top < topVisibleY
) {
topVisible = node;
topVisibleY = rect.top;
}
});
anchorScroll(topVisible, 350);
setLoadedPages((prev) => {
const next = Math.min(prev + 1, olderPages.length);
if (next > prev) {
const newPage = olderPages[olderPages.length - next];
setFreshIds(new Set(newPage.map((message) => message.id)));
}
return next;
});
};
const visiblePages = olderPages.slice(olderPages.length - loadedPages);
const allMessages = [...visiblePages.flat(), ...recentMessages];
const hasMore = loadedPages < olderPages.length;
return (
<div className={styles.root}>
<header className={styles.header}>
<span className={styles.channel}># release-room</span>
<span className={styles.subtitle}>Migration coordination</span>
{loadedPages > 0 ? (
<button
type="button"
className={styles.resetButton}
onClick={() => {
setLoadedPages(0);
setFreshIds(new Set());
}}
>
Reset
</button>
) : null}
</header>
<div ref={scrollContainerRef} className={styles.scroller}>
<div ref={containerRef} className={styles.thread}>
<div className={styles.loadRow}>
{hasMore ? (
<button type="button" onClick={loadOlder} className={styles.loadButton}>
Load earlier messages
</button>
) : (
<span className={styles.threadStart}>Beginning of conversation</span>
)}
</div>
<ol className={styles.list}>
{allMessages.map((message) => (
<li
key={message.id}
ref={(node) => {
messageRefs.current.set(message.id, node);
}}
className={styles.message}
data-fresh={freshIds.has(message.id) ? '' : undefined}
>
<div className={styles.avatar} aria-hidden="true">
{message.avatar}
</div>
<div className={styles.body}>
<div className={styles.meta}>
<span className={styles.author}>{message.author}</span>
<time className={styles.time}>{message.time}</time>
</div>
<p className={styles.text}>{message.body}</p>
</div>
</li>
))}
</ol>
</div>
</div>
</div>
);
}
Wire scrollContainerRef to the scrollable element and containerRef to the element whose size changes inside it. Call anchorScroll(anchor, duration) exactly as in Basic Usage — the hook adjusts the container's scrollTop instead of the page.
The first generic types containerRef (the resizing element); the second types scrollContainerRef (the scrollable ancestor).
const { containerRef, scrollContainerRef, anchorScroll } = useScrollAnchor<
HTMLDivElement,
HTMLDivElement
>();
return (
<div ref={scrollContainerRef} className={styles.scroller}>
<div ref={containerRef}>{/* messages */}</div>
</div>
);
anchorScroll(anchor, duration), the hook records anchor.getBoundingClientRect().top.ResizeObserver watches the container. Each time it fires, the hook compares the anchor's new top to the recorded one and calls window.scrollBy(0, delta) to compensate (or adjusts the scroll container's scrollTop when scrollContainerRef is set). Sub-pixel deltas (Math.abs(delta) <= 0.5) are ignored.duration (plus a small safety buffer) elapses, the user interacts once (a single wheel, touchmove, pointerdown, or keydown — listeners are registered with { once: true } on the scroll container when one is provided, otherwise on the window), or anchorScroll is called again.This means the hook only fights the browser while content is animating; once the user grabs the page, control returns to them immediately.
Reach for useScrollAnchor whenever a layout change happens above content the user is reading or interacting with:
You don't need it for changes that happen below the viewport, or when the changing element is itself the focus of attention.
Keeps an anchor element visually fixed in the viewport while a nearby container element changes size.
Useful around expand/collapse, accordion, and tab-switch transitions
where the natural document flow would otherwise push focused content out
of (or into) the viewport. Uses a ResizeObserver on the container to
react to layout changes without polling, and scrollBy to nudge either
the page or an opt-in scroll container so the anchor’s
getBoundingClientRect().top stays constant.
UseScrollAnchorResult| Key | Type | Required |
|---|---|---|
| containerRef | | Yes |
| scrollContainerRef | | Yes |
| anchorScroll | | Yes |
Result returned by useScrollAnchor.
type UseScrollAnchorResult<
TContainer extends HTMLElement,
TScroll extends HTMLElement = HTMLElement,
> = {
/**
* Ref to attach to the element whose layout is about to change. Resize
* events on this element drive the scroll compensation.
*/
containerRef: React.RefObject<TContainer | null>;
/**
* Optional ref to attach to a scrollable ancestor that should be
* compensated instead of the page. When left unattached, the hook
* compensates `window` scroll, which is the right default for most
* full-page layouts. Attach it when the changing container lives inside
* its own `overflow: auto` region (chat threads, side panels, modals).
*/
scrollContainerRef: React.RefObject<TScroll | null>;
/**
* Start an anchoring session. Records the current viewport position of
* `anchor` and, while the container resizes over the next `duration` ms,
* scrolls the page (or the attached `scrollContainerRef`) so the anchor
* stays at the same position.
*
* The session ends when any of the following happens:
* - The user interacts (wheel, touchmove, pointerdown, keydown).
* - `duration` ms (plus a small safety buffer) elapse.
* - A new `anchorScroll` call starts.
* - The hosting component unmounts.
*/
anchorScroll: (anchor: HTMLElement | null, duration: number) => void;
}useCodeWindow — built on top of this hook to keep collapsed code blocks anchored as they expand.