Repository navigation
ScrollArea: move the thumb with transform instead of inset - #668
Merged
Merged
Conversation
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
size-limit report 📦
|
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
marked this pull request as ready for review
September 12, 2026 16:27
blvdmitry
deleted the
claude/animation-performance-j38gm8-scroll-area
branch
September 12, 2026 16:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Following the guidance in The Browser's Main Thread Is Expensive: scroll-linked visuals should be driven by
transform/opacityon 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 everyscrollevent:handleScrollcalledsetScrollPosition, re-renderingScrollAreaand bothScrollAreaBars through React.::beforeviainset-block-start/inset-inline-start, which are layout properties, so each scroll frame ran style → layout → paint on the main thread.This PR:
transform: translateX/translateYinstead ofinset. 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 getswill-change: transformso it stays on its own compositor layer between updates.ScrollAreaBarreceives thescrollableRef, attaches a passivescrolllistener, and writes--rs-scroll-area-positiononto 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 theratiochanges, which covers the ResizeObserver/MutationObserver paths.positionprop on the internalBarPropswithscrollableRef.Since the translate is physical, this also fixes the horizontal thumb in RTL:
scrollLeftis negative in RTL, and applying it toinset-inline-startpushed the thumb outside the track (measuredleft: 509.8pxfor a 549px-wide thumb in an ~824px track onmain). It now moves left inside the track as expected.Related Issue
N/A
Screenshots / Recordings
Verified in headless Chromium against the
basestory by measuring the thumb geometry at several scroll offsets:mainat rest and when scrolled (vertical, horizontal and both).Notes for Reviewers
style.setPropertyon the bar follows the same pattern already used byModal(--rs-modal-drag) andSlider(--rs-slider-tooltip-offset).🤖 Generated with Claude Code
https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq