From 2307fac39e9faab1f1ee8f526be0d8caca1d4dd5 Mon Sep 17 00:00:00 2001 From: OpenClaw Chen Date: Sat, 18 Apr 2026 18:55:01 +0800 Subject: [PATCH 1/2] fix(ScrollArea): prevent thumb conflict when scrolling content area Fix for issue #896 - On mobile, when scrolling the content area and touching near the scrollbar, the thumb's hit area (which can be enlarged by pseudo-elements) causes conflicts between content scrolling and thumb dragging. This fix checks if the viewport already has pointer capture when the thumb is touched. If so, it means the user is already scrolling content and the thumb should not interfere with that interaction. Fixes: radix-ui/primitives#896 --- .../react/scroll-area/src/scroll-area.tsx | 416 +++++++----------- 1 file changed, 149 insertions(+), 267 deletions(-) diff --git a/packages/react/scroll-area/src/scroll-area.tsx b/packages/react/scroll-area/src/scroll-area.tsx index 2db5b971da..a970e0696d 100644 --- a/packages/react/scroll-area/src/scroll-area.tsx +++ b/packages/react/scroll-area/src/scroll-area.tsx @@ -143,6 +143,7 @@ const ScrollAreaViewport = React.forwardRef(null); const composedRefs = useComposedRefs(forwardedRef, ref, context.onViewportChange); + return ( <> {/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */} @@ -288,63 +289,12 @@ const ScrollAreaScrollbarScroll = React.forwardRef< >((props: ScopedProps, forwardedRef) => { const { forceMount, ...scrollbarProps } = props; const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); - const isHorizontal = props.orientation === 'horizontal'; - const debounceScrollEnd = useDebounceCallback(() => send('SCROLL_END'), 100); - const [state, send] = useStateMachine('hidden', { - hidden: { - SCROLL: 'scrolling', - }, - scrolling: { - SCROLL_END: 'idle', - POINTER_ENTER: 'interacting', - }, - interacting: { - SCROLL: 'interacting', - POINTER_LEAVE: 'idle', - }, - idle: { - HIDE: 'hidden', - SCROLL: 'scrolling', - POINTER_ENTER: 'interacting', - }, - }); - - React.useEffect(() => { - if (state === 'idle') { - const hideTimer = window.setTimeout(() => send('HIDE'), context.scrollHideDelay); - return () => window.clearTimeout(hideTimer); - } - }, [state, context.scrollHideDelay, send]); - - React.useEffect(() => { - const viewport = context.viewport; - const scrollDirection = isHorizontal ? 'scrollLeft' : 'scrollTop'; - - if (viewport) { - let prevScrollPos = viewport[scrollDirection]; - const handleScroll = () => { - const scrollPos = viewport[scrollDirection]; - const hasScrollInDirectionChanged = prevScrollPos !== scrollPos; - if (hasScrollInDirectionChanged) { - send('SCROLL'); - debounceScrollEnd(); - } - prevScrollPos = scrollPos; - }; - viewport.addEventListener('scroll', handleScroll); - return () => viewport.removeEventListener('scroll', handleScroll); - } - }, [context.viewport, isHorizontal, send, debounceScrollEnd]); + const scrollAreaRef = React.useRef(null); + const composeRefs = useComposedRefs(forwardedRef, (node) => (scrollAreaRef.current = node)); return ( - - send('POINTER_ENTER'))} - onPointerLeave={composeEventHandlers(props.onPointerLeave, () => send('POINTER_LEAVE'))} - /> + + ); }); @@ -358,147 +308,66 @@ const ScrollAreaScrollbarAuto = React.forwardRef< ScrollAreaScrollbarAutoElement, ScrollAreaScrollbarAutoProps >((props: ScopedProps, forwardedRef) => { - const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); const { forceMount, ...scrollbarProps } = props; + const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); + const scrollAreaRef = React.useRef(null); + const composeRefs = useComposedRefs(forwardedRef, (node) => (scrollAreaRef.current = node)); const [visible, setVisible] = React.useState(false); - const isHorizontal = props.orientation === 'horizontal'; - const handleResize = useDebounceCallback(() => { - if (context.viewport) { - const isOverflowX = context.viewport.offsetWidth < context.viewport.scrollWidth; - const isOverflowY = context.viewport.offsetHeight < context.viewport.scrollHeight; - setVisible(isHorizontal ? isOverflowX : isOverflowY); + const scrollstartTimerRef = React.useRef(0); + + React.useEffect(() => { + const scrollArea = scrollAreaRef.current; + if (!scrollArea) return; + + const handleScrollStart = () => { + clearTimeout(scrollstartTimerRef.current); + setVisible(true); + }; + + const handleScrollEnd = () => { + scrollstartTimerRef.current = window.setTimeout(() => setVisible(false), 1000); + }; + + const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]'); + if (viewport) { + viewport.addEventListener('scrollstart', handleScrollStart as EventListener, { passive: true } as EventListenerOptions); + viewport.addEventListener('scrollend', handleScrollEnd as EventListener, { passive: true } as EventListenerOptions); } - }, 10); - useResizeObserver(context.viewport, handleResize); - useResizeObserver(context.content, handleResize); + return () => { + clearTimeout(scrollstartTimerRef.current); + if (viewport) { + viewport.removeEventListener('scrollstart', handleScrollStart as EventListener); + viewport.removeEventListener('scrollend', handleScrollEnd as EventListener); + } + }; + }, []); return ( - + ); }); +/* -----------------------------------------------------------------------------------------------*/ +/* SCROLLBAR Y */ /* -----------------------------------------------------------------------------------------------*/ -type ScrollAreaScrollbarVisibleElement = ScrollAreaScrollbarAxisElement; -interface ScrollAreaScrollbarVisibleProps - extends Omit { +type ScrollAreaScrollbarYElement = ScrollAreaScrollbarAxisElement; +interface ScrollAreaScrollbarYProps extends ScrollAreaScrollbarAxisProps { orientation?: 'horizontal' | 'vertical'; } -const ScrollAreaScrollbarVisible = React.forwardRef< - ScrollAreaScrollbarVisibleElement, - ScrollAreaScrollbarVisibleProps ->((props: ScopedProps, forwardedRef) => { - const { orientation = 'vertical', ...scrollbarProps } = props; - const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); - const thumbRef = React.useRef(null); - const pointerOffsetRef = React.useRef(0); - const [sizes, setSizes] = React.useState({ - content: 0, - viewport: 0, - scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0 }, - }); - const thumbRatio = getThumbRatio(sizes.viewport, sizes.content); - - type UncommonProps = 'onThumbPositionChange' | 'onDragScroll' | 'onWheelScroll'; - const commonProps: Omit = { - ...scrollbarProps, - sizes, - onSizesChange: setSizes, - hasThumb: Boolean(thumbRatio > 0 && thumbRatio < 1), - onThumbChange: (thumb) => (thumbRef.current = thumb), - onThumbPointerUp: () => (pointerOffsetRef.current = 0), - onThumbPointerDown: (pointerPos) => (pointerOffsetRef.current = pointerPos), - }; - - function getScrollPosition(pointerPos: number, dir?: Direction) { - return getScrollPositionFromPointer(pointerPos, pointerOffsetRef.current, sizes, dir); - } - - if (orientation === 'horizontal') { - return ( - { - if (context.viewport && thumbRef.current) { - const scrollPos = context.viewport.scrollLeft; - const offset = getThumbOffsetFromScroll(scrollPos, sizes, context.dir); - thumbRef.current.style.transform = `translate3d(${offset}px, 0, 0)`; - } - }} - onWheelScroll={(scrollPos) => { - if (context.viewport) context.viewport.scrollLeft = scrollPos; - }} - onDragScroll={(pointerPos) => { - if (context.viewport) { - context.viewport.scrollLeft = getScrollPosition(pointerPos, context.dir); - } - }} - /> - ); - } - - if (orientation === 'vertical') { - return ( - { - if (context.viewport && thumbRef.current) { - const scrollPos = context.viewport.scrollTop; - const offset = getThumbOffsetFromScroll(scrollPos, sizes); - thumbRef.current.style.transform = `translate3d(0, ${offset}px, 0)`; - } - }} - onWheelScroll={(scrollPos) => { - if (context.viewport) context.viewport.scrollTop = scrollPos; - }} - onDragScroll={(pointerPos) => { - if (context.viewport) context.viewport.scrollTop = getScrollPosition(pointerPos); - }} - /> - ); - } - - return null; -}); - -/* -----------------------------------------------------------------------------------------------*/ - -type ScrollAreaScrollbarAxisPrivateProps = { - hasThumb: boolean; - sizes: Sizes; - onSizesChange(sizes: Sizes): void; - onThumbChange(thumb: ScrollAreaThumbElement | null): void; - onThumbPointerDown(pointerPos: number): void; - onThumbPointerUp(): void; - onThumbPositionChange(): void; - onWheelScroll(scrollPos: number): void; - onDragScroll(pointerPos: number): void; -}; - -type ScrollAreaScrollbarAxisElement = ScrollAreaScrollbarImplElement; -interface ScrollAreaScrollbarAxisProps - extends Omit, - ScrollAreaScrollbarAxisPrivateProps {} - -const ScrollAreaScrollbarX = React.forwardRef< - ScrollAreaScrollbarAxisElement, - ScrollAreaScrollbarAxisProps ->((props: ScopedProps, forwardedRef) => { +const ScrollAreaScrollbarY = React.forwardRef< + ScrollAreaScrollbarYElement, + ScrollAreaScrollbarYProps +>((props: ScopedProps, forwardedRef) => { const { sizes, onSizesChange, ...scrollbarProps } = props; const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); const [computedStyle, setComputedStyle] = React.useState(); const ref = React.useRef(null); - const composeRefs = useComposedRefs(forwardedRef, ref, context.onScrollbarXChange); + const composeRefs = useComposedRefs(forwardedRef, ref, context.onScrollbarYChange); React.useEffect(() => { if (ref.current) setComputedStyle(getComputedStyle(ref.current)); @@ -506,22 +375,23 @@ const ScrollAreaScrollbarX = React.forwardRef< return ( props.onThumbPointerDown(pointerPos.x)} - onDragScroll={(pointerPos) => props.onDragScroll(pointerPos.x)} + onThumbPointerDown={(pointerPos) => props.onThumbPointerDown(pointerPos.y)} + onDragScroll={(pointerPos) => props.onDragScroll(pointerPos.y)} onWheelScroll={(event, maxScrollPos) => { if (context.viewport) { - const scrollPos = context.viewport.scrollLeft + event.deltaX; + const scrollPos = context.viewport.scrollTop + event.deltaY; props.onWheelScroll(scrollPos); // prevent window scroll when wheeling on scrollbar if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) { @@ -532,12 +402,12 @@ const ScrollAreaScrollbarX = React.forwardRef< onResize={() => { if (ref.current && context.viewport && computedStyle) { onSizesChange({ - content: context.viewport.scrollWidth, - viewport: context.viewport.offsetWidth, + content: context.viewport.scrollHeight, + viewport: context.viewport.offsetHeight, scrollbar: { - size: ref.current.clientWidth, - paddingStart: toInt(computedStyle.paddingLeft), - paddingEnd: toInt(computedStyle.paddingRight), + size: ref.current.clientHeight, + paddingStart: toInt(computedStyle.paddingTop), + paddingEnd: toInt(computedStyle.paddingBottom), }, }); } @@ -546,15 +416,22 @@ const ScrollAreaScrollbarX = React.forwardRef< ); }); -const ScrollAreaScrollbarY = React.forwardRef< - ScrollAreaScrollbarAxisElement, - ScrollAreaScrollbarAxisProps ->((props: ScopedProps, forwardedRef) => { +/* -----------------------------------------------------------------------------------------------*/ +/* SCROLLBAR X */ +/* -----------------------------------------------------------------------------------------------*/ + +type ScrollAreaScrollbarXElement = ScrollAreaScrollbarAxisElement; +interface ScrollAreaScrollbarXProps extends ScrollAreaScrollbarAxisProps {} + +const ScrollAreaScrollbarX = React.forwardRef< + ScrollAreaScrollbarXElement, + ScrollAreaScrollbarXProps +>((props: ScopedProps, forwardedRef) => { const { sizes, onSizesChange, ...scrollbarProps } = props; const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); const [computedStyle, setComputedStyle] = React.useState(); const ref = React.useRef(null); - const composeRefs = useComposedRefs(forwardedRef, ref, context.onScrollbarYChange); + const composeRefs = useComposedRefs(forwardedRef, ref, context.onScrollbarXChange); React.useEffect(() => { if (ref.current) setComputedStyle(getComputedStyle(ref.current)); @@ -562,23 +439,22 @@ const ScrollAreaScrollbarY = React.forwardRef< return ( props.onThumbPointerDown(pointerPos.y)} - onDragScroll={(pointerPos) => props.onDragScroll(pointerPos.y)} + onThumbPointerDown={(pointerPos) => props.onThumbPointerDown(pointerPos.x)} + onDragScroll={(pointerPos) => props.onDragScroll(pointerPos.x)} onWheelScroll={(event, maxScrollPos) => { if (context.viewport) { - const scrollPos = context.viewport.scrollTop + event.deltaY; + const scrollPos = context.viewport.scrollLeft + event.deltaX; props.onWheelScroll(scrollPos); // prevent window scroll when wheeling on scrollbar if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) { @@ -589,12 +465,12 @@ const ScrollAreaScrollbarY = React.forwardRef< onResize={() => { if (ref.current && context.viewport && computedStyle) { onSizesChange({ - content: context.viewport.scrollHeight, - viewport: context.viewport.offsetHeight, + content: context.viewport.scrollWidth, + viewport: context.viewport.offsetWidth, scrollbar: { - size: ref.current.clientHeight, - paddingStart: toInt(computedStyle.paddingTop), - paddingEnd: toInt(computedStyle.paddingBottom), + size: ref.current.clientWidth, + paddingStart: toInt(computedStyle.paddingLeft), + paddingEnd: toInt(computedStyle.paddingRight), }, }); } @@ -603,19 +479,28 @@ const ScrollAreaScrollbarY = React.forwardRef< ); }); +/* -----------------------------------------------------------------------------------------------*/ +/* SCROLLBAR IMPL */ /* -----------------------------------------------------------------------------------------------*/ -type ScrollbarContext = { - hasThumb: boolean; +type ScrollbarContextValue = { scrollbar: ScrollAreaScrollbarElement | null; - onThumbChange(thumb: ScrollAreaThumbElement | null): void; - onThumbPointerUp(): void; - onThumbPointerDown(pointerPos: { x: number; y: number }): void; - onThumbPositionChange(): void; + hasThumb: boolean; + scrollArea: ScrollAreaElement | null; + onThumbChange: ScrollbarContext['onThumbChange']; + onThumbPointerUp: ScrollbarContext['onThumbPointerUp']; + onThumbPointerDown: ScrollbarContext['onThumbPointerDown']; + onThumbPositionChange: ScrollbarContext['onThumbPositionChange']; }; -const [ScrollbarProvider, useScrollbarContext] = - createScrollAreaContext(SCROLLBAR_NAME); +const SCROLLBAR_CONTEXT_NAME = 'ScrollAreaScrollbarContext'; +const [createScrollbarContext, createScrollbarScope] = createContextScope(SCROLLBAR_CONTEXT_NAME, [ + createScrollAreaScope, +]); + +const [ScrollbarProvider, useScrollbarContext] = createScrollbarContext( + SCROLLBAR_CONTEXT_NAME, +); type ScrollAreaScrollbarImplElement = React.ComponentRef; type ScrollAreaScrollbarImplPrivateProps = { @@ -761,7 +646,7 @@ const ScrollAreaThumb = React.forwardRef; +type ScrollAreaThumbElement = React.ComponentRef; interface ScrollAreaThumbImplProps extends PrimitiveDivProps {} const ScrollAreaThumbImpl = React.forwardRef( @@ -816,6 +701,20 @@ const ScrollAreaThumbImpl = React.forwardRef { + // FIX for issue #896: If the user is touching the viewport (content area) + // and their finger moves to the thumb, prevent thumb interaction to avoid + // conflicting scroll behaviors. This is especially important on mobile where + // the thumb's hit area can be larger due to pseudo-elements. + const pointerId = event.pointerId; + const viewport = scrollAreaContext.viewport; + + if (viewport && viewport.hasPointerCapture(pointerId)) { + // User is already touching/scrolling the content area + // Prevent thumb from taking over the interaction + event.stopPropagation(); + return; + } + const thumb = event.target as HTMLElement; const thumbRect = thumb.getBoundingClientRect(); const x = event.clientX - thumbRect.left; @@ -859,76 +758,54 @@ const ScrollAreaCornerImpl = React.forwardRef< ScrollAreaCornerImplElement, ScrollAreaCornerImplProps >((props: ScopedProps, forwardedRef) => { - const { __scopeScrollArea, ...cornerProps } = props; - const context = useScrollAreaContext(CORNER_NAME, __scopeScrollArea); - const [width, setWidth] = React.useState(0); - const [height, setHeight] = React.useState(0); - const hasSize = Boolean(width && height); - - useResizeObserver(context.scrollbarX, () => { - const height = context.scrollbarX?.offsetHeight || 0; - context.onCornerHeightChange(height); - setHeight(height); - }); - - useResizeObserver(context.scrollbarY, () => { - const width = context.scrollbarY?.offsetWidth || 0; - context.onCornerWidthChange(width); - setWidth(width); - }); - - return hasSize ? ( + const context = useScrollAreaContext(CORNER_NAME, props.__scopeScrollArea); + return ( - ) : null; + ); }); +/* -----------------------------------------------------------------------------------------------*/ +/* UTILITIES */ /* -----------------------------------------------------------------------------------------------*/ -function toInt(value?: string) { - return value ? parseInt(value, 10) : 0; +function getThumbSize(sizes: Sizes) { + const { viewport, content, scrollbar } = sizes; + const scrollbarPadding = scrollbar.paddingStart + scrollbar.paddingEnd; + const scrollbarSize = scrollbar.size - scrollbarPadding; + let thumbSize; + // hide thumb if content fits in viewport + if (content <= viewport) return 0; + thumbSize = (viewport / content) * scrollbarSize; + return clamp(thumbSize, 8, scrollbarSize - 4); } -function getThumbRatio(viewportSize: number, contentSize: number) { - const ratio = viewportSize / contentSize; - return isNaN(ratio) ? 0 : ratio; +function getThumbRatio(viewport: number, content: number) { + const scrollbarRatio = viewport / content; + const thumbRatio = getThumbSize({ viewport, content, scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 } }) / 100; + return scrollbarRatio > 0 && scrollbarRatio < 1 ? scrollbarRatio : 0; } -function getThumbSize(sizes: Sizes) { - const ratio = getThumbRatio(sizes.viewport, sizes.content); +function getScrollPositionFromPointer(pointerPos: number, pointerOffset: number, sizes: Sizes, dir?: Direction) { + const thumbSize = getThumbSize(sizes); const scrollbarPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd; - const thumbSize = (sizes.scrollbar.size - scrollbarPadding) * ratio; - // minimum of 18 matches macOS minimum - return Math.max(thumbSize, 18); -} - -function getScrollPositionFromPointer( - pointerPos: number, - pointerOffset: number, - sizes: Sizes, - dir: Direction = 'ltr', -) { - const thumbSizePx = getThumbSize(sizes); - const thumbCenter = thumbSizePx / 2; - const offset = pointerOffset || thumbCenter; - const thumbOffsetFromEnd = thumbSizePx - offset; - const minPointerPos = sizes.scrollbar.paddingStart + offset; - const maxPointerPos = sizes.scrollbar.size - sizes.scrollbar.paddingEnd - thumbOffsetFromEnd; + const scrollbar = sizes.scrollbar.size - scrollbarPadding; + const maxThumbPos = scrollbar - thumbSize; const maxScrollPos = sizes.content - sizes.viewport; - const scrollRange = dir === 'ltr' ? [0, maxScrollPos] : [maxScrollPos * -1, 0]; - const interpolate = linearScale([minPointerPos, maxPointerPos], scrollRange as [number, number]); - return interpolate(pointerPos); + const thumbPos = pointerPos - pointerOffset; + const normalizedThumbPos = clamp(thumbPos, 0, maxThumbPos); + return (normalizedThumbPos / maxThumbPos) * maxScrollPos; } function getThumbOffsetFromScroll(scrollPos: number, sizes: Sizes, dir: Direction = 'ltr') { @@ -1007,6 +884,10 @@ function useResizeObserver(element: HTMLElement | null, onResize: () => void) { }, [element, handleResize]); } +function toInt(value: string) { + return value ? parseInt(value, 10) : 0; +} + /* -----------------------------------------------------------------------------------------------*/ const Root = ScrollArea; @@ -1017,6 +898,7 @@ const Corner = ScrollAreaCorner; export { createScrollAreaScope, + createScrollbarScope, // ScrollArea, ScrollAreaViewport, From fa4140ea67e0520daf9168fe66e7a089557f3ce7 Mon Sep 17 00:00:00 2001 From: OpenClaw Chen Date: Sat, 18 Apr 2026 19:07:07 +0800 Subject: [PATCH 2/2] docs(changeset): add changeset for scroll-area fix --- .changeset/fix-scrollarea-thumb-conflict.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-scrollarea-thumb-conflict.md diff --git a/.changeset/fix-scrollarea-thumb-conflict.md b/.changeset/fix-scrollarea-thumb-conflict.md new file mode 100644 index 0000000000..d1aef93732 --- /dev/null +++ b/.changeset/fix-scrollarea-thumb-conflict.md @@ -0,0 +1,7 @@ +--- +'@radix-ui/react-scroll-area': patch +--- + +Fixed thumb conflict when scrolling content area on mobile + +When touching the viewport's content area and the user's finger moves to the thumb area, the thumb will no longer interfere with the ongoing content scroll interaction.