From 1cdeea03467557eb96630ef98088c3630b664939 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 30 Apr 2025 17:41:41 -0500 Subject: [PATCH] Finalize animated masked scroll behavior --- src/cmdk/CommandKClient.tsx | 4 +- src/components/MaskedScroll.tsx | 4 +- src/components/useMaskedScroll.ts | 63 ++++++++++++++++++++++++------- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index b022ff11..dee035da 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -164,7 +164,7 @@ export default function CommandKClient({ }, [mobileViewportHeight]); const refScroll = useRef(null); - const { maskImage, updateMask } = useMaskedScroll({ + const { maskStyle, updateMask } = useMaskedScroll({ ref: refScroll, updateMaskOnEvents: false, }); @@ -591,7 +591,7 @@ export default function CommandKClient({ 'mx-3 pt-2 pb-3.5', '[&>*>*>*]:mt-2.5', )} - style={{ maskImage, maxHeight }} + style={{ ...maskStyle, maxHeight }} >
diff --git a/src/components/MaskedScroll.tsx b/src/components/MaskedScroll.tsx index c33762ab..7dc2be3f 100644 --- a/src/components/MaskedScroll.tsx +++ b/src/components/MaskedScroll.tsx @@ -17,7 +17,7 @@ MaskedScrollExternalProps & { }) { const ref = useRef(null); - const { maskImage } = useMaskedScroll({ + const { maskStyle } = useMaskedScroll({ ref, direction, fadeSize, @@ -34,7 +34,7 @@ MaskedScrollExternalProps & { hideScrollbar && '[scrollbar-width:none]', className, )} - style={{ maskImage, ...style }} + style={{ ...maskStyle, ...style }} > {children}
; diff --git a/src/components/useMaskedScroll.ts b/src/components/useMaskedScroll.ts index b9f00f33..b2fe1a6f 100644 --- a/src/components/useMaskedScroll.ts +++ b/src/components/useMaskedScroll.ts @@ -1,11 +1,18 @@ -import { RefObject, useCallback, useEffect, useMemo } from 'react'; - +import { + CSSProperties, + RefObject, + useCallback, + useEffect, + useMemo, +} from 'react'; + const CSS_VAR_START = '--mask-start'; const CSS_VAR_END = '--mask-end'; export interface MaskedScrollExternalProps { direction?: 'vertical' | 'horizontal' fadeSize?: number + animationDuration?: number scrollToEndOnMount?: boolean } @@ -13,6 +20,7 @@ export default function useMaskedScroll({ ref: containerRef, direction = 'vertical', fadeSize = 24, + animationDuration = 0.3, // Disable when calling 'updateMask' explicitly updateMaskOnEvents = true, scrollToEndOnMount, @@ -26,11 +34,11 @@ export default function useMaskedScroll({ const ref = containerRef?.current; if (ref) { const start = isVertical - ? ref.scrollTop === 0 - : ref.scrollLeft === 0; + ? ref.scrollTop < 1 + : ref.scrollLeft < 1; const end = isVertical - ? Math.abs((ref.scrollHeight - ref.scrollTop) - ref.clientHeight) < 1 - : Math.abs((ref.scrollWidth - ref.scrollLeft) - ref.clientWidth) < 1; + ? (ref.scrollHeight - ref.scrollTop) - ref.clientHeight < 1 + : (ref.scrollWidth - ref.scrollLeft) - ref.clientWidth < 1; ref.style.setProperty(CSS_VAR_START, `${!start ? fadeSize : 0}px`); ref.style.setProperty(CSS_VAR_END, `${!end ? fadeSize : 0}px`); } @@ -61,13 +69,40 @@ export default function useMaskedScroll({ } }, [containerRef, scrollToEndOnMount, isVertical]); - const maskImage = useMemo(() => { - let mask = `linear-gradient(to ${isVertical ? 'bottom' : 'right'}, `; - mask += 'transparent, black '; - mask += `var(${CSS_VAR_START}), black calc(100% - `; - mask += `var(${CSS_VAR_END})), transparent)`; - return mask; - }, [isVertical]); + useEffect(() => { + try { + window.CSS.registerProperty({ + name: CSS_VAR_START, + syntax: '', + initialValue: '0px', + inherits: false, + }); + window.CSS.registerProperty({ + name: CSS_VAR_END, + syntax: '', + initialValue: '0px', + inherits: false, + }); + } catch {} + }, []); - return { maskImage, updateMask }; + const maskStyle: CSSProperties = useMemo(() => { + // eslint-disable-next-line max-len + const gradientStart = `linear-gradient(to ${isVertical ? 'bottom' : 'right'}, transparent, black var(${CSS_VAR_START}))`; + // eslint-disable-next-line max-len + const gradientEnd = `linear-gradient(to ${isVertical ? 'top' : 'left'}, transparent, black var(${CSS_VAR_END}))`; + const maskImage = [gradientStart, gradientEnd].join(', '); + const transition = [ + `${CSS_VAR_START} ${animationDuration}s ease-in-out`, + `${CSS_VAR_END} ${animationDuration}s ease-in-out`, + ].join(', '); + return { + maskImage, + maskComposite: 'intersect', + maskRepeat: 'no-repeat', + transition, + }; + }, [isVertical, animationDuration]); + + return { maskStyle, updateMask }; }