Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scroll-area-thumb-compositor.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
transition: opacity var(--rs-duration-fast) var(--rs-easing-standard);
position: absolute;
opacity: 0.2;
will-change: transform;
}
}

Expand All @@ -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%)
);
}
}

Expand All @@ -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%)
);
}
}

Expand Down
55 changes: 35 additions & 20 deletions packages/reshaped/src/components/ScrollArea/ScrollArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type * as T from "./ScrollArea.types";
import s from "./ScrollArea.module.css";

const ScrollAreaBar: React.FC<T.BarProps> = (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);
Expand Down Expand Up @@ -66,6 +66,36 @@ const ScrollAreaBar: React.FC<T.BarProps> = (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;

Expand All @@ -81,12 +111,7 @@ const ScrollAreaBar: React.FC<T.BarProps> = (props) => {
return (
<div
className={barClassNames}
style={
{
"--rs-scroll-area-ratio": ratio,
"--rs-scroll-area-position": position,
} as React.CSSProperties
}
style={{ "--rs-scroll-area-ratio": ratio } as React.CSSProperties}
ref={barRef}
onClick={handleClick}
onMouseDown={handleDragStart}
Expand All @@ -113,7 +138,6 @@ const ScrollArea = forwardRef<HTMLDivElement, T.Props>((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<ReturnType<typeof setTimeout>>(undefined);
const scrollableRef = React.useRef<HTMLDivElement>(null);
Expand All @@ -138,17 +162,12 @@ const ScrollArea = forwardRef<HTMLDivElement, T.Props>((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<HTMLDivElement>) => {
Expand All @@ -161,10 +180,6 @@ const ScrollArea = forwardRef<HTMLDivElement, T.Props>((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),
Expand Down Expand Up @@ -252,14 +267,14 @@ const ScrollArea = forwardRef<HTMLDivElement, T.Props>((props, ref) => {
vertical
onThumbMove={handleThumbYMove}
ratio={scrollRatio.y}
position={scrollPosition.y}
scrollableRef={scrollableRef}
/>
)}
{orientation !== "vertical" && scrollRatio.x < 1 && scrollbarDisplay !== "hidden" && (
<ScrollAreaBar
onThumbMove={handleThumbXMove}
ratio={scrollRatio.x}
position={scrollPosition.x}
scrollableRef={scrollableRef}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export type Props = {

export type BarProps = {
ratio: number;
position: number;
vertical?: boolean;
scrollableRef: React.RefObject<HTMLElement | null>;
onThumbMove: (args: { value: number; type: "absolute" | "relative" }) => void;
};
Loading