import { type JSX, useCallback, useEffect, useRef, useState } from 'react'; import type { CSSProperties, MutableRefObject, PropsWithChildren, Ref } from 'react'; export const ENTRY_REVEAL_ANIMATION_MS = 700; export const ENTRY_REVEAL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; interface AnimatedHeightRevealProps extends PropsWithChildren { animate?: boolean; className?: string; style?: CSSProperties; containerRef?: Ref; } function assignRef(ref: Ref | undefined, value: T | null): void { if (!ref) return; if (typeof ref === 'function') { ref(value); return; } const mutableRef = ref as MutableRefObject; mutableRef.current = value; } export const AnimatedHeightReveal = ({ animate, className, style, containerRef, children, }: AnimatedHeightRevealProps): JSX.Element => { const [shouldAnimateOnMount] = useState(() => Boolean(animate)); const wrapperRef = useRef(null); const animationFrameRef = useRef(null); const [prefersReducedMotion] = useState( () => window.matchMedia('(prefers-reduced-motion: reduce)').matches ); const [isExpanded, setIsExpanded] = useState( () => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches ); // Overflow must stay hidden during the height transition so the grid clip // actually works. Switch to visible only after the animation completes. const [overflowVisible, setOverflowVisible] = useState( () => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches ); const setWrapperRef = useCallback( (node: HTMLDivElement | null) => { wrapperRef.current = node; assignRef(containerRef, node); }, [containerRef] ); const clearPendingAnimation = useCallback(() => { if (animationFrameRef.current !== null) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }, []); useEffect(() => { if (!shouldAnimateOnMount || prefersReducedMotion) { return; } animationFrameRef.current = requestAnimationFrame(() => { animationFrameRef.current = requestAnimationFrame(() => { setIsExpanded(true); animationFrameRef.current = null; }); }); // Switch overflow to visible after the height transition finishes // so popovers/tooltips inside can render outside bounds. const overflowTimer = setTimeout(() => { setOverflowVisible(true); }, ENTRY_REVEAL_ANIMATION_MS + 50); return () => { clearPendingAnimation(); clearTimeout(overflowTimer); }; }, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]); useEffect( () => () => { clearPendingAnimation(); }, [clearPendingAnimation] ); const shouldTransition = shouldAnimateOnMount && !prefersReducedMotion && isExpanded; return (
{children}
); };