Skip to content
Andrew SmithSolutions
← Component library

Marquee

Motion

A ribbon of names that loops seamlessly, pauses when you reach for it, and turns into a plain scrollable row under reduced motion.

Infinite loopReduced motionCompositedCSS only — no dependency

Preview

Next.jsTypeScriptPostgresStripeTwilioVercel

Controls

Notes

The seam is hidden by rendering the track twice and translating it exactly -50%: when the first copy leaves, the second is already where it started. The duplicate is aria-hidden, because otherwise every name is read twice and nothing says which list is real.

It pauses on hover and on focus-within, so someone who wants to read one of the names can stop it.

Under reduced motion the clone is removed and the row becomes horizontally scrollable. Stopping the animation alone would leave half a word off the edge with no way to reach it.

It translates the track, which is one composited layer. Animating scrollLeft or margin-left instead re-runs layout every frame for the same picture.

Source

Show
import { cn } from "@/lib/cn";

/**
 * A ribbon that loops seamlessly.
 *
 * The seam is hidden by rendering the track TWICE and translating it exactly
 * -50%: when the first copy leaves, the second is already where it started.
 * The duplicate is aria-hidden, because otherwise every name is read twice and
 * the reader has no way to tell which list is real.
 *
 * It translates the track — one composited layer. Animating scrollLeft or
 * margin-left instead re-runs layout every frame for the same picture.
 */
const css = `
@keyframes marquee-roll { to { translate: -50% 0; } }
.marquee { overflow: hidden; }
.marquee-track {
  display: flex;
  inline-size: max-content;
  animation: marquee-roll var(--marquee-duration, 26s) linear infinite;
}
.marquee-reverse .marquee-track { animation-direction: reverse; }
.marquee-pause:hover .marquee-track,
.marquee-pause:focus-within .marquee-track { animation-play-state: paused; }
.marquee-fade {
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
}
/* Not "stop and leave it half off-screen": drop the duplicate and hand the
   reader a row they can scroll themselves. */
@media (prefers-reduced-motion: reduce) {
  .marquee { overflow-x: auto; }
  .marquee-track { animation: none; }
  .marquee-clone { display: none; }
}
`;

const speeds = { slow: "46s", medium: "26s", fast: "14s" } as const;

export type MarqueeSpeed = keyof typeof speeds;
export type MarqueeDirection = "left" | "right";

export function Marquee({
  items,
  speed = "medium",
  direction = "left",
  pauseOnHover = true,
  fade = true,
  className,
}: {
  items: string[];
  speed?: MarqueeSpeed;
  direction?: MarqueeDirection;
  pauseOnHover?: boolean;
  fade?: boolean;
  className?: string;
}) {
  const row = (clone: boolean) => (
    <div
      aria-hidden={clone || undefined}
      className={cn("flex shrink-0 items-center", clone && "marquee-clone")}
    >
      {items.map((item) => (
        <span
          key={item}
          className="px-7 font-mono text-[12px] tracking-[0.14em] whitespace-nowrap text-ink-muted uppercase"
        >
          {item}
        </span>
      ))}
    </div>
  );

  return (
    <div
      className={cn(
        "marquee w-full",
        direction === "right" && "marquee-reverse",
        pauseOnHover && "marquee-pause",
        fade && "marquee-fade",
        className,
      )}
      style={{ "--marquee-duration": speeds[speed] } as React.CSSProperties}
    >
      <style href="marquee-roll" precedence="default">
        {css}
      </style>
      <div className="marquee-track">
        {row(false)}
        {row(true)}
      </div>
    </div>
  );
}

const stack = ["Next.js", "TypeScript", "Postgres", "Stripe", "Twilio", "Vercel"];

export default function Demo({
  speed = "medium",
  direction = "left",
  pauseOnHover = true,
  fade = true,
}: {
  speed?: MarqueeSpeed;
  direction?: MarqueeDirection;
  pauseOnHover?: boolean;
  fade?: boolean;
}) {
  return (
    <div className="w-full max-w-md rounded-xl border border-rule bg-paper-raised py-5">
      <Marquee
        items={stack}
        speed={speed}
        direction={direction}
        pauseOnHover={pauseOnHover}
        fade={fade}
      />
    </div>
  );
}

Next

Button

Next step

Tell me where the business is losing time.

No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.