MUI Docs Infra

Warning

This is an internal project, and is not intended for public use. No support or stability guarantees are provided.

Use Scroll Anchor

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.

Why?

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.

Why not browser scroll anchoring?

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.

  • Coverage gaps. It isn't implemented in Safari at all (WebKit bug 171099), and where it is supported it only compensates for changes above the chosen anchor — so layout changes below your focused row go uncorrected.
  • Snap, not track. It applies a single correction once layout settles, so the row visibly drifts during any height transition or other sustained animation and the browser only catches up at the end.

useScrollAnchor fills these gaps: pick the anchor element explicitly, control the duration, and the hook keeps compensating frame-by-frame for the entire animation.

See it in action

Comparing browser anchoring with the hook

Comparison

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.

  • Without the hook — the events you were already reading get pushed down off the screen as older history loads in. Some browsers compensate for the instant case, but the reader still loses their place during animations.
  • With useScrollAnchor — the topmost event in your viewport stays exactly where it was, animated or not, on every browser.
Anchoring strategy

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.

  1. Build started

    CI picked up commit a1b2c3d on the release branch.

  2. Tests queued

    Sharding 1,284 specs across 8 runners.

  3. Lint passed

    No new warnings introduced; 0 errors across 482 files.

  4. Unit tests passed

    1,284 specs, 0 failures, 17 skipped.

  5. Bundle uploaded

    Artifact pushed to staging at s3://artifacts/release/2.4.0.tar.gz.

  6. Smoke tests started

    Spawning 12 browsers across 3 regions.

  1. Smoke tests passed

    All regions green. Ready for canary rollout.

  2. Canary deployed

    5% of traffic routed to release-2.4.0. Monitoring error budget.

  3. Rollout complete

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

Safari has no native anchoring at all

Safari

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.

Anchoring strategy

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.

  1. priya

    Smoke tests are green. Going to start the canary in five.

  2. leo

    Cool. I have the dashboard open if anything spikes.

  3. priya

    Canary is live. 5% of traffic on release-2.4.0.

  4. leo

    Error rate flat. Latency p95 within 2ms of baseline.

  5. priya

    Bumping to 25%.

'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>
  );
}

Basic Usage

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.

Anchoring inside a scroll container

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.

Chat

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.

# release-roomMigration coordination
  1. Priya

    Migration is running on prod. ETA about 3 minutes.

  2. Marco

    Watching the dashboard. p99 looks normal.

  3. Sam

    Done. Schema version bumped to 47.

'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>
);

How it works

  1. When you call anchorScroll(anchor, duration), the hook records anchor.getBoundingClientRect().top.
  2. A 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.
  3. The session ends when 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.

When to use it

Reach for useScrollAnchor whenever a layout change happens above content the user is reading or interacting with:

  • Expand / collapse sections, accordions, and code blocks.
  • Tab switches that change panel height.
  • "Show more" lists.
  • Inserting new items above a focused row, like loading older chat history or activity-feed entries.

You don't need it for changes that happen below the viewport, or when the changing element is itself the focus of attention.

API Reference

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.

Return Type
UseScrollAnchorResult
KeyTypeRequired
containerRef
React.RefObject<HTMLElement | null>
Yes
scrollContainerRef
React.RefObject<HTMLElement | null>
Yes
anchorScroll
(
  anchor: HTMLElement | null,
  duration: number,
) => void
Yes
UseScrollAnchorResult

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.