From 64f72b4a1695f8d915cd4031648810b50aa7f202 Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Fri, 13 Nov 2026 14:19:48 -0600 Subject: [PATCH 1/2] feat: add useScrollAnchor with an interactive demo Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/safari-scroll-anchor.md | 5 + .../hooks/__tests__/useScrollAnchor.test.tsx | 107 ++++++++++++++++++ packages/react/src/hooks/index.ts | 2 + .../src/hooks/useScrollAnchor.hookDocs.json | 22 ++++ packages/react/src/hooks/useScrollAnchor.ts | 62 ++++++++++ packages/react/src/index.ts | 1 + .../stories/ScrollAnchor.stories.module.css | 45 ++++++++ .../src/stories/useScrollAnchor.stories.tsx | 64 +++++++++++ 8 files changed, 308 insertions(+) create mode 100644 .changeset/safari-scroll-anchor.md create mode 100644 packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx create mode 100644 packages/react/src/hooks/useScrollAnchor.hookDocs.json create mode 100644 packages/react/src/hooks/useScrollAnchor.ts create mode 100644 packages/react/src/stories/ScrollAnchor.stories.module.css create mode 100644 packages/react/src/stories/useScrollAnchor.stories.tsx diff --git a/.changeset/safari-scroll-anchor.md b/.changeset/safari-scroll-anchor.md new file mode 100644 index 00000000000..9b1cdb83dc4 --- /dev/null +++ b/.changeset/safari-scroll-anchor.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +useScrollAnchor: Preserve an element's viewport position as its container content changes. diff --git a/packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx b/packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx new file mode 100644 index 00000000000..d5558d1a34c --- /dev/null +++ b/packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx @@ -0,0 +1,107 @@ +import {act, render, waitFor} from '@testing-library/react' +import {useRef} from 'react' +import {expect, it, vi} from 'vitest' +import {useScrollAnchor} from '../../hooks/useScrollAnchor' + +function ScrollRegion({getAnchorTop, anchorVersion = 'initial'}: {getAnchorTop: () => number; anchorVersion?: string}) { + const containerRef = useRef(null) + const anchorRef = useRef(null) + + useScrollAnchor({containerRef, anchorRef}) + + return ( +
+
{ + anchorRef.current = node + if (node) { + vi.spyOn(node, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, getAnchorTop(), 100, 20)) + } + }} + data-testid="anchor" + /> +
+ ) +} + +it('preserves the anchor position when content changes above it', async () => { + const anchor = {top: 300} + const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation((_x, y) => { + anchor.top -= y + }) + + const {container} = render( anchor.top} />) + + await act(async () => { + container.firstElementChild?.prepend(document.createElement('div')) + anchor.top += 48 + }) + + await waitFor(() => { + expect(scrollBy).toHaveBeenCalledWith(0, 48) + }) + expect(anchor.top).toBe(300) + + scrollBy.mockRestore() +}) + +it('uses the latest anchor position after the user scrolls', async () => { + const anchor = {top: 300} + const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation((_x, y) => { + anchor.top -= y + }) + + const {container} = render( anchor.top} />) + + anchor.top = 240 + act(() => { + window.dispatchEvent(new Event('scroll')) + }) + + await act(async () => { + container.firstElementChild?.prepend(document.createElement('div')) + anchor.top += 20 + }) + + await waitFor(() => { + expect(scrollBy).toHaveBeenCalledWith(0, 20) + }) + expect(anchor.top).toBe(240) + + scrollBy.mockRestore() +}) + +it('does not adjust scroll when the anchor position is unchanged', async () => { + const scrollBy = vi.spyOn(window, 'scrollBy') + const {container} = render( 0} />) + + await act(async () => { + container.firstElementChild?.prepend(document.createElement('div')) + }) + + await waitFor(() => { + expect(container.firstElementChild?.children).toHaveLength(2) + }) + expect(scrollBy).not.toHaveBeenCalled() + + scrollBy.mockRestore() +}) + +it('preserves the position when the anchor element is replaced', async () => { + const anchor = {top: 300} + const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation((_x, y) => { + anchor.top -= y + }) + + const {rerender} = render( anchor.top} />) + anchor.top += 32 + rerender( anchor.top} anchorVersion="updated" />) + + await waitFor(() => { + expect(scrollBy).toHaveBeenCalledWith(0, 32) + }) + expect(anchor.top).toBe(300) + + scrollBy.mockRestore() +}) diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index eece539e4a4..1af5668d692 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -17,4 +17,6 @@ export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef' export {useId} from './useId' export {useIsMacOS} from './useIsMacOS' export {useMergedRefs} from './useMergedRefs' +export {useScrollAnchor} from './useScrollAnchor' +export type {UseScrollAnchorSettings} from './useScrollAnchor' export {useSlots} from './useSlots' diff --git a/packages/react/src/hooks/useScrollAnchor.hookDocs.json b/packages/react/src/hooks/useScrollAnchor.hookDocs.json new file mode 100644 index 00000000000..44fcba56869 --- /dev/null +++ b/packages/react/src/hooks/useScrollAnchor.hookDocs.json @@ -0,0 +1,22 @@ +{ + "name": "useScrollAnchor", + "importPath": "@primer/react", + "stories": [{"id": "hooks-usescrollanchor--scroll-anchor"}], + "parameters": [ + { + "name": "containerRef", + "type": "RefObject", + "required": true, + "description": "A ref to the container whose DOM changes should be observed." + }, + { + "name": "anchorRef", + "type": "RefObject", + "required": true, + "description": "A ref to the visible element whose viewport position should be preserved. Only window scrolling is adjusted." + } + ], + "returns": { + "type": "void" + } +} diff --git a/packages/react/src/hooks/useScrollAnchor.ts b/packages/react/src/hooks/useScrollAnchor.ts new file mode 100644 index 00000000000..82d0aabdc6b --- /dev/null +++ b/packages/react/src/hooks/useScrollAnchor.ts @@ -0,0 +1,62 @@ +import type {RefObject} from 'react' +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' + +export interface UseScrollAnchorSettings { + containerRef: RefObject + anchorRef: RefObject +} + +/** + * Preserves the viewport position of an anchor when content in its container changes. + * The anchor should be a visible element, and this hook only adjusts the window scroll position. + * + * @param containerRef A ref to the container whose DOM changes should be observed. + * @param anchorRef A ref to the visible element whose viewport position should be preserved. + */ +export function useScrollAnchor({containerRef, anchorRef}: UseScrollAnchorSettings): void { + useIsomorphicLayoutEffect(() => { + const container = containerRef.current + const initialAnchor = anchorRef.current + + if (!container || !initialAnchor || !container.contains(initialAnchor)) { + return + } + + let anchorTop = initialAnchor.getBoundingClientRect().top + + const updateAnchorTop = () => { + const anchor = anchorRef.current + if (anchor && container.contains(anchor)) { + anchorTop = anchor.getBoundingClientRect().top + } + } + + const observer = new MutationObserver(() => { + const anchor = anchorRef.current + if (!anchor || !container.contains(anchor)) { + return + } + + const currentTop = anchor.getBoundingClientRect().top + const offset = currentTop - anchorTop + + if (offset !== 0) { + window.scrollBy(0, offset) + anchorTop = currentTop - offset + } + }) + + observer.observe(container, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) + window.addEventListener('scroll', updateAnchorTop, {passive: true}) + + return () => { + observer.disconnect() + window.removeEventListener('scroll', updateAnchorTop) + } + }, [anchorRef, containerRef]) +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 48750d9e583..bd8aca950de 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -53,6 +53,7 @@ export type {FocusZoneHookSettings} from './hooks/useFocusZone' export {useRefObjectAsForwardedRef} from './hooks/useRefObjectAsForwardedRef' export {useMergedRefs} from './hooks/useMergedRefs' export {useResizeObserver} from './hooks/useResizeObserver' +export {useScrollAnchor, type UseScrollAnchorSettings} from './hooks/useScrollAnchor' export {useResponsiveValue, type ResponsiveValue} from './hooks/useResponsiveValue' export {default as useIsomorphicLayoutEffect} from './utils/useIsomorphicLayoutEffect' export {useProvidedRefOrCreate} from './hooks/useProvidedRefOrCreate' diff --git a/packages/react/src/stories/ScrollAnchor.stories.module.css b/packages/react/src/stories/ScrollAnchor.stories.module.css new file mode 100644 index 00000000000..532c15bcbae --- /dev/null +++ b/packages/react/src/stories/ScrollAnchor.stories.module.css @@ -0,0 +1,45 @@ +.Root { + padding: var(--base-size-16); + color: var(--fgColor-default); +} + +.Instructions { + margin: 0 0 var(--base-size-16); +} + +.Controls { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: var(--base-size-8); + padding: var(--base-size-8); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-medium); + background: var(--bgColor-default); +} + +.Status { + margin-left: auto; +} + +.Timeline { + margin-top: var(--base-size-16); + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-medium); +} + +.Item { + min-height: 5rem; + padding: var(--base-size-16); + border-bottom: var(--borderWidth-thin) solid var(--borderColor-muted); +} + +.Item:last-child { + border-bottom: 0; +} + +.Anchor { + background: var(--bgColor-attention-muted); +} diff --git a/packages/react/src/stories/useScrollAnchor.stories.tsx b/packages/react/src/stories/useScrollAnchor.stories.tsx new file mode 100644 index 00000000000..95035b70c69 --- /dev/null +++ b/packages/react/src/stories/useScrollAnchor.stories.tsx @@ -0,0 +1,64 @@ +import React, {useRef, useState} from 'react' +import type {Meta} from '@storybook/react-vite' +import {Button} from '../Button' +import {useScrollAnchor} from '../hooks/useScrollAnchor' +import classes from './ScrollAnchor.stories.module.css' + +export default { + title: 'Hooks/useScrollAnchor', +} as Meta + +export const ScrollAnchor = () => { + const [enabled, setEnabled] = useState(true) + const [loadedPages, setLoadedPages] = useState(0) + const containerRef = useRef(null) + const anchorRef = useRef(null) + const disabledAnchorRef = useRef(null) + + useScrollAnchor({containerRef, anchorRef: enabled ? anchorRef : disabledAnchorRef}) + + const olderComments = Array.from({length: loadedPages * 3}, (_, index) => `Earlier comment ${index + 1}`) + + return ( + <> + +
+

+ Scroll until the highlighted comment is in view, then load earlier comments. Toggle the hook to compare how + the comment moves. Native browser scroll anchoring is disabled in this story so the hook's effect is visible + in every browser. +

+

+ {'useScrollAnchor({containerRef, anchorRef})'} watches the timeline container and preserves the + highlighted comment's viewport position. It adjusts window scrolling only. +

+
+ + + Hook is {enabled ? 'on' : 'off'} +
+
+ {olderComments.map(comment => ( +
+ {comment} +
+ ))} + {['Comment 1', 'Comment 2', 'Comment 3'].map(comment => ( +
+ {comment} +
+ ))} +
+ You're reading this comment +

Keep this comment at the same viewport position while content is added above it.

+
+ {Array.from({length: 12}, (_, index) => ( +
+ Comment {index + 4} +
+ ))} +
+
+ + ) +} From dd1192ce47ca81aea16390da435c9418f5f535c8 Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Fri, 13 Nov 2026 16:11:57 -0600 Subject: [PATCH 2/2] fix: resolve scroll anchor CI failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 2 ++ .../react/src/hooks/__tests__/useScrollAnchor.test.tsx | 2 +- packages/react/src/hooks/useScrollAnchor.ts | 3 ++- packages/react/src/stories/useScrollAnchor.stories.tsx | 8 ++++---- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index ee096520739..faddf3c3548 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -254,6 +254,8 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useResponsiveValue", "useRovingTabIndex", "useSafeTimeout", + "useScrollAnchor", + "UseScrollAnchorSettings", "useSlots", "useSyncedState", "useTheme", diff --git a/packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx b/packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx index d5558d1a34c..1d66b12860e 100644 --- a/packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx +++ b/packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx @@ -5,7 +5,7 @@ import {useScrollAnchor} from '../../hooks/useScrollAnchor' function ScrollRegion({getAnchorTop, anchorVersion = 'initial'}: {getAnchorTop: () => number; anchorVersion?: string}) { const containerRef = useRef(null) - const anchorRef = useRef(null) + const anchorRef = useRef(null) useScrollAnchor({containerRef, anchorRef}) diff --git a/packages/react/src/hooks/useScrollAnchor.ts b/packages/react/src/hooks/useScrollAnchor.ts index 82d0aabdc6b..e6313b18bda 100644 --- a/packages/react/src/hooks/useScrollAnchor.ts +++ b/packages/react/src/hooks/useScrollAnchor.ts @@ -52,7 +52,8 @@ export function useScrollAnchor({containerRef, anchorRef}: UseScrollAnchorSettin childList: true, subtree: true, }) - window.addEventListener('scroll', updateAnchorTop, {passive: true}) + // eslint-disable-next-line github/prefer-observers -- Track every scroll position to preserve the anchor's current viewport position. + window.addEventListener('scroll', updateAnchorTop) return () => { observer.disconnect() diff --git a/packages/react/src/stories/useScrollAnchor.stories.tsx b/packages/react/src/stories/useScrollAnchor.stories.tsx index 95035b70c69..52c9e3c62ec 100644 --- a/packages/react/src/stories/useScrollAnchor.stories.tsx +++ b/packages/react/src/stories/useScrollAnchor.stories.tsx @@ -25,12 +25,12 @@ export const ScrollAnchor = () => {

Scroll until the highlighted comment is in view, then load earlier comments. Toggle the hook to compare how - the comment moves. Native browser scroll anchoring is disabled in this story so the hook's effect is visible - in every browser. + the comment moves. Native browser scroll anchoring is disabled in this story so the hook's effect is + visible in every browser.

{'useScrollAnchor({containerRef, anchorRef})'} watches the timeline container and preserves the - highlighted comment's viewport position. It adjusts window scrolling only. + highlighted comment's viewport position. It adjusts window scrolling only.

@@ -49,7 +49,7 @@ export const ScrollAnchor = () => {
))}
- You're reading this comment + You're reading this comment

Keep this comment at the same viewport position while content is added above it.

{Array.from({length: 12}, (_, index) => (