From b54e16595b94385953ad15c68dd4954401fad0f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 21:35:22 +0000 Subject: [PATCH 1/3] ScrollArea: move the thumb with transform instead of inset 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 Claude-Session: https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq --- .changeset/scroll-area-thumb-compositor.md | 5 +++ .../ScrollArea/ScrollArea.module.css | 16 +++++++- .../src/components/ScrollArea/ScrollArea.tsx | 39 +++++++++---------- .../components/ScrollArea/ScrollArea.types.ts | 1 - 4 files changed, 37 insertions(+), 24 deletions(-) create mode 100644 .changeset/scroll-area-thumb-compositor.md diff --git a/.changeset/scroll-area-thumb-compositor.md b/.changeset/scroll-area-thumb-compositor.md new file mode 100644 index 00000000..99ea0e80 --- /dev/null +++ b/.changeset/scroll-area-thumb-compositor.md @@ -0,0 +1,5 @@ +--- +"reshaped": patch +--- + +Improved the `ScrollArea` scrolling performance. The custom scrollbar thumb now follows the scroll position with a `transform` instead of `inset`, so the scroll-linked updates run on the compositor thread without triggering layout and paint, and the scroll position is written directly to a css variable instead of re-rendering the component on every scroll event. This also fixes the horizontal thumb moving outside of the scrollbar track when scrolling in RTL. diff --git a/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css b/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css index 6ecad24a..6ac253ab 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css @@ -194,6 +194,11 @@ opacity: 0; transition: opacity var(--rs-duration-fast) var(--rs-easing-standard); + /** + * Thumb position follows the scroll with a transform instead of inset, + * so the scroll-linked updates run on the compositor without triggering layout and paint. + * The translate percentage is relative to the thumb size, so the position is divided by the ratio. + */ &::before { content: ""; display: block; @@ -202,6 +207,7 @@ transition: opacity var(--rs-duration-fast) var(--rs-easing-standard); position: absolute; opacity: 0.2; + will-change: transform; } } @@ -212,9 +218,12 @@ padding-block: calc(var(--rs-scroll-area-thumb-offset) + var(--rs-unit-x1)); & .thumb::before { - inset-block-start: calc(var(--rs-scroll-area-position) * 100%); + inset-block-start: 0; width: 100%; height: calc(var(--rs-scroll-area-ratio) * 100%); + transform: translateY( + calc(var(--rs-scroll-area-position-y, 0) / var(--rs-scroll-area-ratio) * 100%) + ); } } @@ -225,9 +234,12 @@ padding-inline: calc(var(--rs-scroll-area-thumb-offset) + var(--rs-unit-x0-5)); & .thumb::before { - inset-inline-start: calc(var(--rs-scroll-area-position) * 100%); + inset-inline-start: 0; height: 100%; width: calc(var(--rs-scroll-area-ratio) * 100%); + transform: translateX( + calc(var(--rs-scroll-area-position-x, 0) / var(--rs-scroll-area-ratio) * 100%) + ); } } diff --git a/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx b/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx index 6ea36ca4..a64dea4b 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx @@ -2,7 +2,7 @@ import React, { forwardRef } from "react"; import { classNames } from "@reshaped/utilities"; -import { disableScroll, enableScroll } from "@reshaped/utilities/internal"; +import { disableScroll, enableScroll, type Coordinates } from "@reshaped/utilities/internal"; import useHandlerRef from "@/hooks/useHandlerRef"; import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect"; @@ -11,7 +11,7 @@ import type * as T from "./ScrollArea.types"; import s from "./ScrollArea.module.css"; const ScrollAreaBar: React.FC = (props) => { - const { ratio, position, vertical, onThumbMove } = props; + const { ratio, vertical, onThumbMove } = props; const onThumbMoveRef = useHandlerRef(onThumbMove); const [dragging, setDragging] = React.useState(false); const dragStartPositionRef = React.useRef(0); @@ -81,12 +81,7 @@ const ScrollAreaBar: React.FC = (props) => { return (
((props, ref) => { scrollableClassName, } = props; const [scrollRatio, setScrollRatio] = React.useState({ x: 1, y: 1 }); - const [scrollPosition, setScrollPosition] = React.useState({ x: 0, y: 0 }); const [scrolling, setScrolling] = React.useState(false); const scrollingTimeoutRef = React.useRef>(undefined); const scrollableRef = React.useRef(null); @@ -134,6 +128,18 @@ const ScrollArea = forwardRef((props, ref) => { fade && s["--fade"] ); + /** + * Scroll position is written directly to the css variables instead of the react state, + * so the scroll events don't re-render the component on every frame + */ + const setScrollPosition = React.useCallback((position: Coordinates) => { + const rootEl = rootRef.current; + if (!rootEl) return; + + rootEl.style.setProperty("--rs-scroll-area-position-x", `${position.x}`); + rootEl.style.setProperty("--rs-scroll-area-position-y", `${position.y}`); + }, []); + const updateScroll = React.useCallback(() => { const scrollableEl = scrollableRef.current; if (!scrollableEl) return; @@ -149,7 +155,7 @@ const ScrollArea = forwardRef((props, ref) => { x: scrollWidth <= clientWidth ? 0 : scrollLeft / scrollWidth, y: scrollHeight <= clientHeight ? 0 : scrollTop / scrollHeight, }); - }, []); + }, [setScrollPosition]); const handleScroll = (e: React.UIEvent) => { const { scrollLeft, scrollTop, clientWidth, clientHeight, scrollWidth, scrollHeight } = @@ -248,19 +254,10 @@ const ScrollArea = forwardRef((props, ref) => {
{orientation !== "horizontal" && scrollRatio.y < 1 && scrollbarDisplay !== "hidden" && ( - + )} {orientation !== "vertical" && scrollRatio.x < 1 && scrollbarDisplay !== "hidden" && ( - + )} ); diff --git a/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts b/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts index 8b29d159..ce7549ff 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts @@ -34,7 +34,6 @@ export type Props = { export type BarProps = { ratio: number; - position: number; vertical?: boolean; onThumbMove: (args: { value: number; type: "absolute" | "relative" }) => void; }; From 5980aab6321585e26490ef056aae820390b5bfbf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 09:00:13 +0000 Subject: [PATCH 2/3] ScrollArea: track the scroll position from the bar itself 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 Claude-Session: https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq --- .../ScrollArea/ScrollArea.module.css | 4 +- .../src/components/ScrollArea/ScrollArea.tsx | 72 ++++++++++++------- .../components/ScrollArea/ScrollArea.types.ts | 1 + 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css b/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css index 6ac253ab..32fa9005 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css @@ -222,7 +222,7 @@ width: 100%; height: calc(var(--rs-scroll-area-ratio) * 100%); transform: translateY( - calc(var(--rs-scroll-area-position-y, 0) / var(--rs-scroll-area-ratio) * 100%) + calc(var(--rs-scroll-area-position, 0) / var(--rs-scroll-area-ratio) * 100%) ); } } @@ -238,7 +238,7 @@ height: 100%; width: calc(var(--rs-scroll-area-ratio) * 100%); transform: translateX( - calc(var(--rs-scroll-area-position-x, 0) / var(--rs-scroll-area-ratio) * 100%) + calc(var(--rs-scroll-area-position, 0) / var(--rs-scroll-area-ratio) * 100%) ); } } diff --git a/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx b/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx index a64dea4b..b47d63ab 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx @@ -2,7 +2,7 @@ import React, { forwardRef } from "react"; import { classNames } from "@reshaped/utilities"; -import { disableScroll, enableScroll, type Coordinates } from "@reshaped/utilities/internal"; +import { disableScroll, enableScroll } from "@reshaped/utilities/internal"; import useHandlerRef from "@/hooks/useHandlerRef"; import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect"; @@ -11,11 +11,41 @@ import type * as T from "./ScrollArea.types"; import s from "./ScrollArea.module.css"; const ScrollAreaBar: React.FC = (props) => { - const { ratio, vertical, onThumbMove } = props; + const { ratio, vertical, scrollableRef, onThumbMove } = props; const onThumbMoveRef = useHandlerRef(onThumbMove); const [dragging, setDragging] = React.useState(false); const dragStartPositionRef = React.useRef(0); const barRef = React.useRef(null); + + /** + * Thumb position is written directly to a css variable on the bar instead of the react state, + * so scrolling doesn't re-render the component and the style recalculation stays scoped to the bar + */ + useIsomorphicLayoutEffect(() => { + const scrollableEl = scrollableRef.current; + const barEl = barRef.current; + if (!scrollableEl || !barEl) return; + + const updatePosition = () => { + const { scrollLeft, scrollTop, clientWidth, clientHeight, scrollWidth, scrollHeight } = + scrollableEl; + const position = vertical + ? scrollHeight <= clientHeight + ? 0 + : scrollTop / scrollHeight + : scrollWidth <= clientWidth + ? 0 + : scrollLeft / scrollWidth; + + barEl.style.setProperty("--rs-scroll-area-position", `${position}`); + }; + + updatePosition(); + scrollableEl.addEventListener("scroll", updatePosition, { passive: true }); + + return () => scrollableEl.removeEventListener("scroll", updatePosition); + // Ratio is included to re-sync the position after the content or the container size changes + }, [vertical, scrollableRef, ratio]); const barClassNames = classNames( s.scrollbar, vertical ? s["--scrollbar-y"] : s["--scrollbar-x"], @@ -128,34 +158,17 @@ const ScrollArea = forwardRef((props, ref) => { fade && s["--fade"] ); - /** - * Scroll position is written directly to the css variables instead of the react state, - * so the scroll events don't re-render the component on every frame - */ - const setScrollPosition = React.useCallback((position: Coordinates) => { - const rootEl = rootRef.current; - if (!rootEl) return; - - rootEl.style.setProperty("--rs-scroll-area-position-x", `${position.x}`); - rootEl.style.setProperty("--rs-scroll-area-position-y", `${position.y}`); - }, []); - const updateScroll = React.useCallback(() => { const scrollableEl = scrollableRef.current; if (!scrollableEl) return; - const { scrollLeft, scrollTop, clientWidth, clientHeight, scrollWidth, scrollHeight } = - scrollableEl; + const { clientWidth, clientHeight, scrollWidth, scrollHeight } = scrollableEl; setScrollRatio({ x: scrollWidth === 0 ? 1 : Math.min(clientWidth / scrollWidth, 1), y: scrollHeight === 0 ? 1 : Math.min(clientHeight / scrollHeight, 1), }); - setScrollPosition({ - x: scrollWidth <= clientWidth ? 0 : scrollLeft / scrollWidth, - y: scrollHeight <= clientHeight ? 0 : scrollTop / scrollHeight, - }); - }, [setScrollPosition]); + }, []); const handleScroll = (e: React.UIEvent) => { const { scrollLeft, scrollTop, clientWidth, clientHeight, scrollWidth, scrollHeight } = @@ -167,10 +180,6 @@ const ScrollArea = forwardRef((props, ref) => { scrollingTimeoutRef.current = setTimeout(() => setScrolling(false), 1000); } - setScrollPosition({ - x: scrollLeft / scrollWidth, - y: scrollTop / scrollHeight, - }); onScroll?.({ x: scrollWidth === clientWidth ? 0 : scrollLeft / (scrollWidth - clientWidth), y: scrollHeight === clientHeight ? 0 : scrollTop / (scrollHeight - clientHeight), @@ -254,10 +263,19 @@ const ScrollArea = forwardRef((props, ref) => { {orientation !== "horizontal" && scrollRatio.y < 1 && scrollbarDisplay !== "hidden" && ( - + )} {orientation !== "vertical" && scrollRatio.x < 1 && scrollbarDisplay !== "hidden" && ( - + )} ); diff --git a/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts b/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts index ce7549ff..457a33e9 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts @@ -35,5 +35,6 @@ export type Props = { export type BarProps = { ratio: number; vertical?: boolean; + scrollableRef: React.RefObject; onThumbMove: (args: { value: number; type: "absolute" | "relative" }) => void; }; From 827c224dd141148d882c816fb56846c9ac2ec397 Mon Sep 17 00:00:00 2001 From: Dmitry Belyaev Date: Sat, 12 Sep 2026 18:26:53 +0200 Subject: [PATCH 3/3] ScrollArea: drag performance --- .../ScrollArea/ScrollArea.module.css | 5 -- .../src/components/ScrollArea/ScrollArea.tsx | 60 +++++++++---------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css b/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css index 32fa9005..ebf11331 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css @@ -194,11 +194,6 @@ opacity: 0; transition: opacity var(--rs-duration-fast) var(--rs-easing-standard); - /** - * Thumb position follows the scroll with a transform instead of inset, - * so the scroll-linked updates run on the compositor without triggering layout and paint. - * The translate percentage is relative to the thumb size, so the position is divided by the ratio. - */ &::before { content: ""; display: block; diff --git a/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx b/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx index b47d63ab..b17b0a17 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx @@ -16,36 +16,6 @@ const ScrollAreaBar: React.FC = (props) => { const [dragging, setDragging] = React.useState(false); const dragStartPositionRef = React.useRef(0); const barRef = React.useRef(null); - - /** - * Thumb position is written directly to a css variable on the bar instead of the react state, - * so scrolling doesn't re-render the component and the style recalculation stays scoped to the bar - */ - useIsomorphicLayoutEffect(() => { - const scrollableEl = scrollableRef.current; - const barEl = barRef.current; - if (!scrollableEl || !barEl) return; - - const updatePosition = () => { - const { scrollLeft, scrollTop, clientWidth, clientHeight, scrollWidth, scrollHeight } = - scrollableEl; - const position = vertical - ? scrollHeight <= clientHeight - ? 0 - : scrollTop / scrollHeight - : scrollWidth <= clientWidth - ? 0 - : scrollLeft / scrollWidth; - - barEl.style.setProperty("--rs-scroll-area-position", `${position}`); - }; - - updatePosition(); - scrollableEl.addEventListener("scroll", updatePosition, { passive: true }); - - return () => scrollableEl.removeEventListener("scroll", updatePosition); - // Ratio is included to re-sync the position after the content or the container size changes - }, [vertical, scrollableRef, ratio]); const barClassNames = classNames( s.scrollbar, vertical ? s["--scrollbar-y"] : s["--scrollbar-x"], @@ -96,6 +66,36 @@ const ScrollAreaBar: React.FC = (props) => { enableScroll(); }, []); + /** + * Thumb position is written directly to a css variable on the bar instead of the react state, + * so scrolling doesn't re-render the component and the style recalculation stays scoped to the bar + */ + useIsomorphicLayoutEffect(() => { + const scrollableEl = scrollableRef.current; + const barEl = barRef.current; + if (!scrollableEl || !barEl) return; + + const updatePosition = () => { + const { scrollLeft, scrollTop, clientWidth, clientHeight, scrollWidth, scrollHeight } = + scrollableEl; + const position = vertical + ? scrollHeight <= clientHeight + ? 0 + : scrollTop / scrollHeight + : scrollWidth <= clientWidth + ? 0 + : scrollLeft / scrollWidth; + + barEl.style.setProperty("--rs-scroll-area-position", `${position}`); + }; + + updatePosition(); + scrollableEl.addEventListener("scroll", updatePosition, { passive: true }); + + return () => scrollableEl.removeEventListener("scroll", updatePosition); + // Ratio is included to re-sync the position after the content or the container size changes + }, [vertical, scrollableRef, ratio]); + React.useEffect(() => { if (!dragging) return;