Finalize animated masked scroll behavior

This commit is contained in:
Sam Becker 2025-04-30 17:41:41 -05:00
parent 60c5314c3a
commit 1cdeea0346
3 changed files with 53 additions and 18 deletions

View File

@ -164,7 +164,7 @@ export default function CommandKClient({
}, [mobileViewportHeight]); }, [mobileViewportHeight]);
const refScroll = useRef<HTMLDivElement>(null); const refScroll = useRef<HTMLDivElement>(null);
const { maskImage, updateMask } = useMaskedScroll({ const { maskStyle, updateMask } = useMaskedScroll({
ref: refScroll, ref: refScroll,
updateMaskOnEvents: false, updateMaskOnEvents: false,
}); });
@ -591,7 +591,7 @@ export default function CommandKClient({
'mx-3 pt-2 pb-3.5', 'mx-3 pt-2 pb-3.5',
'[&>*>*>*]:mt-2.5', '[&>*>*>*]:mt-2.5',
)} )}
style={{ maskImage, maxHeight }} style={{ ...maskStyle, maxHeight }}
> >
<div className="-mt-2.5"> <div className="-mt-2.5">
<Command.Empty className="mt-1 pl-3 text-dim pb-1"> <Command.Empty className="mt-1 pl-3 text-dim pb-1">

View File

@ -17,7 +17,7 @@ MaskedScrollExternalProps & {
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { maskImage } = useMaskedScroll({ const { maskStyle } = useMaskedScroll({
ref, ref,
direction, direction,
fadeSize, fadeSize,
@ -34,7 +34,7 @@ MaskedScrollExternalProps & {
hideScrollbar && '[scrollbar-width:none]', hideScrollbar && '[scrollbar-width:none]',
className, className,
)} )}
style={{ maskImage, ...style }} style={{ ...maskStyle, ...style }}
> >
{children} {children}
</div>; </div>;

View File

@ -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_START = '--mask-start';
const CSS_VAR_END = '--mask-end'; const CSS_VAR_END = '--mask-end';
export interface MaskedScrollExternalProps { export interface MaskedScrollExternalProps {
direction?: 'vertical' | 'horizontal' direction?: 'vertical' | 'horizontal'
fadeSize?: number fadeSize?: number
animationDuration?: number
scrollToEndOnMount?: boolean scrollToEndOnMount?: boolean
} }
@ -13,6 +20,7 @@ export default function useMaskedScroll({
ref: containerRef, ref: containerRef,
direction = 'vertical', direction = 'vertical',
fadeSize = 24, fadeSize = 24,
animationDuration = 0.3,
// Disable when calling 'updateMask' explicitly // Disable when calling 'updateMask' explicitly
updateMaskOnEvents = true, updateMaskOnEvents = true,
scrollToEndOnMount, scrollToEndOnMount,
@ -26,11 +34,11 @@ export default function useMaskedScroll({
const ref = containerRef?.current; const ref = containerRef?.current;
if (ref) { if (ref) {
const start = isVertical const start = isVertical
? ref.scrollTop === 0 ? ref.scrollTop < 1
: ref.scrollLeft === 0; : ref.scrollLeft < 1;
const end = isVertical const end = isVertical
? Math.abs((ref.scrollHeight - ref.scrollTop) - ref.clientHeight) < 1 ? (ref.scrollHeight - ref.scrollTop) - ref.clientHeight < 1
: Math.abs((ref.scrollWidth - ref.scrollLeft) - ref.clientWidth) < 1; : (ref.scrollWidth - ref.scrollLeft) - ref.clientWidth < 1;
ref.style.setProperty(CSS_VAR_START, `${!start ? fadeSize : 0}px`); ref.style.setProperty(CSS_VAR_START, `${!start ? fadeSize : 0}px`);
ref.style.setProperty(CSS_VAR_END, `${!end ? fadeSize : 0}px`); ref.style.setProperty(CSS_VAR_END, `${!end ? fadeSize : 0}px`);
} }
@ -61,13 +69,40 @@ export default function useMaskedScroll({
} }
}, [containerRef, scrollToEndOnMount, isVertical]); }, [containerRef, scrollToEndOnMount, isVertical]);
const maskImage = useMemo(() => { useEffect(() => {
let mask = `linear-gradient(to ${isVertical ? 'bottom' : 'right'}, `; try {
mask += 'transparent, black '; window.CSS.registerProperty({
mask += `var(${CSS_VAR_START}), black calc(100% - `; name: CSS_VAR_START,
mask += `var(${CSS_VAR_END})), transparent)`; syntax: '<length>',
return mask; initialValue: '0px',
}, [isVertical]); inherits: false,
});
window.CSS.registerProperty({
name: CSS_VAR_END,
syntax: '<length>',
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 };
} }