Skip to content

ScrollArea: move the thumb with transform instead of inset - #668

Merged
blvdmitry merged 4 commits into
mainfrom
claude/animation-performance-j38gm8-scroll-area
Sep 12, 2026
Merged

blvdmitry merged 4 commits into
mainfrom
claude/animation-performance-j38gm8-scroll-area

Conversation

@blvdmitry

@blvdmitry blvdmitry commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Following the guidance in The Browser's Main Thread Is Expensive: scroll-linked visuals should be driven by transform/opacity on the compositor, not by layout properties updated from JavaScript on every frame.

ScrollArea's custom scrollbar previously did the most expensive version of this on every scroll event:

  1. handleScroll called setScrollPosition, re-rendering ScrollArea and both ScrollAreaBars through React.
  2. The new position was applied to the thumb's ::before via inset-block-start / inset-inline-start, which are layout properties, so each scroll frame ran style → layout → paint on the main thread.

This PR:

  • Moves the thumb with transform: translateX/translateY instead of inset. The translate percentage is relative to the thumb's own size, so the position is divided by the ratio to map it back onto the track (position / ratio * 100%). The thumb pseudo-element gets will-change: transform so it stays on its own compositor layer between updates.
  • Lets each bar track the scrollable element itself. ScrollAreaBar receives the scrollableRef, attaches a passive scroll listener, and writes --rs-scroll-area-position onto its own element. Scroll events no longer re-render anything, and the style recalculation stays scoped to the bar (writing an inherited custom property on the root instead would invalidate every node of the scrolled content: measured ~10ms per update on a 6000-node subtree in Chromium, versus ~0.02ms on the bar). The position is re-synced whenever the ratio changes, which covers the ResizeObserver/MutationObserver paths.
  • Keeps the scroll ratio in React state, since it decides whether a bar is rendered at all and only changes on resize/mutation.
  • Replaces the position prop on the internal BarProps with scrollableRef.

Since the translate is physical, this also fixes the horizontal thumb in RTL: scrollLeft is negative in RTL, and applying it to inset-inline-start pushed the thumb outside the track (measured left: 509.8px for a 549px-wide thumb in an ~824px track on main). It now moves left inside the track as expected.

Related Issue

N/A

Screenshots / Recordings

Verified in headless Chromium against the base story by measuring the thumb geometry at several scroll offsets:

  • LTR: thumb position and size are identical to main at rest and when scrolled (vertical, horizontal and both).
  • RTL: identical at rest; when scrolled, the thumb now stays inside the track instead of overflowing it.

Notes for Reviewers

  • ScrollArea, Table and Autocomplete story tests pass locally (23 tests).
  • The imperative style.setProperty on the bar follows the same pattern already used by Modal (--rs-modal-drag) and Slider (--rs-slider-tooltip-offset).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq

Every scroll event went through React state, re-rendered ScrollArea and
its bars, and moved the thumb pseudo element with inset-block-start /
inset-inline-start. Those are layout properties, so each scroll frame
ran style, layout and paint on the main thread.

The thumb is now offset with translateX/translateY, a compositor-only
property, and the scroll position is written directly to the
--rs-scroll-area-position-x/y css variables on the root instead of the
react state, so scroll events no longer re-render the component. The
translate percentage is relative to the thumb size, so the position is
divided by the ratio to map it back onto the track.

Since the translate is physical, this also fixes the horizontal thumb in
RTL, which previously moved outside of the track because of the negative
scrollLeft value being applied to inset-inline-start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq
@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

size-limit report 📦

Path Size Loading time (3g) Running time (snapdragon) Total time
Library / JS 50.66 KB (0%) 1.1 s (0%) 1.3 s (+14.12% 🔺) 2.3 s
Library / CSS 23.87 KB (0%) 478 ms (0%) 0 ms (+100% 🔺) 478 ms
Theming / JS 7.78 KB (0%) 156 ms (0%) 183 ms (+153.75% 🔺) 339 ms
Theming with a default theme definition / JS 8.69 KB (0%) 174 ms (0%) 136 ms (-18.77% 🔽) 310 ms

Writing the position to an inherited custom property on the root made
Chromium recalculate style for every node of the scrollable content on
each scroll event (measured ~10ms per update on a 6000-node subtree).
The bar now listens to the scrollable element itself and writes
--rs-scroll-area-position onto its own element, keeping the
recalculation scoped to the thumb.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq
@blvdmitry
blvdmitry marked this pull request as ready for review September 12, 2026 16:27
@blvdmitry
blvdmitry merged commit 3c21d21 into main Sep 12, 2026
2 of 3 checks passed
@blvdmitry
blvdmitry deleted the claude/animation-performance-j38gm8-scroll-area branch September 12, 2026 16:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants