Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
logos{ name: string; mark?: ReactNode; href?: string }[]The logos. A name alone renders a text wordmark; mark replaces it with your own element.
labelstringSmall uppercase line above the track.
durationnumber40Seconds 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.