Infinite Card Marquee
A component for displaying cards in a continuous scrolling carousel.
Our team specializes in building responsive and accessible web applications using the latest technologies.
We create native-like mobile experiences that work seamlessly across both iOS and Android platforms.
Our backend services are built to scale, with robust APIs and efficient database designs.
We implement automated testing and deployment pipelines to ensure consistent and reliable software delivery.
Our design team creates intuitive and visually appealing interfaces with a focus on user experience.
Our team specializes in building responsive and accessible web applications using the latest technologies.
We create native-like mobile experiences that work seamlessly across both iOS and Android platforms.
Our backend services are built to scale, with robust APIs and efficient database designs.
We implement automated testing and deployment pipelines to ensure consistent and reliable software delivery.
Our design team creates intuitive and visually appealing interfaces with a focus on user experience.
Our team specializes in building responsive and accessible web applications using the latest technologies.
We create native-like mobile experiences that work seamlessly across both iOS and Android platforms.
Our backend services are built to scale, with robust APIs and efficient database designs.
We implement automated testing and deployment pipelines to ensure consistent and reliable software delivery.
Our design team creates intuitive and visually appealing interfaces with a focus on user experience.
1"use client";23import React from "react";4import { cva, type VariantProps } from "class-variance-authority";56import { cn } from "@/lib/utils";7import {8 Card,9 CardHeader,10 CardTitle,11 CardDescription,12 CardContent,13 CardFooter,14} from "@/components/ui/card";1516const 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});3233/** 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});120121const 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);135136const infiniteCardTrackPauseVariants = cva("", {137 variants: {138 pauseOnHover: {139 true: "infinite-marquee-track",140 false: null,141 },142 },143 defaultVariants: {144 pauseOnHover: true,145 },146});147148const 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);162163export 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};175176export 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};208209export 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 );230231 const isVertical = axis === "vertical";232233 React.useEffect(() => {234 if (!fillViewport) {235 setNumTrackSets(Math.max(2, sets));236 return;237 }238239 const measure = () => {240 const el = containerRef.current;241 if (!el) return;242243 const w = el.offsetWidth;244 const h = el.offsetHeight;245 const containerSize = isVertical ? h : w;246 const itemSize = isVertical247 ? typeof cardHeight === "number"248 ? cardHeight249 : 300250 : typeof cardWidth === "number"251 ? cardWidth252 : 300;253254 const totalItemSize = cards.length * (itemSize + gap);255 if (totalItemSize <= 0) return;256257 const needed = Math.ceil((containerSize * 2) / totalItemSize);258 setNumTrackSets(Math.max(Math.max(2, sets), needed));259 };260261 measure();262 window.addEventListener("resize", measure);263 return () => window.removeEventListener("resize", measure);264 }, [fillViewport, cardWidth, cardHeight, gap, cards.length, isVertical, sets]);265266 const containerStyle = React.useMemo(267 () =>268 ({269 "--gap": `${gap}px`,270 }) as React.CSSProperties,271 [gap]272 );273274 const actualCardHeight = React.useMemo(() => {275 if (cardHeight !== "auto") return cardHeight;276 return isVertical ? 200 : 300;277 }, [cardHeight, isVertical]);278279 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 );292293 const rootClasses = cn("w-full overflow-hidden", className);294295 const viewportClasses = cn(296 infiniteCardViewportVariants({ axis, pauseOnHover }),297 viewportClassName298 );299300 const trackClasses = cn(301 infiniteCardTrackLayoutVariants({ axis }),302 infiniteCardTrackAnimationVariants({ axis, reverse, speed }),303 infiniteCardTrackPauseVariants({ pauseOnHover }),304 trackClassName305 );306307 const renderCard = React.useCallback(308 (card: CardItem, keyPrefix: string, index: number) => (309 <Card310 key={`${keyPrefix}-${card.id}-${index}`}311 className={cn(312 infiniteCardItemVariants({ scaleOnHover }),313 itemClassName,314 card.className315 )}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 <CardContent329 className={cn(330 "flex-grow overflow-auto",331 card.contentClassName332 )}333 >334 <div className="h-full">{card.content}</div>335 </CardContent>336 )}337 {card.footer && (338 <CardFooter339 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 );349350 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]);359360 return (361 <div className={rootClasses}>362 <div363 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
| Name | Type | Default | Description |
|---|---|---|---|
| cards | CardItem[] | Required | Items to render in the loop |
| axis | "horizontal" | "vertical" | "horizontal" | Scroll along the x or y axis |
| reverse | boolean | false | Opposite scroll (right/up instead of left/down) |
| speed | "slow" | "normal" | "fast" | "normal" | Animation speed preset |
| pauseOnHover | boolean | true | Pause while the pointer is over the viewport |
| scaleOnHover | boolean | true | Slight scale-up on card hover |
| cardWidth | number | string | 300 | Card width (number = px) |
| cardHeight | number | string | "auto" | Card height; auto uses a fixed default for layout stability |
| gap | number | 16 | Gap between cards in px |
| sets | number | 2 | How many full list copies per track when fillViewport is false |
| fillViewport | boolean | false | Compute copies from container size for a seamless loop |
| className | string | — | Outer wrapper |
| viewportClassName | string | — | Clipped area (vertical default height is h-[400px]) |
| trackClassName | string | — | Animated row/column |
| itemClassName | string | — | Every Card root (merged with each item className) |