"use client"; import Image from "next/image"; 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" | "up" | "down"; width?: number | string; logoHeight?: number; gap?: number; pauseOnHover?: boolean; hoverSpeed?: number; fadeOut?: boolean; fadeOutColor?: string; scaleOnHover?: boolean; renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode; 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 isNodeItem = ( item: LogoItem, ): item is Extract => "node" in item; const isImageItem = ( item: LogoItem, ): item is Extract => "src" in item; 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: Array = []; for (const ref of elements) { if (!ref.current) { observers.push(null); continue; } const observer = new ResizeObserver(callback); observer.observe(ref.current); observers.push(observer); } callback(); return () => { for (const observer of observers) { observer?.disconnect(); } }; }, [callback, elements, ...elements, ...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); }); }; }, [onLoad, seqRef, ...dependencies]); }; const useAnimationLoop = ( trackRef: React.RefObject, targetVelocity: number, seqWidth: number, seqHeight: number, isHovered: boolean, hoverSpeed: number | undefined, isVertical: 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; 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; } if (prefersReduced) { track.style.transform = isVertical ? "translate3d(0, 0, 0)" : "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 = 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; }; }, [ trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical, ]); }; export const LogoLoop = React.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: number; 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 }), }) as React.CSSProperties, [gap, logoHeight, fadeOutColor], ); const rootClasses = useMemo( () => cx( "relative group", isVertical ? "overflow-hidden h-full inline-block" : "overflow-x-hidden", "[--logoloop-gap:32px]", "[--logoloop-logoHeight:28px]", "[--logoloop-fadeColorAuto:#ffffff]", "dark:[--logoloop-fadeColorAuto:#0b0b0b]", scaleOnHover && "py-[calc(var(--logoloop-logoHeight)*0.1)]", className, ), [isVertical, scaleOnHover, className], ); const handleMouseEnter = useCallback(() => { if (effectiveHoverSpeed !== undefined) setIsHovered(true); }, [effectiveHoverSpeed]); const handleMouseLeave = useCallback(() => { if (effectiveHoverSpeed !== undefined) setIsHovered(false); }, [effectiveHoverSpeed]); const renderLogoItem = useCallback( (item: LogoItem, key: React.Key) => { if (renderItem) { return (
  • {renderItem(item, key)}
  • ); } const content = isNodeItem(item) ? ( {item.node} ) : ( {item.alt ); const itemAriaLabel = isNodeItem(item) ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title); const inner = item.href ? ( {content} ) : ( content ); return (
  • {inner}
  • ); }, [isVertical, scaleOnHover, renderItem], ); 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, isVertical], ); const containerStyle = useMemo( (): React.CSSProperties => ({ width: isVertical ? toCssLength(width) === "100%" ? undefined : toCssLength(width) : (toCssLength(width) ?? "100%"), ...cssVariables, ...style, }), [width, cssVariables, style, isVertical], ); return (
    {fadeOut && (isVertical ? ( <>
    ) : ( <>
    ))}
    {logoLists}
    ); }, ); LogoLoop.displayName = "LogoLoop"; const logos: LogoItem[] = [ { src: "/logos/chase.svg", alt: "Chase" }, { src: "/logos/hp.svg", alt: "HP" }, { src: "/logos/huggingface.svg", alt: "Hugging Face" }, { src: "/logos/sandisk.svg", alt: "SanDisk" }, { src: "/logos/tmobile.svg", alt: "T-Mobile" }, ]; export default function LogoSlider() { return (
    { if (isImageItem(item)) { return ( {item.alt ); } return {item.node}; }} className="relative" style={{ WebkitMaskImage: "linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)", maskImage: "linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)", }} />
    ); }