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..ebf11331 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.module.css @@ -202,6 +202,7 @@ transition: opacity var(--rs-duration-fast) var(--rs-easing-standard); position: absolute; opacity: 0.2; + will-change: transform; } } @@ -212,9 +213,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, 0) / var(--rs-scroll-area-ratio) * 100%) + ); } } @@ -225,9 +229,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, 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..b17b0a17 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.tsx @@ -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, scrollableRef, onThumbMove } = props; const onThumbMoveRef = useHandlerRef(onThumbMove); const [dragging, setDragging] = React.useState(false); const dragStartPositionRef = React.useRef(0); @@ -66,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; @@ -81,12 +111,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); @@ -138,17 +162,12 @@ const ScrollArea = forwardRef((props, ref) => { 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, - }); }, []); const handleScroll = (e: React.UIEvent) => { @@ -161,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), @@ -252,14 +267,14 @@ const ScrollArea = forwardRef((props, ref) => { vertical onThumbMove={handleThumbYMove} ratio={scrollRatio.y} - position={scrollPosition.y} + scrollableRef={scrollableRef} /> )} {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..457a33e9 100644 --- a/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts +++ b/packages/reshaped/src/components/ScrollArea/ScrollArea.types.ts @@ -34,7 +34,7 @@ export type Props = { export type BarProps = { ratio: number; - position: number; vertical?: boolean; + scrollableRef: React.RefObject; onThumbMove: (args: { value: number; type: "absolute" | "relative" }) => void; };