import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './LogoLoop.css'; const ANIMATION_CONFIG = { SMOOTH_TAU: 0.25, MIN_COPIES: 2, COPY_HEADROOM: 2, }; const toCssLength = (value) => typeof value === 'number' ? `${value}px` : value ?? undefined; const cx = (...parts) => parts.filter(Boolean).join(' '); function useResizeObserver(callback, elements, deps) { useEffect(() => { if (!window.ResizeObserver) { const handler = () => callback(); window.addEventListener('resize', handler); callback(); return () => window.removeEventListener('resize', handler); } const observers = elements.map((ref) => { if (!ref.current) return null; const observer = new ResizeObserver(callback); observer.observe(ref.current); return observer; }); callback(); return () => { observers.forEach((o) => o?.disconnect()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); } function useImageLoader(seqRef, onLoad, deps) { useEffect(() => { const images = seqRef.current?.querySelectorAll('img') ?? []; if (images.length === 0) { onLoad(); return; } let remaining = images.length; const handleLoad = () => { remaining -= 1; if (remaining === 0) onLoad(); }; images.forEach((img) => { if (img.complete) { handleLoad(); } else { img.addEventListener('load', handleLoad, { once: true }); img.addEventListener('error', handleLoad, { once: true }); } }); return () => { images.forEach((img) => { img.removeEventListener('load', handleLoad); img.removeEventListener('error', handleLoad); }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); } function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) { const rafRef = useRef(null); const lastTsRef = useRef(null); const offsetRef = useRef(0); const velocityRef = useRef(0); useEffect(() => { const track = trackRef.current; if (!track) return; const prefersReduced = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; const seqSize = isVertical ? seqHeight : seqWidth; if (seqSize > 0) { offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; track.style.transform = isVertical ? `translate3d(0, ${-offsetRef.current}px, 0)` : `translate3d(${-offsetRef.current}px, 0, 0)`; } if (prefersReduced) { track.style.transform = 'translate3d(0, 0, 0)'; return () => { lastTsRef.current = null; }; } const animate = (ts) => { if (lastTsRef.current === null) lastTsRef.current = ts; const dt = Math.max(0, ts - lastTsRef.current) / 1000; lastTsRef.current = ts; const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU); velocityRef.current += (target - velocityRef.current) * easing; if (seqSize > 0) { let next = offsetRef.current + velocityRef.current * dt; next = ((next % seqSize) + seqSize) % seqSize; offsetRef.current = next; track.style.transform = isVertical ? `translate3d(0, ${-offsetRef.current}px, 0)` : `translate3d(${-offsetRef.current}px, 0, 0)`; } rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); rafRef.current = null; lastTsRef.current = null; }; }, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]); } export default function LogoLoop({ logos, speed = 60, direction = 'left', width = '100%', logoHeight = 36, gap = 32, pauseOnHover = true, hoverSpeed, fadeOut = true, fadeOutColor, scaleOnHover = true, ariaLabel = 'Skill logos', className, style, }) { const containerRef = useRef(null); const trackRef = useRef(null); const seqRef = useRef(null); const [seqWidth, setSeqWidth] = useState(0); const [seqHeight, setSeqHeight] = useState(0); const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES); const [isHovered, setIsHovered] = useState(false); const effectiveHoverSpeed = useMemo(() => { if (hoverSpeed !== undefined) return hoverSpeed; if (pauseOnHover === true) return 0; if (pauseOnHover === false) return undefined; return 0; }, [hoverSpeed, pauseOnHover]); const isVertical = direction === 'up' || direction === 'down'; const targetVelocity = useMemo(() => { const magnitude = Math.abs(speed); const dirMul = isVertical ? direction === 'up' ? 1 : -1 : direction === 'left' ? 1 : -1; const speedMul = speed < 0 ? -1 : 1; return magnitude * dirMul * speedMul; }, [speed, direction, isVertical]); const updateDimensions = useCallback(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; const seqRect = seqRef.current?.getBoundingClientRect?.(); const sw = seqRect?.width ?? 0; const sh = seqRect?.height ?? 0; if (isVertical) { const parentH = containerRef.current?.parentElement?.clientHeight ?? 0; if (containerRef.current && parentH > 0) { const h = Math.ceil(parentH); if (containerRef.current.style.height !== `${h}px`) containerRef.current.style.height = `${h}px`; } if (sh > 0) { setSeqHeight(Math.ceil(sh)); const viewport = containerRef.current?.clientHeight ?? parentH ?? sh; const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM; setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies)); } } else if (sw > 0) { setSeqWidth(Math.ceil(sw)); const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM; setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies)); } }, [isVertical]); useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]); useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]); useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical); const cssVars = useMemo(() => ({ '--logoloop-gap': `${gap}px`, '--logoloop-logoHeight': `${logoHeight}px`, ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }), }), [gap, logoHeight, fadeOutColor]); const containerStyle = useMemo(() => ({ width: isVertical ? toCssLength(width) === '100%' ? undefined : toCssLength(width) : toCssLength(width) ?? '100%', ...cssVars, ...style, }), [width, cssVars, style, isVertical]); const handleEnter = useCallback(() => { if (effectiveHoverSpeed !== undefined) setIsHovered(true); }, [effectiveHoverSpeed]); const handleLeave = useCallback(() => { if (effectiveHoverSpeed !== undefined) setIsHovered(false); }, [effectiveHoverSpeed]); const renderItem = (item, key) => { const isNode = 'node' in item; const inner = isNode ? ( {item.node} ) : ( {item.alt ); const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title; const wrapper = item.href ? ( {inner} ) : ( inner ); return (
  • {wrapper}
  • ); }; const lists = useMemo( () => Array.from({ length: copyCount }, (_, i) => ( )), // eslint-disable-next-line react-hooks/exhaustive-deps [copyCount, logos, isVertical, scaleOnHover], ); return (
    {fadeOut && ( <>
    )}
    {lists}
    ); }