## Logo Marquee — Content Stops when you look at it. Docs: https://www.interior.dev/docs/logo-marquee Reference: https://www.interior.dev/reference/logo-marquee License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/logo-marquee.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { LogoMarquee } from "@/components/interior/logo-marquee"; const CUSTOMERS = [ { id: "atlas", label: "Atlas", href: "/customers/atlas" }, { id: "meridian", label: "Meridian", href: "/customers/meridian" }, { id: "kelvin", label: "Kelvin", href: "/customers/kelvin" }, { id: "northbeam", label: "Northbeam", href: "/customers/northbeam" }, { id: "orbit", label: "Orbit", href: "/customers/orbit" }, ]; export function TrustedBy() { return (

Trusted by

); } ``` ### Props - `items` (`LogoMarqueeItem[]`) Each item is { id, label, href?, mark? }. `mark` is the artwork; `label` is what a screen reader gets and what renders when no mark is supplied. - `label` (`string`) — default: `"Logos"` Accessible name for the region, so the strip is announced as one landmark rather than a loose run of links. - `speed` (`number`) — default: `44` Travel in pixels per second. Speed is time-based, not frame-based, so a 120Hz display does not run twice as fast. - `direction` (`"left" | "right"`) — default: `"left"` Which way the strip drifts. Both directions use the same wrap window, so neither one shows a seam. - `gap` (`number`) — default: `40` Pixel gap between logos and between repeats, so the spacing across the loop seam matches the spacing everywhere else. - `paused` (`boolean`) — default: `false` Controlled stop from outside, ORed with hover and focus. Clearing it ramps back up from the current position. - `onSelect` (`(item: LogoMarqueeItem) => void`) Renders each logo as a button instead of a link. Supply this or `href` if the logos are meant to be clickable. - `onPauseChange` (`(paused: boolean) => void`) Fires once per transition between drifting and held, not per frame. Held through a ref, so an inline arrow does not resubscribe. - `className` (`string`) — default: `""` Appended last on the section, so the surface, radius and border are overridable from outside. ### Behavior notes - Pointer or keyboard entry ramps the strip to a stop and resumes from the pixel it stopped on, so a logo never slides out from under a cursor that was already aiming at it, and leaving never restarts the run from the beginning. - Tabbing to a logo pulls that logo inside the viewport before its focus ring can be painted off-screen, and the viewport's scrollLeft is pinned to zero so the browser's own focus scrolling can never desync the scroll position from the transform. - The transport is one requestAnimationFrame writing a single transform to the track through a ref; React never re-renders to move it, the loop is unsubscribed entirely while the strip is off-screen, and the frame delta is clamped so returning from a background tab resumes instead of teleporting. - The number of repeats is measured from the viewport width and one group's width with a ResizeObserver, so a wide screen never runs out of content and the wrap always lands on a position that is pixel-identical to the one before it. - Only one copy is real: the repeats are aria-hidden and hold nothing focusable, so a screen reader reads the list of companies once and tab order visits each logo once. - Under prefers-reduced-motion the strip stops being a transport and becomes a single scrollable row that keyboard users can reach and move; every logo is still present, none of it is hidden. ### Source (`components/interior/logo-marquee.tsx`) ```tsx "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useIsomorphicLayoutEffect, useReducedMotion } from "motion/react"; const RAMP = 0.19; const SETTLE = 0.16; const MAX_COPIES = 14; export type MarqueeDirection = "left" | "right"; export type UseLogoMarqueeOptions = { speed?: number; direction?: MarqueeDirection; gap?: number; paused?: boolean; }; function fold(x: number, loop: number) { const m = x % loop; return m > 0 ? m - loop : m; } function clamp(x: number, min: number, max: number) { return x < min ? min : x > max ? max : x; } export function useLogoMarquee({ speed = 44, direction = "left", gap = 40, paused = false, }: UseLogoMarqueeOptions = {}) { const viewportRef = useRef(null); const trackRef = useRef(null); const groupRef = useRef(null); const [copies, setCopies] = useState(4); const [held, setHeld] = useState(false); const [near, setNear] = useState(false); const reduced = useReducedMotion() === true; const stopped = held || paused; const reducedRef = useRef(reduced); reducedRef.current = reduced; const movingRef = useRef(false); movingRef.current = !stopped && !reduced; const offset = useRef(0); const nudge = useRef(0); const rate = useRef(0); const span = useRef(0); const paint = useCallback(() => { const track = trackRef.current; if (!track) return; const x = reducedRef.current ? 0 : offset.current - span.current; track.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`; }, []); useIsomorphicLayoutEffect(() => { const viewport = viewportRef.current; const group = groupRef.current; if (!viewport || !group) return; const measure = () => { const width = group.getBoundingClientRect().width; const loop = width > 0 ? width + gap : 0; const room = viewport.getBoundingClientRect().width; span.current = loop; offset.current = loop > 0 ? clamp(offset.current, -loop, loop) : 0; paint(); const next = reduced || loop <= 0 ? 4 : clamp(Math.ceil(room / loop) + 3, 4, MAX_COPIES); setCopies((prev) => (prev === next ? prev : next)); }; measure(); const observer = new ResizeObserver(measure); observer.observe(viewport); observer.observe(group); return () => observer.disconnect(); }, [gap, paint, reduced]); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; if (typeof IntersectionObserver === "undefined") { setNear(true); return; } const observer = new IntersectionObserver( (entries) => { const entry = entries[entries.length - 1]; if (entry) setNear(entry.isIntersecting); }, { rootMargin: "96px" }, ); observer.observe(viewport); return () => observer.disconnect(); }, []); useEffect(() => { if (reduced || !near) return; let frame = 0; let last = 0; const sign = direction === "right" ? 1 : -1; const tick = (now: number) => { frame = requestAnimationFrame(tick); const dt = last ? Math.min((now - last) / 1000, 0.05) : 0; last = now; const loop = span.current; if (loop <= 0) return; rate.current += ((movingRef.current ? 1 : 0) - rate.current) * (1 - Math.exp(-dt / RAMP)); const pull = nudge.current * (1 - Math.exp(-dt / SETTLE)); nudge.current -= pull; let x = offset.current + sign * speed * rate.current * dt + pull; if (rate.current > 0.002 && Math.abs(nudge.current) < 0.25) { nudge.current = 0; x = fold(x, loop); } else { x = clamp(x, -loop, loop); } offset.current = x; paint(); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [reduced, near, speed, direction, paint]); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; const pin = () => { if (reducedRef.current) return; if (viewport.scrollLeft !== 0) viewport.scrollLeft = 0; if (viewport.scrollTop !== 0) viewport.scrollTop = 0; }; viewport.addEventListener("scroll", pin, { passive: true }); return () => viewport.removeEventListener("scroll", pin); }, []); useEffect(() => { const release = () => setHeld(false); window.addEventListener("blur", release); return () => window.removeEventListener("blur", release); }, []); const reveal = useCallback((node: HTMLElement) => { const viewport = viewportRef.current; const loop = span.current; if (!viewport || reducedRef.current || loop <= 0) return; if (node === viewport) return; const view = viewport.getBoundingClientRect(); const box = node.getBoundingClientRect(); const pad = 12; let delta = 0; if (box.left < view.left + pad) delta = view.left + pad - box.left; else if (box.right > view.right - pad) delta = view.right - pad - box.right; if (delta === 0) return; const target = clamp(offset.current + nudge.current + delta, -loop, loop); nudge.current = target - offset.current; }, []); const bind = { onPointerEnter: (e: React.PointerEvent) => { if (e.pointerType !== "touch") setHeld(true); }, onPointerDown: () => setHeld(true), onPointerUp: (e: React.PointerEvent) => { if (e.pointerType === "touch") setHeld(false); }, onPointerCancel: () => setHeld(false), onPointerLeave: () => setHeld(false), onFocus: (e: React.FocusEvent) => { setHeld(true); reveal(e.target as HTMLElement); }, onBlur: () => setHeld(false), }; return { viewportRef, trackRef, groupRef, copies, paused: stopped, reduced, bind, }; } export type LogoMarqueeItem = { id: string; label: string; href?: string; mark?: React.ReactNode; }; export type LogoMarqueeProps = { items: LogoMarqueeItem[]; label?: string; speed?: number; direction?: MarqueeDirection; gap?: number; paused?: boolean; onSelect?: (item: LogoMarqueeItem) => void; className?: string; }; const FACE = "inline-flex h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-[9px] px-3 text-[13px] font-medium tracking-[-0.01em] text-stone-500 dark:text-stone-400"; const HIT = "outline-none transition-colors duration-150 hover:text-stone-700 focus-visible:bg-[#4568FF]/[0.06] focus-visible:text-stone-700 focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:hover:text-stone-200 dark:focus-visible:bg-[#93B0FF]/[0.10] dark:focus-visible:text-stone-200 dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"; function face(item: LogoMarqueeItem) { if (!item.mark) return item.label; return ( <> {item.mark} {item.label} ); } export function LogoMarquee({ items, label = "Logos", speed = 44, direction = "left", gap = 40, paused = false, onSelect, className = "", }: LogoMarqueeProps) { const { viewportRef, trackRef, groupRef, copies, reduced, bind } = useLogoMarquee({ speed, direction, gap, paused }); const groups = reduced ? 1 : copies; const live = reduced ? 0 : 1; return (
{Array.from({ length: groups }, (_, copy) => (
    {items.map((item) => (
  • {copy !== live ? ( {item.mark ?? item.label} ) : item.href ? ( {face(item)} ) : onSelect ? ( ) : ( {face(item)} )}
  • ))}
))}
); } ```