import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; export type LogoItem = | { node: React.ReactNode; href?: string; title?: string; ariaLabel?: string; } | { src: string; alt?: string; href?: string; title?: string; srcSet?: string; sizes?: string; width?: number; height?: number; }; export interface LogoLoopProps { logos: LogoItem[]; speed?: number; direction?: 'left' | 'right'; width?: number | string; logoHeight?: number; gap?: number; pauseOnHover?: boolean; fadeOut?: boolean; fadeOutColor?: string; scaleOnHover?: boolean; ariaLabel?: string; className?: string; style?: React.CSSProperties; } const ANIMATION_CONFIG = { SMOOTH_TAU: 0.25, MIN_COPIES: 2, COPY_HEADROOM: 2 } as const; const toCssLength = (value?: number | string): string | undefined => typeof value === 'number' ? `${value}px` : (value ?? undefined); const cx = (...parts: Array) => parts.filter(Boolean).join(' '); const useResizeObserver = ( callback: () => void, elements: Array>, dependencies: React.DependencyList ) => { 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()); }; }, dependencies); }; const useImageLoader = ( seqRef: React.RefObject, onLoad: () => void, dependencies: React.DependencyList ) => { 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 as HTMLImageElement; 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); }); }; }, dependencies); }; const useAnimationLoop = ( trackRef: React.RefObject, targetVelocity: number, seqWidth: number, isHovered: boolean, pauseOnHover: boolean ) => { 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 prefersReduced = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (seqWidth > 0) { offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth; track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; } if (prefersReduced) { track.style.transform = 'translate3d(0, 0, 0)'; return () => { lastTimestampRef.current = null; }; } const animate = (timestamp: number) => { if (lastTimestampRef.current === null) { lastTimestampRef.current = timestamp; } const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000; lastTimestampRef.current = timestamp; const target = pauseOnHover && isHovered ? 0 : targetVelocity; const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); velocityRef.current += (target - velocityRef.current) * easingFactor; if (seqWidth > 0) { let nextOffset = offsetRef.current + velocityRef.current * deltaTime; nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth; offsetRef.current = nextOffset; const translateX = -offsetRef.current; track.style.transform = `translate3d(${translateX}px, 0, 0)`; } rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); return () => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } lastTimestampRef.current = null; }; }, [targetVelocity, seqWidth, isHovered, pauseOnHover]); }; export const LogoLoop = React.memo( ({ logos, speed = 120, direction = 'left', width = '100%', logoHeight = 28, gap = 32, pauseOnHover = true, fadeOut = false, fadeOutColor, scaleOnHover = false, ariaLabel = 'Partner logos', className, style }) => { const containerRef = useRef(null); const trackRef = useRef(null); const seqRef = useRef(null); const [seqWidth, setSeqWidth] = useState(0); const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES); const [isHovered, setIsHovered] = useState(false); const targetVelocity = useMemo(() => { const magnitude = Math.abs(speed); const directionMultiplier = direction === 'left' ? 1 : -1; const speedMultiplier = speed < 0 ? -1 : 1; return magnitude * directionMultiplier * speedMultiplier; }, [speed, direction]); const updateDimensions = useCallback(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0; 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)); } }, []); useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]); useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]); useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover); const cssVariables = useMemo( () => ({ '--logoloop-gap': `${gap}px`, '--logoloop-logoHeight': `${logoHeight}px`, ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }) }) as React.CSSProperties, [gap, logoHeight, fadeOutColor] ); const rootClasses = useMemo( () => cx( 'relative overflow-x-hidden group', '[--logoloop-gap:32px]', '[--logoloop-logoHeight:28px]', '[--logoloop-fadeColorAuto:#ffffff]', 'dark:[--logoloop-fadeColorAuto:#0b0b0b]', scaleOnHover && 'py-[calc(var(--logoloop-logoHeight)*0.1)]', className ), [scaleOnHover, className] ); const handleMouseEnter = useCallback(() => { if (pauseOnHover) setIsHovered(true); }, [pauseOnHover]); const handleMouseLeave = useCallback(() => { if (pauseOnHover) setIsHovered(false); }, [pauseOnHover]); const renderLogoItem = useCallback( (item: LogoItem, key: React.Key) => { const isNodeItem = 'node' in item; const content = isNodeItem ? ( {(item as any).node} ) : ( {(item ); const itemAriaLabel = isNodeItem ? ((item as any).ariaLabel ?? (item as any).title) : ((item as any).alt ?? (item as any).title); const inner = (item as any).href ? ( {content} ) : ( content ); return (
  • {inner}
  • ); }, [scaleOnHover] ); const logoLists = useMemo( () => Array.from({ length: copyCount }, (_, copyIndex) => (
      0} ref={copyIndex === 0 ? seqRef : undefined} > {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}
    )), [copyCount, logos, renderLogoItem] ); const containerStyle = useMemo( (): React.CSSProperties => ({ width: toCssLength(width) ?? '100%', ...cssVariables, ...style }), [width, cssVariables, style] ); return (
    {fadeOut && ( <>
    )}
    {logoLists}
    ); } ); LogoLoop.displayName = 'LogoLoop'; export default LogoLoop;