import React, { useCallback, useEffect, useMemo, useRef, useState, memo } 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 useResizeObserver = (callback, elements, dependencies) => { useEffect(() => { if (!window.ResizeObserver) { const handleResize = () => callback(); window.addEventListener('resize', handleResize); callback(); return () => window.removeEventListener('resize', handleResize); } 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(observer => observer?.disconnect()); }; }, [callback, elements, dependencies]); }; const useImageLoader = (seqRef, onLoad, dependencies) => { useEffect(() => { const images = seqRef.current?.querySelectorAll('img') ?? []; if (images.length === 0) { onLoad(); return; } let remainingImages = images.length; const handleImageLoad = () => { remainingImages -= 1; if (remainingImages === 0) onLoad(); }; images.forEach(img => { const htmlImg = img; if (htmlImg.complete) { handleImageLoad(); } else { htmlImg.addEventListener('load', handleImageLoad, { once: true }); htmlImg.addEventListener('error', handleImageLoad, { once: true }); } }); return () => { images.forEach(img => { img.removeEventListener('load', handleImageLoad); img.removeEventListener('error', handleImageLoad); }); }; }, [onLoad, seqRef, dependencies]); }; const useAnimationLoop = (trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) => { const rafRef = useRef(null); const lastTimestampRef = useRef(null); const offsetRef = useRef(0); const velocityRef = useRef(0); useEffect(() => { const track = trackRef.current; if (!track) return; const seqSize = isVertical ? seqHeight : seqWidth; if (seqSize > 0) { offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; const transformValue = isVertical ? `translate3d(0, ${-offsetRef.current}px, 0)` : `translate3d(${-offsetRef.current}px, 0, 0)`; track.style.transform = transformValue; } const animate = timestamp => { if (lastTimestampRef.current === null) { lastTimestampRef.current = timestamp; } const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000; lastTimestampRef.current = timestamp; const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); velocityRef.current += (target - velocityRef.current) * easingFactor; if (seqSize > 0) { let nextOffset = offsetRef.current + velocityRef.current * deltaTime; nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize; offsetRef.current = nextOffset; const transformValue = isVertical ? `translate3d(0, ${-offsetRef.current}px, 0)` : `translate3d(${-offsetRef.current}px, 0, 0)`; track.style.transform = transformValue; } rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); return () => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } lastTimestampRef.current = null; }; }, [targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical, trackRef]); }; export const LogoLoop = memo( ({ logos, speed = 120, direction = 'left', width = '100%', logoHeight = 28, gap = 32, pauseOnHover, hoverSpeed, fadeOut = false, fadeOutColor, scaleOnHover = false, renderItem, ariaLabel = 'Partner 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); let directionMultiplier; if (isVertical) { directionMultiplier = direction === 'up' ? 1 : -1; } else { directionMultiplier = direction === 'left' ? 1 : -1; } const speedMultiplier = speed < 0 ? -1 : 1; return magnitude * directionMultiplier * speedMultiplier; }, [speed, direction, isVertical]); const updateDimensions = useCallback(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; const sequenceRect = seqRef.current?.getBoundingClientRect?.(); const sequenceWidth = sequenceRect?.width ?? 0; const sequenceHeight = sequenceRect?.height ?? 0; if (isVertical) { const parentHeight = containerRef.current?.parentElement?.clientHeight ?? 0; if (containerRef.current && parentHeight > 0) { const targetHeight = Math.ceil(parentHeight); if (containerRef.current.style.height !== `${targetHeight}px`) containerRef.current.style.height = `${targetHeight}px`; } if (sequenceHeight > 0) { setSeqHeight(Math.ceil(sequenceHeight)); const viewport = containerRef.current?.clientHeight ?? parentHeight ?? sequenceHeight; const copiesNeeded = Math.ceil(viewport / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM; setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); } } else if (sequenceWidth > 0) { setSeqWidth(Math.ceil(sequenceWidth)); const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); } }, [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 cssVariables = useMemo( () => ({ '--logoloop-gap': `${gap}px`, '--logoloop-logoHeight': `${logoHeight}px`, ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }) }), [gap, logoHeight, fadeOutColor] ); const rootClassName = useMemo( () => [ 'logoloop', isVertical ? 'logoloop--vertical' : 'logoloop--horizontal', fadeOut && 'logoloop--fade', scaleOnHover && 'logoloop--scale-hover', className ] .filter(Boolean) .join(' '), [isVertical, fadeOut, scaleOnHover, className] ); const handleMouseEnter = useCallback(() => { if (effectiveHoverSpeed !== undefined) setIsHovered(true); }, [effectiveHoverSpeed]); const handleMouseLeave = useCallback(() => { if (effectiveHoverSpeed !== undefined) setIsHovered(false); }, [effectiveHoverSpeed]); const renderLogoItem = useCallback( (item, key) => { if (renderItem) { return (
  • {renderItem(item, key)}
  • ); } const isNodeItem = 'node' in item; const content = isNodeItem ? ( {item.node} ) : ( {item.alt ); const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title); const itemContent = item.href ? ( {content} ) : ( content ); return (
  • {itemContent}
  • ); }, [renderItem] ); const logoLists = useMemo( () => Array.from({ length: copyCount }, (_, copyIndex) => ( )), [copyCount, logos, renderLogoItem] ); const containerStyle = useMemo( () => ({ width: isVertical ? toCssLength(width) === '100%' ? undefined : toCssLength(width) : (toCssLength(width) ?? '100%'), ...cssVariables, ...style }), [width, cssVariables, style, isVertical] ); return (
    {logoLists}
    ); } ); LogoLoop.displayName = 'LogoLoop'; export default LogoLoop;