Logo marquee
A slow looping row of logos that pauses on hover and focus and stops under reduced motion.
npx shadcn@latest add https://ui.goodtabs.co/r/logo-marquee.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/logo-marquee
Shipped by small teams
- Halden
- Fernwick
- Oxbow Labs
- Tallis
- Marlow & Pike
- Quillon
- Brindle
- Sorrel
Eight made-up wordmarks with a label above.
Styled marks
- HALDEN
- fernwick
- oxbow/labs
- TALLIS
- Quillon
- brindle.
Marks in mono and serif type, passed as elements, slower loop.
Linked logos
Each logo is a link, and keyboard focus pauses the row.
Usage
import { LogoMarquee } from "@/components/logo-marquee"
<LogoMarquee label="Trusted by small teams" logos={[{ name: "Halden" }, { name: "Fernwick" }]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| logos | { name: string; mark?: ReactNode; href?: string }[] | The logos. A name alone renders a text wordmark; mark replaces it with your own element. | |
| label | string | Small uppercase line above the track. | |
| duration | number | 40 | Seconds for one full loop. Slower reads calmer. |
Source
logo-marquee/logo-marquee.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
export type MarqueeLogo = {
/** Visible name. Rendered as a text wordmark unless `mark` is given. */
name: string;
/** Your own logo element, for example an inline svg. It replaces the text wordmark. */
mark?: React.ReactNode;
/** Makes the logo a link. */
href?: string;
};
type LogoMarqueeProps = Omit<React.ComponentProps<"section">, "title"> & {
/** The logos, in order. Four or more fill the track best. */
logos: MarqueeLogo[];
/** Small line above the track. */
label?: string;
/** Seconds for one full loop. Slower reads calmer. */
duration?: number;
};
const css = `
.gt-marquee{--gap:3rem;-webkit-mask-image:linear-gradient(to right,transparent,black 12%,black 88%,transparent);mask-image:linear-gradient(to right,transparent,black 12%,black 88%,transparent)}
.gt-marquee-track{display:flex;width:max-content;animation:gt-marquee var(--gt-duration,40s) linear infinite}
.gt-marquee:hover .gt-marquee-track,.gt-marquee:focus-within .gt-marquee-track{animation-play-state:paused}
.gt-marquee-group{display:flex;flex-shrink:0;align-items:center;gap:var(--gap);padding-right:var(--gap)}
@keyframes gt-marquee{to{transform:translate3d(-50%,0,0)}}
@media (prefers-reduced-motion:reduce){
.gt-marquee{-webkit-mask-image:none;mask-image:none}
.gt-marquee-track{animation:none;width:auto;justify-content:center}
.gt-marquee-group{flex-wrap:wrap;justify-content:center;padding-right:0;row-gap:1.25rem}
.gt-marquee-group[aria-hidden=true]{display:none}
}
`;
function Logo({ logo }: { logo: MarqueeLogo }) {
const body = logo.mark ?? <span className="text-lg font-medium tracking-tight whitespace-nowrap">{logo.name}</span>;
const base = "inline-flex items-center text-muted-foreground transition-colors";
if (logo.href) {
return (
<a href={logo.href} aria-label={logo.name} className={cn(base, "rounded-sm hover:text-foreground")}>
{body}
</a>
);
}
return (
<span className={base} role="img" aria-label={logo.name}>
{body}
</span>
);
}
/**
* A slow, endless row of logos. Pauses on hover and keyboard focus.
* Under reduced motion it stops and wraps into a still, centered row.
* Server component: the motion is CSS only, and the second copy of the list is hidden from assistive tech.
*/
export function LogoMarquee({ logos, label, duration = 40, className, style, ...props }: LogoMarqueeProps) {
const group = (hidden: boolean) => (
<ul
className="gt-marquee-group m-0 list-none p-0"
aria-hidden={hidden || undefined}
{...(hidden ? { inert: true } : {})}
>
{logos.map((logo) => (
<li key={logo.name} className="flex items-center">
<Logo logo={logo} />
</li>
))}
</ul>
);
return (
<section
data-slot="logo-marquee"
className={cn("flex flex-col items-center gap-5 py-6", className)}
style={{ "--gt-duration": `${duration}s`, ...style } as React.CSSProperties}
{...props}
>
<style href="gt-logo-marquee" precedence="default">
{css}
</style>
{label && <p className="m-0 font-mono text-xs tracking-wide text-muted-foreground uppercase">{label}</p>}
<div className="gt-marquee w-full overflow-hidden">
<div className="gt-marquee-track">
{group(false)}
{group(true)}
</div>
</div>
</section>
);
}
For AI agents
Hand your agent this page, the markdown at /components/logo-marquee/prompt.md, or the Copy AI prompt button above.
When to use
- A strip of customer or partner logos under a hero
- When there are more logos than fit in one row
- Quiet social proof that should not compete with the headline
When not to use
- Fewer than four logos, where a static row is calmer
- As the only evidence of trust, which needs numbers or quotes
- Inside dense app screens
Agent notes
- Server component with no state; the motion is a CSS keyframe injected through a precedence style tag
- The list renders twice for a seamless loop; the second copy is aria-hidden and inert
- Hover and focus-within pause it; under prefers-reduced-motion it stops and wraps into a centered still row
- Pass real logos as mark; the demos use made-up wordmarks only
The maths
- Loop distance is the width of one copy, so the track moves by -50% of its own width: 2 copies × w, shift = w.
- Edge fade is a mask from 0 to 12% and 88% to 100%, so a logo is fully visible across the middle 76%.
- Gap and trailing padding are both 3rem, so the seam equals the gap: no jump at the loop point.