"use client"; import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"; import { useReducedMotion } from "motion/react"; export type LogoMarqueeItem = { /** Company name. Read out by screen readers, and shown if there is no picture. */ name: string; src?: string; /** An inline svg or any mark, in place of src. */ logo?: ReactNode; href?: string; }; type LogoMarqueeProps = { logos: LogoMarqueeItem[]; /** Small line above the strip, such as "Trusted by teams at". */ title?: string; /** Two rows split the logos and run in opposite directions. */ rows?: 1 | 2; /** Pixels per second. */ speed?: number; /** Space between logos in pixels. */ gap?: number; /** Grey until hovered. */ mono?: boolean; /** Set padding and colours here. */ className?: string; }; const MASK = "linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent)"; function Row({ logos, reverse, speed, gap, mono }: { logos: LogoMarqueeItem[]; reverse: boolean; speed: number; gap: number; mono: boolean }) { const reduce = useReducedMotion(); const box = useRef(null); const belt = useRef(null); const run = useRef(null); const anim = useRef(null); const frame = useRef(0); const [copies, setCopies] = useState(2); const still = copies === 1; useEffect(() => { if (!box.current || !run.current) return; const watcher = new ResizeObserver(() => { const width = run.current?.offsetWidth ?? 0; if (!width) return; setCopies(reduce ? 1 : Math.ceil((box.current?.offsetWidth ?? 0) / width) + 1); anim.current?.effect?.updateTiming({ duration: (width / speed) * 1000 }); }); watcher.observe(box.current); watcher.observe(run.current); return () => watcher.disconnect(); }, [reduce, speed]); useEffect(() => { if (reduce || !box.current || !belt.current || !run.current) return; const moving = belt.current.animate( { transform: ["translateX(0)", `translateX(${-100 / copies}%)`] }, { duration: Math.max(1, (run.current.offsetWidth / speed) * 1000), iterations: Infinity, direction: reverse ? "reverse" : "normal" }, ); anim.current = moving; const onScreen = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) moving.play(); else moving.pause(); }); onScreen.observe(box.current); return () => { cancelAnimationFrame(frame.current); onScreen.disconnect(); moving.cancel(); anim.current = null; }; }, [copies, reduce, reverse, speed]); const glide = (to: number) => { cancelAnimationFrame(frame.current); const step = () => { const moving = anim.current; if (!moving) return; const next = moving.playbackRate + (to - moving.playbackRate) * 0.14; moving.playbackRate = Math.abs(to - next) < 0.01 ? to : next; if (moving.playbackRate !== to) frame.current = requestAnimationFrame(step); }; step(); }; // A focused link must be seen, so the strip stops with it in the middle. const showFocused = (e: FocusEvent) => { cancelAnimationFrame(frame.current); const moving = anim.current; if (!moving || !box.current || !run.current) return; moving.playbackRate = 0; const logo = e.target.getBoundingClientRect(); const start = run.current.getBoundingClientRect(); const middle = (box.current.offsetWidth - logo.width) / 2; const part = Math.max(0, logo.left - start.left - middle) / run.current.offsetWidth; const duration = Number(moving.effect?.getTiming().duration ?? 0); moving.currentTime = reverse ? Math.min(duration * (1 - part), duration - 1) : duration * part; }; return (
glide(0)} onPointerLeave={() => glide(1)} onFocus={showFocused} onBlur={() => glide(1)} className="min-w-0 overflow-clip" style={still ? undefined : { maskImage: MASK, WebkitMaskImage: MASK }} >
{Array.from({ length: copies }, (_, copy) => (
    0 || undefined} className={`flex items-center ${still ? "flex-wrap justify-center" : "shrink-0"}`} style={{ gap, paddingRight: still ? 0 : gap }} > {logos.map((item) => { const mark = item.logo ?? (item.src ? ( {item.href ) : ( {item.name} )); const look = `block rounded transition-[filter,opacity] duration-300 motion-reduce:transition-none ${mono ? "opacity-60 grayscale hover:opacity-100 hover:grayscale-0" : ""}`; return (
  • {item.href ? ( 0 ? -1 : undefined} className={look}> {mark} ) : ( {mark} )}
  • ); })}
))}
); } /* LogoMarquee: a "trusted by" strip of logos in one or two rows that loop with no seam. Grey until hovered; eases to a stop under the pointer. Under reduced motion the logos sit in a still, wrapped group. */ export function LogoMarquee({ logos, title, rows = 1, speed = 40, gap = 56, mono = true, className = "bg-[#0b0a14] px-5 py-12 text-white", }: LogoMarqueeProps) { const split = rows === 2 ? [logos.filter((_, i) => i % 2 === 0), logos.filter((_, i) => i % 2 === 1)] : [logos]; return (
{title &&

{title}

}
{split.map((row, i) => ( ))}
); }