Website Sections
LogoMarquee
A trusted-by strip of logos in one or two rows that loop with no seam. Grey until hovered. Under reduced motion they sit in a still, wrapped group.
Download
- React
- Tailwind CSS v4
- motion
components/ui/logo-marquee.tsx
"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<HTMLDivElement>(null);
const belt = useRef<HTMLDivElement>(null);
const run = useRef<HTMLUListElement>(null);
const anim = useRef<Animation | null>(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<HTMLDivElement>) => {
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 (
<div
ref={box}
onPointerEnter={() => glide(0)}
onPointerLeave={() => glide(1)}
onFocus={showFocused}
onBlur={() => glide(1)}
className="min-w-0 overflow-clip"
style={still ? undefined : { maskImage: MASK, WebkitMaskImage: MASK }}
>
<div ref={belt} className={still ? "" : "flex w-max"}>
{Array.from({ length: copies }, (_, copy) => (
<ul
key={copy}
ref={copy === 0 ? run : undefined}
aria-hidden={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 ? (
<img src={item.src} alt={item.href ? "" : item.name} className="h-8 w-auto max-w-40 object-contain" />
) : (
<span className="text-lg font-semibold tracking-tight whitespace-nowrap">{item.name}</span>
));
const look = `block rounded transition-[filter,opacity] duration-300 motion-reduce:transition-none ${mono ? "opacity-60 grayscale hover:opacity-100 hover:grayscale-0" : ""}`;
return (
<li key={item.name} className="shrink-0">
{item.href ? (
<a href={item.href} aria-label={item.name} tabIndex={copy > 0 ? -1 : undefined} className={look}>
{mark}
</a>
) : (
<span role={item.logo ? "img" : undefined} aria-label={item.logo ? item.name : undefined} className={look}>
{mark}
</span>
)}
</li>
);
})}
</ul>
))}
</div>
</div>
);
}
/* 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 (
<section aria-label={title ?? "Logos"} className={className}>
{title && <p className="mb-6 text-center text-sm font-medium text-balance opacity-70">{title}</p>}
<div className="mx-auto grid max-w-6xl grid-cols-[minmax(0,1fr)] gap-6">
{split.map((row, i) => (
<Row key={i} logos={row} reverse={i === 1} speed={speed} gap={gap} mono={mono} />
))}
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/logo-marquee.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/logo-marquee-demo.tsx
import { Anchor, Flame, Hexagon, Leaf, Moon, Sunrise, Triangle, Waves } from "lucide-react"; import { LogoMarquee } from "@/components/ui/logo-marquee"; const MARKS = [ { name: "Tideline", Icon: Waves, tint: "#38bdf8" }, { name: "Emberly", Icon: Flame, tint: "#fb923c" }, { name: "Mosslight", Icon: Leaf, tint: "#4ade80" }, { name: "Duskworks", Icon: Moon, tint: "#a78bfa" }, { name: "Meridian", Icon: Hexagon, tint: "#f472b6" }, { name: "Harbourly", Icon: Anchor, tint: "#22d3ee" }, { name: "Firstlight", Icon: Sunrise, tint: "#facc15" }, { name: "Northpeak", Icon: Triangle, tint: "#f87171" }, ]; const LOGOS = MARKS.map(({ name, Icon, tint }) => ({ name, href: "#", logo: ( <span className="flex items-center gap-2 text-lg font-semibold tracking-tight whitespace-nowrap"> <Icon className="size-5" style={{ color: tint }} /> {name} </span> ), })); export default function LogoMarqueeExample() { return ( <div className="flex h-full items-center bg-[#0b0a14]"> <LogoMarquee title="Trusted by teams at" logos={LOGOS} rows={2} className="w-full min-w-0 p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| logos | LogoMarqueeItem[] | โ | Each has name, plus optional src, logo (inline svg or any mark) and href. |
| title | string | โ | Small line above the strip. |
| rows | 1 | 2 | 1 | Two rows split the logos and run in opposite directions. |
| speed | number | 40 | Pixels per second. |
| gap | number | 56 | Space between logos in pixels. |
| mono | boolean | true | Grey until hovered. |
| className | string | "bg-[#0b0a14] px-5 py-12 text-white" | Set padding and colours here. |