## 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 (