Infinite Card Marquee

A component for displaying cards in a continuous scrolling carousel.

Infinite Card Marquee
Infinite card marquee component built ontop of shadcn/ui and tailwind css.
Web Development
Modern frontend solutions

Our team specializes in building responsive and accessible web applications using the latest technologies.

ReactNext.js
Mobile Apps
Cross-platform development

We create native-like mobile experiences that work seamlessly across both iOS and Android platforms.

React NativeFlutter
Backend Services
Scalable API solutions

Our backend services are built to scale, with robust APIs and efficient database designs.

Node.jsPython
DevOps
CI/CD and deployment

We implement automated testing and deployment pipelines to ensure consistent and reliable software delivery.

DockerKubernetes
UI/UX Design
User-centered interfaces

Our design team creates intuitive and visually appealing interfaces with a focus on user experience.

FigmaAdobe XD
Web Development
Modern frontend solutions

Our team specializes in building responsive and accessible web applications using the latest technologies.

ReactNext.js
Mobile Apps
Cross-platform development

We create native-like mobile experiences that work seamlessly across both iOS and Android platforms.

React NativeFlutter
Backend Services
Scalable API solutions

Our backend services are built to scale, with robust APIs and efficient database designs.

Node.jsPython
DevOps
CI/CD and deployment

We implement automated testing and deployment pipelines to ensure consistent and reliable software delivery.

DockerKubernetes
UI/UX Design
User-centered interfaces

Our design team creates intuitive and visually appealing interfaces with a focus on user experience.

FigmaAdobe XD
Web Development
Modern frontend solutions

Our team specializes in building responsive and accessible web applications using the latest technologies.

ReactNext.js
Mobile Apps
Cross-platform development

We create native-like mobile experiences that work seamlessly across both iOS and Android platforms.

React NativeFlutter
Backend Services
Scalable API solutions

Our backend services are built to scale, with robust APIs and efficient database designs.

Node.jsPython
DevOps
CI/CD and deployment

We implement automated testing and deployment pipelines to ensure consistent and reliable software delivery.

DockerKubernetes
UI/UX Design
User-centered interfaces

Our design team creates intuitive and visually appealing interfaces with a focus on user experience.

