Marquee
MotionA ribbon of names that loops seamlessly, pauses when you reach for it, and turns into a plain scrollable row under reduced motion.
Preview
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
ShowHide
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
ButtonNext 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.