'use client'; import * as React from 'react'; import { type HTMLMotionProps, type LegacyAnimationControls, motion, type SVGMotionProps, useAnimation, type UseInViewOptions, type Variants, } from 'motion/react'; import { cn } from '@/lib/utils'; import { useIsInView } from '@/hooks/use-is-in-view'; import { Slot, type WithAsChild, } from '@/components/animate-ui/primitives/animate/slot'; const staticAnimations = { path: { initial: { pathLength: 1 }, animate: { pathLength: [0.05, 1], transition: { duration: 0.8, ease: 'easeInOut', }, }, } as Variants, 'path-loop': { initial: { pathLength: 1 }, animate: { pathLength: [1, 0.05, 1], transition: { duration: 1.6, ease: 'easeInOut', }, }, } as Variants, } as const; type StaticAnimations = keyof typeof staticAnimations; type TriggerProp = boolean | StaticAnimations | T; type Trigger = TriggerProp; type AnimateIconContextValue = { controls: LegacyAnimationControls | undefined; animation: StaticAnimations | string; loop: boolean; loopDelay: number; active: boolean; animate?: Trigger; initialOnAnimateEnd?: boolean; completeOnStop?: boolean; persistOnAnimateEnd?: boolean; delay?: number; }; type DefaultIconProps = { animate?: TriggerProp; animateOnHover?: TriggerProp; animateOnTap?: TriggerProp; animateOnView?: TriggerProp; animateOnViewMargin?: UseInViewOptions['margin']; animateOnViewOnce?: boolean; animation?: T | StaticAnimations; loop?: boolean; loopDelay?: number; initialOnAnimateEnd?: boolean; completeOnStop?: boolean; persistOnAnimateEnd?: boolean; delay?: number; }; type AnimateIconProps = WithAsChild< HTMLMotionProps<'span'> & DefaultIconProps & { children: React.ReactNode; asChild?: boolean; } >; type IconProps = DefaultIconProps & Omit, 'animate'> & { size?: number; }; type IconWrapperProps = IconProps & { icon: React.ComponentType>; }; const AnimateIconContext = React.createContext( null, ); function useAnimateIconContext() { const context = React.useContext(AnimateIconContext); if (!context) return { controls: undefined, animation: 'default', loop: undefined, loopDelay: undefined, active: undefined, animate: undefined, initialOnAnimateEnd: undefined, completeOnStop: undefined, persistOnAnimateEnd: undefined, delay: undefined, }; return context; } function composeEventHandlers>( theirs?: (event: E) => void, ours?: (event: E) => void, ) { return (event: E) => { theirs?.(event); ours?.(event); }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyProps = Record; function AnimateIcon({ asChild = false, animate = false, animateOnHover = false, animateOnTap = false, animateOnView = false, animateOnViewMargin = '0px', animateOnViewOnce = true, animation = 'default', loop = false, loopDelay = 0, initialOnAnimateEnd = false, completeOnStop = false, persistOnAnimateEnd = false, delay = 0, children, ...props }: AnimateIconProps) { const controls = useAnimation(); const [localAnimate, setLocalAnimate] = React.useState(() => { if (animate === undefined || animate === false) return false; return delay <= 0; }); const [currentAnimation, setCurrentAnimation] = React.useState< string | StaticAnimations >(typeof animate === 'string' ? animate : animation); const [status, setStatus] = React.useState<'initial' | 'animate'>('initial'); const delayRef = React.useRef | null>(null); const loopDelayRef = React.useRef | null>(null); const isAnimateInProgressRef = React.useRef(false); const animateEndPromiseRef = React.useRef | null>(null); const resolveAnimateEndRef = React.useRef<(() => void) | null>(null); const activeRef = React.useRef(localAnimate); const runGenRef = React.useRef(0); const cancelledRef = React.useRef(false); const bumpGeneration = React.useCallback(() => { runGenRef.current++; }, []); const startAnimation = React.useCallback( (trigger: TriggerProp) => { const next = typeof trigger === 'string' ? trigger : animation; bumpGeneration(); if (delayRef.current) { clearTimeout(delayRef.current); delayRef.current = null; } setCurrentAnimation(next); if (delay > 0) { setLocalAnimate(false); delayRef.current = setTimeout(() => { setLocalAnimate(true); }, delay); } else { setLocalAnimate(true); } }, [animation, delay, bumpGeneration], ); const stopAnimation = React.useCallback(() => { bumpGeneration(); if (delayRef.current) { clearTimeout(delayRef.current); delayRef.current = null; } if (loopDelayRef.current) { clearTimeout(loopDelayRef.current); loopDelayRef.current = null; } setLocalAnimate(false); }, [bumpGeneration]); React.useEffect(() => { activeRef.current = localAnimate; }, [localAnimate]); React.useEffect(() => { if (animate === undefined) return; setCurrentAnimation(typeof animate === 'string' ? animate : animation); if (animate) startAnimation(animate as TriggerProp); else stopAnimation(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [animate]); React.useEffect(() => { return () => { if (delayRef.current) clearTimeout(delayRef.current); if (loopDelayRef.current) clearTimeout(loopDelayRef.current); }; }, []); const viewOuterRef = React.useRef(null); const { ref: inViewRef, isInView } = useIsInView(viewOuterRef, { inView: !!animateOnView, inViewOnce: animateOnViewOnce, inViewMargin: animateOnViewMargin, }); const startAnim = React.useCallback( async (anim: 'initial' | 'animate', method: 'start' | 'set' = 'start') => { try { await controls[method](anim); setStatus(anim); } catch { return; } }, [controls], ); React.useEffect(() => { if (!animateOnView) return; if (isInView) startAnimation(animateOnView); else stopAnimation(); }, [isInView, animateOnView, startAnimation, stopAnimation]); React.useEffect(() => { const gen = ++runGenRef.current; cancelledRef.current = false; async function run() { if (cancelledRef.current || gen !== runGenRef.current) { await startAnim('initial'); return; } if (!localAnimate) { if ( completeOnStop && isAnimateInProgressRef.current && animateEndPromiseRef.current ) { try { await animateEndPromiseRef.current; } catch { // noop } } if (!persistOnAnimateEnd) { if (cancelledRef.current || gen !== runGenRef.current) { await startAnim('initial'); return; } await startAnim('initial'); } return; } if (loop) { if (cancelledRef.current || gen !== runGenRef.current) { await startAnim('initial'); return; } await startAnim('initial', 'set'); } isAnimateInProgressRef.current = true; animateEndPromiseRef.current = new Promise((resolve) => { resolveAnimateEndRef.current = resolve; }); if (cancelledRef.current || gen !== runGenRef.current) { isAnimateInProgressRef.current = false; resolveAnimateEndRef.current?.(); resolveAnimateEndRef.current = null; animateEndPromiseRef.current = null; await startAnim('initial'); return; } await startAnim('animate'); if (cancelledRef.current || gen !== runGenRef.current) { isAnimateInProgressRef.current = false; resolveAnimateEndRef.current?.(); resolveAnimateEndRef.current = null; animateEndPromiseRef.current = null; await startAnim('initial'); return; } isAnimateInProgressRef.current = false; resolveAnimateEndRef.current?.(); resolveAnimateEndRef.current = null; animateEndPromiseRef.current = null; if (initialOnAnimateEnd) { if (cancelledRef.current || gen !== runGenRef.current) { await startAnim('initial'); return; } await startAnim('initial', 'set'); } if (loop) { if (loopDelay > 0) { await new Promise((resolve) => { loopDelayRef.current = setTimeout(() => { loopDelayRef.current = null; resolve(); }, loopDelay); }); if (cancelledRef.current || gen !== runGenRef.current) { await startAnim('initial'); return; } if (!activeRef.current) { if (status !== 'initial' && !persistOnAnimateEnd) await startAnim('initial'); return; } } else { if (!activeRef.current) { if (status !== 'initial' && !persistOnAnimateEnd) await startAnim('initial'); return; } } if (cancelledRef.current || gen !== runGenRef.current) { await startAnim('initial'); return; } await run(); } } void run(); return () => { cancelledRef.current = true; if (delayRef.current) { clearTimeout(delayRef.current); delayRef.current = null; } if (loopDelayRef.current) { clearTimeout(loopDelayRef.current); loopDelayRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [localAnimate, controls]); const childProps = ( React.isValidElement(children) ? (children as React.ReactElement).props : {} ) as AnyProps; const handleMouseEnter = composeEventHandlers>( childProps.onMouseEnter, () => { if (animateOnHover) startAnimation(animateOnHover); }, ); const handleMouseLeave = composeEventHandlers>( childProps.onMouseLeave, () => { if (animateOnHover || animateOnTap) stopAnimation(); }, ); const handlePointerDown = composeEventHandlers< React.PointerEvent >(childProps.onPointerDown, () => { if (animateOnTap) startAnimation(animateOnTap); }); const handlePointerUp = composeEventHandlers>( childProps.onPointerUp, () => { if (animateOnTap) stopAnimation(); }, ); const content = asChild ? ( {children} ) : ( {children} ); return ( {content} ); } const pathClassName = "[&_[stroke-dasharray='1px_1px']]:![stroke-dasharray:1px_0px]"; function IconWrapper({ size = 28, animation: animationProp, animate, animateOnHover, animateOnTap, animateOnView, animateOnViewMargin, animateOnViewOnce, icon: IconComponent, loop, loopDelay, persistOnAnimateEnd, initialOnAnimateEnd, delay, completeOnStop, className, ...props }: IconWrapperProps) { const context = React.useContext(AnimateIconContext); if (context) { const { controls, animation: parentAnimation, loop: parentLoop, loopDelay: parentLoopDelay, active: parentActive, animate: parentAnimate, persistOnAnimateEnd: parentPersistOnAnimateEnd, initialOnAnimateEnd: parentInitialOnAnimateEnd, delay: parentDelay, completeOnStop: parentCompleteOnStop, } = context; const hasOverrides = animate !== undefined || animateOnHover !== undefined || animateOnTap !== undefined || animateOnView !== undefined || loop !== undefined || loopDelay !== undefined || initialOnAnimateEnd !== undefined || persistOnAnimateEnd !== undefined || delay !== undefined || completeOnStop !== undefined; if (hasOverrides) { const inheritedAnimate: Trigger = parentActive ? (animationProp ?? parentAnimation ?? 'default') : false; const finalAnimate: Trigger = (animate ?? parentAnimate ?? inheritedAnimate) as Trigger; return ( ); } const animationToUse = animationProp ?? parentAnimation; const loopToUse = parentLoop; const loopDelayToUse = parentLoopDelay; return ( ); } if ( animate !== undefined || animateOnHover !== undefined || animateOnTap !== undefined || animateOnView !== undefined || animationProp !== undefined ) { return ( ); } return ( ); } function getVariants< V extends { default: T; [key: string]: T }, T extends Record, >(animations: V): T { // eslint-disable-next-line react-hooks/rules-of-hooks const { animation: animationType } = useAnimateIconContext(); let result: T; if (animationType in staticAnimations) { const variant = staticAnimations[animationType as StaticAnimations]; result = {} as T; for (const key in animations.default) { if ( (animationType === 'path' || animationType === 'path-loop') && key.includes('group') ) continue; result[key] = variant as T[Extract]; } } else { result = (animations[animationType as keyof V] as T) ?? animations.default; } return result; } export { pathClassName, staticAnimations, AnimateIcon, IconWrapper, useAnimateIconContext, getVariants, type IconProps, type IconWrapperProps, type AnimateIconProps, type AnimateIconContextValue, };