React 19+
Uses a generic render function and stable generated animation names.
Reusable infinite marquee primitive for logos, cards, chips, or any JSX content. Duplicates one identical group and animates by half the track for a seamless reset.
Seamless Primitive
Duplicate one identical group, animate the track by half its width, and the reset disappears.
Copy the file below into your project. Heronimation itself is not a runtime dependency; install or replace only the requirements listed for this variant.
"use client";
import { useId } from "react";
export interface SeamlessMarqueeProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
getKey?: (item: T, index: number) => string | number;
direction?: "left" | "right";
duration?: number;
gap?: string;
pauseOnHover?: boolean;
ariaLabel?: string;
className?: string;
trackClassName?: string;
groupClassName?: string;
}
const defaultDuration = 40;
const defaultGap = "2rem";
export default function SeamlessMarquee<T>({
items,
renderItem,
getKey,
direction = "left",
duration = defaultDuration,
gap = defaultGap,
pauseOnHover = true,
ariaLabel,
className = "",
trackClassName = "",
groupClassName = "",
}: SeamlessMarqueeProps<T>) {
const id = useId().replace(/:/g, "");
const marqueeClassName = `seamless-marquee-${id}`;
const animationName =
direction === "left" ? "seamless-marquee-left" : "seamless-marquee-right";
const renderGroup = (group: "primary" | "duplicate") => (
<div
className={`flex shrink-0 items-center ${groupClassName}`}
style={{ gap, paddingRight: gap }}
aria-hidden={group === "duplicate"}
>
{items.map((item, index) => (
<div
key={`${group}-${getKey ? getKey(item, index) : String(index)}`}
className="shrink-0"
>
{renderItem(item, index)}
</div>
))}
</div>
);
return (
<div
className={`overflow-hidden ${className}`}
aria-label={ariaLabel}
role={ariaLabel ? "region" : undefined}
>
<div
className={`flex w-max ${marqueeClassName} ${
pauseOnHover ? `${marqueeClassName}-pause` : ""
} ${trackClassName}`}
style={
{
animation: `${animationName} ${duration}s linear infinite`,
transform: "translate3d(0, 0, 0)",
willChange: "transform",
} as React.CSSProperties
}
>
{renderGroup("primary")}
{renderGroup("duplicate")}
</div>
<style>{`
@keyframes seamless-marquee-left {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes seamless-marquee-right {
from { transform: translateX(-50%); }
to { transform: translateX(0); }
}
${
pauseOnHover
? `
@media (hover: hover) {
.${marqueeClassName}-pause:hover {
animation-play-state: paused;
}
}
`
: ""
}
`}</style>
</div>
);
}
export function SeamlessMarqueeDemo() {
const signals = [
"Survey Corps",
"Wall Maria",
"ODM Gear",
"Basement Key",
"Paths",
"The Ocean",
"Founding Titan",
"Rumbling",
"Hange Zoe",
"Eren Yeager",
"Zeke Yeager",
"World Map",
];
return (
<section className="flex min-h-[420px] flex-col justify-center gap-10 bg-zinc-950 py-16 text-zinc-50">
<div className="mx-auto max-w-3xl px-6 text-center">
<p className="mb-3 text-xs font-bold uppercase tracking-[0.22em] text-cyan-300">
Seamless Primitive
</p>
<h2 className="text-3xl font-black tracking-tight sm:text-5xl">
Reusable Infinite Marquee
</h2>
<p className="mx-auto mt-4 max-w-2xl text-sm leading-6 text-zinc-400 sm:text-base">
Duplicate one identical group, animate the track by half its width,
and the reset disappears.
</p>
</div>
<div className="space-y-4">
<SeamlessMarquee
items={[...signals, ...signals]}
getKey={(item, index) => `${item}-${index}`}
duration={56}
gap="1rem"
pauseOnHover={false}
ariaLabel="Example marquee moving left"
renderItem={(item) => (
<span className="rounded-full border border-cyan-300/30 bg-cyan-300/10 px-7 py-3 text-sm font-semibold uppercase tracking-wider text-cyan-100 shadow-lg shadow-cyan-950/40">
{item}
</span>
)}
/>
<SeamlessMarquee
items={[...signals].reverse().concat(signals)}
getKey={(item, index) => `${item}-${index}`}
direction="right"
duration={64}
gap="1rem"
pauseOnHover={false}
ariaLabel="Example marquee moving right"
renderItem={(item) => (
<span className="rounded-full border border-amber-300/30 bg-amber-300/10 px-7 py-3 text-sm font-semibold uppercase tracking-wider text-amber-100 shadow-lg shadow-amber-950/40">
{item}
</span>
)}
/>
</div>
</section>
);
}
Uses a generic render function and stable generated animation names.
Only needed for the included demo classes.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | T[] | Yes | — | Data items rendered into the marquee group |
| renderItem | (item: T, index: number) => React.ReactNode | Yes | — | Render function for each item |
| getKey | (item: T, index: number) => string | number | No | — | Optional stable key getter |
| direction | "left" | "right" | No | "left" | Scroll direction |
| duration | number | No | 40 | Seconds per full loop |
| gap | string | No | "2rem" | CSS gap between rendered items |
| pauseOnHover | boolean | No | true | Pause animation when hovered on pointer devices |
| ariaLabel | string | No | — | Accessible label for the marquee region |
| className | string | No | — | Classes for the outer clipping container |
| trackClassName | string | No | — | Classes for the animated track |
| groupClassName | string | No | — | Classes applied to each duplicated item group |