Logo marquee
A quiet strip of logos that notices you: it eases to a stop on hover and the logo you point at stays bright.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/logo-marquee.jsonOverview
The customer logo strip is on every landing page, and it usually either sits still or scrolls like a ticker that never notices you. This one drifts past at a steady speed and fades out softly at both edges. Point at it and it eases to a stop instead of halting, with the logo under your pointer staying bright while the rest dim; move away and it eases back up to speed. The speed is in pixels per second, so a longer strip never moves faster.
States
- moving
- Two copies of the set loop seamlessly at speed px/s, masked to fade over the outer 12% on each side.
- hover
- The playback rate eases from 1 to 0 over 650ms; the hovered logo goes to full ink, the others to 35%.
- leave
- The rate eases back to 1 from wherever it was.
Usage
import { LogoMarquee } from "@/components/logo-marquee";
<LogoMarquee logos={[<img key="a" src="/logos/acme.svg" alt="Acme" className="h-6" />, …]} />In a proof strip
Use monochrome SVGs with currentColor, so they take the muted tone and light up on hover.
import { LogoMarquee } from "@/components/logo-marquee";
import { Acme, Globex, Initech, Umbrella } from "@/components/logos"; // your SVGs, using fill="currentColor"
export function Customers() {
return (
<section className="py-16 text-center">
<p className="text-sm text-muted-foreground">Trusted by teams who ship weekly</p>
<LogoMarquee
className="mt-6"
speed={32}
logos={[
<Acme key="acme" aria-label="Acme" className="h-6" />,
<Globex key="globex" aria-label="Globex" className="h-6" />,
<Initech key="initech" aria-label="Initech" className="h-6" />,
<Umbrella key="umbrella" aria-label="Umbrella" className="h-6" />,
]}
/>
</section>
);
}Props
| Prop | Type | Description |
|---|---|---|
| logos | ReactNode[] | Images or inline SVGs, each with an accessible name. |
| speed | numberdefault 36 | Pixels per second, whatever the strip’s length. |
| gap | numberdefault 56 | Space between logos (px). |
| direction | "left" | "right"default "left" | Which way the strip drifts. |
| pauseOnHover | booleandefault true | Ease to a stop while pointed at. |
Accessibility and motion
- Screen readers get one list of logos; the copy that makes the loop seamless is hidden.
- Runs on the Web Animations API with a single transform, so it costs nothing per frame; the stop is a tween of playbackRate, not a jump.
- With reduced motion the logos sit still in a centred, wrapping row.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
Source
›Show the full source of logo-marquee.tsx (122 lines)
"use client";
import * as React from "react";
/* ─────────────────────────────────────────────────────────
* LOGO MARQUEE: a quiet strip of logos that notices you
*
* moving logos drift past at a steady speed, fading out at
* both edges; any width, any number of logos
* hover the strip eases to a stop instead of halting, the
* logo you point at stays bright and the rest dim
* leave it eases back up to speed
*
* The speed is in pixels per second, so a longer strip doesn't
* move faster. With reduced motion the logos simply sit in a row.
* ───────────────────────────────────────────────────────── */
export interface LogoMarqueeProps extends React.HTMLAttributes<HTMLDivElement> {
/** The logos, each an image or an inline SVG with an accessible name. */
logos: React.ReactNode[];
/** Pixels per second. */
speed?: number;
/** Space between logos (px). */
gap?: number;
direction?: "left" | "right";
/** Ease to a stop on hover. */
pauseOnHover?: boolean;
}
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
export function LogoMarquee({ logos, speed = 36, gap = 56, direction = "left", pauseOnHover = true, className = "", ...props }: LogoMarqueeProps) {
const reduced = useReducedMotion();
const trackRef = React.useRef<HTMLDivElement>(null);
const animation = React.useRef<Animation | null>(null);
const tween = React.useRef(0);
// One loop is exactly one set of logos; its duration follows from the set's width and the speed.
React.useEffect(() => {
const track = trackRef.current;
if (!track || reduced) return;
const set = () => track.scrollWidth / 2;
const keyframes = direction === "left" ? [{ transform: "translateX(0)" }, { transform: "translateX(-50%)" }] : [{ transform: "translateX(-50%)" }, { transform: "translateX(0)" }];
const a = track.animate(keyframes, { duration: (set() / speed) * 1000, iterations: Infinity, easing: "linear" });
animation.current = a;
const observer = new ResizeObserver(() => a.effect?.updateTiming({ duration: (set() / speed) * 1000 }));
observer.observe(track);
return () => {
observer.disconnect();
cancelAnimationFrame(tween.current);
a.cancel();
animation.current = null;
};
}, [speed, direction, reduced, logos.length]);
// Ease the playback rate toward a target, so stopping and starting feel physical.
const easeTo = (target: number) => {
const a = animation.current;
if (!a) return;
cancelAnimationFrame(tween.current);
const from = a.playbackRate;
const start = performance.now();
const step = (now: number) => {
const t = Math.min(1, (now - start) / 650);
const eased = 1 - Math.pow(1 - t, 3);
// Never below zero, or the strip would briefly run backwards.
a.playbackRate = t < 1 ? Math.max(0, from + (target - from) * eased) : target;
if (t < 1) tween.current = requestAnimationFrame(step);
};
tween.current = requestAnimationFrame(step);
};
const items = (copy: number) =>
logos.map((logo, i) => (
<li
key={`${copy}-${i}`}
aria-hidden={copy > 0 || undefined}
className="flex shrink-0 items-center text-muted-foreground transition-[opacity,color,filter] duration-500 group-hover/marquee:opacity-35 hover:!opacity-100 hover:text-foreground"
style={{ marginRight: gap }}
>
{logo}
</li>
));
if (reduced) {
return (
<div className={`w-full ${className}`} {...props}>
<ul className="flex flex-wrap items-center justify-center gap-y-6" style={{ columnGap: gap }}>
{logos.map((logo, i) => (
<li key={i} className="flex items-center text-muted-foreground">
{logo}
</li>
))}
</ul>
</div>
);
}
return (
<div
className={`group/marquee w-full overflow-hidden [mask-image:linear-gradient(to_right,transparent,#000_12%,#000_88%,transparent)] ${className}`}
onPointerEnter={() => pauseOnHover && easeTo(0)}
onPointerLeave={() => pauseOnHover && easeTo(1)}
{...props}
>
<div ref={trackRef} className="flex w-max will-change-transform">
<ul className="flex shrink-0 items-center">{items(0)}</ul>
<ul aria-hidden="true" className="flex shrink-0 items-center">
{items(1)}
</ul>
</div>
</div>
);
}
Registry item: /r/logo-marquee.json