FigmaAdobe XD
1"use client";
2
3import React from "react";
4import { cva, type VariantProps } from "class-variance-authority";
5
6import { cn } from "@/lib/utils";
7import {
8 Card,
9 CardHeader,
10 CardTitle,
11 CardDescription,
12 CardContent,
13 CardFooter,
14} from "@/components/ui/card";
15
16const infiniteCardViewportVariants = cva("relative w-full overflow-hidden", {
17 variants: {
18 axis: {
19 horizontal: "flex",
20 vertical: "flex h-[400px] flex-col",
21 },
22 pauseOnHover: {
23 true: "infinite-marquee-root",
24 false: null,
25 },
26 },
27 defaultVariants: {
28 axis: "horizontal",
29 pauseOnHover: true,
30 },
31});
32
33/** Maps axis × reverse × speed to the correct global marquee animation class. */
34const infiniteCardTrackAnimationVariants = cva("", {
35 variants: {
36 axis: { horizontal: "", vertical: "" },
37 reverse: { true: "", false: "" },
38 speed: { slow: "", normal: "", fast: "" },
39 },
40 compoundVariants: [
41 {
42 axis: "horizontal",
43 reverse: false,
44 speed: "slow",
45 class: "animate-marquee-slow",
46 },
47 {
48 axis: "horizontal",
49 reverse: false,
50 speed: "normal",
51 class: "animate-marquee",
52 },
53 {
54 axis: "horizontal",
55 reverse: false,
56 speed: "fast",
57 class: "animate-marquee-fast",
58 },
59 {
60 axis: "horizontal",
61 reverse: true,
62 speed: "slow",
63 class: "animate-marquee-slow-reverse",
64 },
65 {
66 axis: "horizontal",
67 reverse: true,
68 speed: "normal",
69 class: "animate-marquee-reverse",
70 },
71 {
72 axis: "horizontal",
73 reverse: true,
74 speed: "fast",
75 class: "animate-marquee-fast-reverse",
76 },
77 {
78 axis: "vertical",
79 reverse: false,
80 speed: "slow",
81 class: "animate-marquee-vertical-slow",
82 },
83 {
84 axis: "vertical",
85 reverse: false,
86 speed: "normal",
87 class: "animate-marquee-vertical",
88 },
89 {
90 axis: "vertical",
91 reverse: false,
92 speed: "fast",
93 class: "animate-marquee-vertical-fast",
94 },
95 {
96 axis: "vertical",
97 reverse: true,
98 speed: "slow",
99 class: "animate-marquee-vertical-slow-reverse",
100 },
101 {
102 axis: "vertical",
103 reverse: true,
104 speed: "normal",
105 class: "animate-marquee-vertical-reverse",
106 },
107 {
108 axis: "vertical",
109 reverse: true,
110 speed: "fast",
111 class: "animate-marquee-vertical-fast-reverse",
112 },
113 ],
114 defaultVariants: {
115 axis: "horizontal",
116 reverse: false,
117 speed: "normal",
118 },
119});
120
121const infiniteCardTrackLayoutVariants = cva(
122 "flex shrink-0 items-center gap-[var(--gap)]",
123 {
124 variants: {
125 axis: {
126 horizontal: "min-w-full flex-row py-4",
127 vertical: "min-h-full flex-col px-4",
128 },
129 },
130 defaultVariants: {
131 axis: "horizontal",
132 },
133 }
134);
135
136const infiniteCardTrackPauseVariants = cva("", {
137 variants: {
138 pauseOnHover: {
139 true: "infinite-marquee-track",
140 false: null,
141 },
142 },
143 defaultVariants: {
144 pauseOnHover: true,
145 },
146});
147
148const infiniteCardItemVariants = cva(
149 "overflow-hidden transition-all duration-300",
150 {
151 variants: {
152 scaleOnHover: {
153 true: "hover:scale-105",
154 false: null,
155 },
156 },
157 defaultVariants: {
158 scaleOnHover: true,
159 },
160 }
161);
162
163export type CardItem = {
164 id: string | number;
165 title?: string;
166 description?: string;
167 content?: React.ReactNode;
168 footer?: React.ReactNode;
169 /** Classes on the root `Card` */
170 className?: string;
171 headerClassName?: string;
172 contentClassName?: string;
173 footerClassName?: string;
174};
175
176export type InfiniteCardMarqueeProps = {
177 cards: CardItem[];
178 /** Scroll horizontally (default) or vertically. */
179 axis?: VariantProps<typeof infiniteCardViewportVariants>["axis"];
180 /**
181 * Flip scroll direction: horizontal marquees move right instead of left;
182 * vertical marquees move up instead of down.
183 */
184 reverse?: boolean;
185 speed?: VariantProps<typeof infiniteCardTrackAnimationVariants>["speed"];
186 pauseOnHover?: boolean;
187 scaleOnHover?: boolean;
188 cardWidth?: number | string;
189 cardHeight?: number | string;
190 /** Space between cards in pixels. */
191 gap?: number;
192 /**
193 * How many full passes of `cards` are rendered in each animated track.
194 * Ignored when `fillViewport` is true (count is computed from layout).
195 */
196 sets?: number;
197 /** Grow repetition until the track fills the viewport for smoother infinite loops. */
198 fillViewport?: boolean;
199 /** Outer wrapper (width boundary). */
200 className?: string;
201 /** Clipped viewport: default includes `h-[400px]` when `axis="vertical"`. */
202 viewportClassName?: string;
203 /** Inner row/column that runs the CSS marquee animation. */
204 trackClassName?: string;
205 /** Applied to every `Card` root (in addition to each item’s `className`). */
206 itemClassName?: string;
207};
208
209export default function InfiniteCardMarquee({
210 cards,
211 axis = "horizontal",
212 reverse = false,
213 speed = "normal",
214 pauseOnHover = true,
215 scaleOnHover = true,
216 cardWidth = 300,
217 cardHeight = "auto",
218 gap = 16,
219 className,
220 sets = 2,
221 fillViewport = false,
222 viewportClassName,
223 trackClassName,
224 itemClassName,
225}: InfiniteCardMarqueeProps) {
226 const containerRef = React.useRef<HTMLDivElement>(null);
227 const [numTrackSets, setNumTrackSets] = React.useState(() =>
228 Math.max(2, sets)
229 );
230
231 const isVertical = axis === "vertical";
232
233 React.useEffect(() => {
234 if (!fillViewport) {
235 setNumTrackSets(Math.max(2, sets));
236 return;
237 }
238
239 const measure = () => {
240 const el = containerRef.current;
241 if (!el) return;
242
243 const w = el.offsetWidth;
244 const h = el.offsetHeight;
245 const containerSize = isVertical ? h : w;
246 const itemSize = isVertical
247 ? typeof cardHeight === "number"
248 ? cardHeight
249 : 300
250 : typeof cardWidth === "number"
251 ? cardWidth
252 : 300;
253
254 const totalItemSize = cards.length * (itemSize + gap);
255 if (totalItemSize <= 0) return;
256
257 const needed = Math.ceil((containerSize * 2) / totalItemSize);
258 setNumTrackSets(Math.max(Math.max(2, sets), needed));
259 };
260
261 measure();
262 window.addEventListener("resize", measure);
263 return () => window.removeEventListener("resize", measure);
264 }, [fillViewport, cardWidth, cardHeight, gap, cards.length, isVertical, sets]);
265
266 const containerStyle = React.useMemo(
267 () =>
268 ({
269 "--gap": `${gap}px`,
270 }) as React.CSSProperties,
271 [gap]
272 );
273
274 const actualCardHeight = React.useMemo(() => {
275 if (cardHeight !== "auto") return cardHeight;
276 return isVertical ? 200 : 300;
277 }, [cardHeight, isVertical]);
278
279 const cardSizeStyle = React.useMemo(
280 () =>
281 ({
282 width: typeof cardWidth === "number" ? `${cardWidth}px` : cardWidth,
283 height:
284 typeof actualCardHeight === "number"
285 ? `${actualCardHeight}px`
286 : actualCardHeight,
287 flexShrink: 0,
288 flexGrow: 0,
289 }) as React.CSSProperties,
290 [cardWidth, actualCardHeight]
291 );
292
293 const rootClasses = cn("w-full overflow-hidden", className);
294
295 const viewportClasses = cn(
296 infiniteCardViewportVariants({ axis, pauseOnHover }),
297 viewportClassName
298 );
299
300 const trackClasses = cn(
301 infiniteCardTrackLayoutVariants({ axis }),
302 infiniteCardTrackAnimationVariants({ axis, reverse, speed }),
303 infiniteCardTrackPauseVariants({ pauseOnHover }),
304 trackClassName
305 );
306
307 const renderCard = React.useCallback(
308 (card: CardItem, keyPrefix: string, index: number) => (
309 <Card
310 key={`${keyPrefix}-${card.id}-${index}`}
311 className={cn(
312 infiniteCardItemVariants({ scaleOnHover }),
313 itemClassName,
314 card.className
315 )}
316 style={cardSizeStyle}
317 >
318 <div className="flex h-full flex-col">
319 {(card.title || card.description) && (
320 <CardHeader className={cn("flex-shrink-0", card.headerClassName)}>
321 {card.title && <CardTitle>{card.title}</CardTitle>}
322 {card.description && (
323 <CardDescription>{card.description}</CardDescription>
324 )}
325 </CardHeader>
326 )}
327 {card.content && (
328 <CardContent
329 className={cn(
330 "flex-grow overflow-auto",
331 card.contentClassName
332 )}
333 >
334 <div className="h-full">{card.content}</div>
335 </CardContent>
336 )}
337 {card.footer && (
338 <CardFooter
339 className={cn("mt-auto flex-shrink-0", card.footerClassName)}
340 >
341 {card.footer}
342 </CardFooter>
343 )}
344 </div>
345 </Card>
346 ),
347 [cardSizeStyle, itemClassName, scaleOnHover]
348 );
349
350 const duplicatedCards = React.useMemo(() => {
351 const out: React.ReactNode[] = [];
352 for (let i = 0; i < numTrackSets; i++) {
353 out.push(
354 ...cards.map((card, index) => renderCard(card, `set-${i}`, index))
355 );
356 }
357 return out;
358 }, [cards, numTrackSets, renderCard]);
359
360 return (
361 <div className={rootClasses}>
362 <div
363 ref={containerRef}
364 className={viewportClasses}
365 style={containerStyle}
366 >
367 <div className={trackClasses}>{duplicatedCards}</div>
368 <div className={trackClasses}>
369 {cards.map((card, index) => renderCard(card, "sync", index))}
370 </div>
371 </div>
372 </div>
373 );
374}

Props

NameTypeDefaultDescription
cardsCardItem[]RequiredItems to render in the loop
axis"horizontal" | "vertical""horizontal"Scroll along the x or y axis
reversebooleanfalseOpposite scroll (right/up instead of left/down)
speed"slow" | "normal" | "fast""normal"Animation speed preset
pauseOnHoverbooleantruePause while the pointer is over the viewport
scaleOnHoverbooleantrueSlight scale-up on card hover
cardWidthnumber | string300Card width (number = px)
cardHeightnumber | string"auto"Card height; auto uses a fixed default for layout stability
gapnumber16Gap between cards in px
setsnumber2How many full list copies per track when fillViewport is false
fillViewportbooleanfalseCompute copies from container size for a seamless loop
classNamestringOuter wrapper
viewportClassNamestringClipped area (vertical default height is h-[400px])
trackClassNamestringAnimated row/column
itemClassNamestringEvery Card root (merged with each item className)