{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"marquee","type":"registry:ui","title":"Marquee","description":"An eased conveyor of repeated content that dissolves into soft edge fades and, on hover or focus, brakes smoothly to a stop and pulls away again instead of freezing.","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["layout"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/marquee/Marquee.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport \"./marquee.css\";\n\nexport interface MarqueeProps {\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/** Reverse the scroll direction */\n\treverse?: boolean;\n\t/** Ease the conveyor to a stop while the pointer (or focus) is inside */\n\tpauseOnHover?: boolean;\n\t/** Scroll vertically instead of horizontally */\n\tvertical?: boolean;\n\t/** Number of copies of the children rendered on the track */\n\trepeat?: number;\n\t/** Dissolve items at both edges with a gradient mask */\n\tfade?: boolean;\n\t/** Speed multiplier applied on top of `--duration` (2 = twice as fast) */\n\tspeed?: number;\n\t/** Content to repeat and scroll */\n\tchildren?: ReactNode;\n}\n\n/** Time constant (ms) of the exponential ease on playbackRate. The rate\n * covers ~97% of the distance to its target in 3.5τ ≈ 500 ms, which reads\n * as a conveyor braking and pulling away rather than a hard freeze. */\nconst EASE_TAU = 140;\nconst SETTLE_EPSILON = 0.002;\nconst DEFAULT_DURATION_MS = 40_000;\n\nfunction parseDuration(raw: string): number {\n\tconst match = /^\\s*([\\d.]+)\\s*(ms|s)\\s*$/i.exec(raw);\n\tif (!match) return DEFAULT_DURATION_MS;\n\tconst value = Number.parseFloat(match[1] ?? \"\");\n\tif (!Number.isFinite(value) || value <= 0) return DEFAULT_DURATION_MS;\n\treturn (match[2] ?? \"s\").toLowerCase() === \"ms\" ? value : value * 1000;\n}\n\n/**\n * The per-instance conveyor state. Hover state and the eased factor live\n * outside React's render cycle: they change every frame and nothing in the\n * markup depends on them.\n */\ninterface Conveyor {\n\tspeed: number;\n\tpauseOnHover: boolean;\n\tengaged: boolean;\n\tfactor: number;\n\tanimations: Animation[];\n\trafId: number;\n\tlastTs: number;\n\t/** Where the previous set of animations stood when it was torn down, as a\n\t * position along the loop (0–1, independent of direction and duration), so\n\t * a rebuild (reverse / vertical / repeat / class toggled) neither snaps to\n\t * zero nor mirrors when the direction flips. */\n\tresumeAt: number | null;\n\tapplyRate(): void;\n\twake(): void;\n\tsetEngaged(next: boolean): void;\n}\n\nfunction createConveyor(): Conveyor {\n\tconst c: Conveyor = {\n\t\tspeed: 1,\n\t\tpauseOnHover: false,\n\t\tengaged: false,\n\t\tfactor: 1,\n\t\tanimations: [],\n\t\trafId: 0,\n\t\tlastTs: 0,\n\t\tresumeAt: null,\n\t\tapplyRate() {\n\t\t\tconst rate = c.speed * c.factor;\n\t\t\tfor (const animation of c.animations) animation.playbackRate = rate;\n\t\t},\n\t\twake() {\n\t\t\tif (!c.animations.length || c.rafId) return;\n\t\t\tc.lastTs = 0;\n\t\t\tc.rafId = requestAnimationFrame(tick);\n\t\t},\n\t\tsetEngaged(next: boolean) {\n\t\t\tc.engaged = next && c.pauseOnHover;\n\t\t\tc.wake();\n\t\t},\n\t};\n\n\tfunction tick(ts: number) {\n\t\tconst dt = c.lastTs ? Math.min(ts - c.lastTs, 64) : 16;\n\t\tc.lastTs = ts;\n\t\tconst target = c.engaged ? 0 : 1;\n\t\tc.factor += (target - c.factor) * (1 - Math.exp(-dt / EASE_TAU));\n\t\tif (Math.abs(target - c.factor) < SETTLE_EPSILON) c.factor = target;\n\t\tc.applyRate();\n\t\tif (c.factor === target) {\n\t\t\t// Settled: the loop sleeps until the next enter/leave.\n\t\t\tc.rafId = 0;\n\t\t\tc.lastTs = 0;\n\t\t\treturn;\n\t\t}\n\t\tc.rafId = requestAnimationFrame(tick);\n\t}\n\n\treturn c;\n}\n\nexport function Marquee({\n\tclassName,\n\treverse = false,\n\tpauseOnHover = false,\n\tvertical = false,\n\trepeat = 4,\n\tfade = true,\n\tspeed = 1,\n\tchildren,\n}: MarqueeProps) {\n\tconst reduced = useReducedMotion();\n\tconst rootRef = useRef<HTMLDivElement>(null);\n\t/** True once the tracks are driven by script-owned animations; the CSS\n\t * keyframe is switched off at that point so the two never stack. */\n\tconst [upgraded, setUpgraded] = useState(false);\n\n\tconst safeSpeed = Number.isFinite(speed) && speed > 0 ? speed : 1;\n\n\tconst conveyorRef = useRef<Conveyor | null>(null);\n\tif (!conveyorRef.current) conveyorRef.current = createConveyor();\n\tconst conveyor = conveyorRef.current;\n\n\t// A live `speed` change only needs the rate re-applied, not a rebuild.\n\t// Declared before the upgrade effect so a fresh build reads the current speed.\n\tuseEffect(() => {\n\t\tconveyor.speed = safeSpeed;\n\t\tconveyor.applyRate();\n\t}, [conveyor, safeSpeed]);\n\n\t// Turning pauseOnHover off while hovered releases the brake.\n\tuseEffect(() => {\n\t\tconveyor.pauseOnHover = pauseOnHover;\n\t\tif (!pauseOnHover && conveyor.engaged) conveyor.setEngaged(false);\n\t}, [conveyor, pauseOnHover]);\n\n\t// Upgrade the CSS conveyor to Web Animations so the speed can be eased.\n\t// Re-runs when direction, axis, copy count, class (a class swap may carry a\n\t// new --duration / --gap) or reduced-motion change.\n\tuseEffect(() => {\n\t\tconst el = rootRef.current;\n\t\tif (!el || reduced) return;\n\t\tif (typeof Element === \"undefined\" || typeof Element.prototype.animate !== \"function\") return;\n\n\t\tconst styles = getComputedStyle(el);\n\t\tconst duration = parseDuration(styles.getPropertyValue(\"--duration\"));\n\t\tconst gapPx = Number.parseFloat(vertical ? styles.rowGap : styles.columnGap) || 0;\n\t\tconst axis = vertical ? \"translateY\" : \"translateX\";\n\t\tconst keyframes: Keyframe[] = [\n\t\t\t{ transform: `${axis}(0)` },\n\t\t\t{ transform: `${axis}(calc(-100% - ${gapPx}px))` },\n\t\t];\n\n\t\tconst tracks = Array.from(el.querySelectorAll<HTMLElement>(\":scope > [data-marquee-track]\"));\n\t\t// Pick up where the motion already is, so the hand-off is seamless: the\n\t\t// previous script animation if this is a rebuild, otherwise the CSS\n\t\t// keyframe (whose clock runs `speed` times faster than ours).\n\t\tlet startTime = 0;\n\t\tif (conveyor.resumeAt !== null) {\n\t\t\tstartTime = (reverse ? 1 - conveyor.resumeAt : conveyor.resumeAt) * duration;\n\t\t} else {\n\t\t\tconst running = tracks[0]?.getAnimations?.()[0];\n\t\t\tif (running && typeof running.currentTime === \"number\") {\n\t\t\t\tstartTime = running.currentTime * conveyor.speed;\n\t\t\t}\n\t\t}\n\n\t\tconveyor.animations = tracks\n\t\t\t.map((track) =>\n\t\t\t\ttrack.animate(keyframes, {\n\t\t\t\t\tduration,\n\t\t\t\t\titerations: Infinity,\n\t\t\t\t\teasing: \"linear\",\n\t\t\t\t\tdirection: reverse ? \"reverse\" : \"normal\",\n\t\t\t\t})\n\t\t\t)\n\t\t\t.filter(Boolean);\n\t\tfor (const animation of conveyor.animations) animation.currentTime = startTime % duration;\n\t\tconveyor.applyRate();\n\t\tsetUpgraded(true);\n\t\tif (conveyor.factor !== (conveyor.engaged ? 0 : 1)) conveyor.wake();\n\n\t\treturn () => {\n\t\t\tif (conveyor.rafId) cancelAnimationFrame(conveyor.rafId);\n\t\t\tconveyor.rafId = 0;\n\t\t\tconst t = conveyor.animations[0]?.currentTime;\n\t\t\tif (typeof t === \"number\") {\n\t\t\t\tconst progress = (t % duration) / duration;\n\t\t\t\tconveyor.resumeAt = reverse ? 1 - progress : progress;\n\t\t\t} else {\n\t\t\t\tconveyor.resumeAt = null;\n\t\t\t}\n\t\t\tfor (const animation of conveyor.animations) animation.cancel?.();\n\t\t\tconveyor.animations = [];\n\t\t\tsetUpgraded(false);\n\t\t};\n\t}, [conveyor, vertical, reverse, repeat, className, reduced]);\n\n\tfunction onFocusOut(event: FocusEvent<HTMLDivElement>) {\n\t\tif (!rootRef.current?.contains(event.relatedTarget as Node | null)) conveyor.setEngaged(false);\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tref={rootRef}\n\t\t\tclassName={cn(\n\t\t\t\t\"marquee group flex [gap:var(--gap)] overflow-hidden p-2 [--duration:40s] [--gap:1rem]\",\n\t\t\t\tvertical ? \"marquee-vertical flex-col\" : \"flex-row\",\n\t\t\t\tfade && \"marquee-fade\",\n\t\t\t\tupgraded && \"marquee-upgraded\",\n\t\t\t\tclassName\n\t\t\t)}\n\t\t\tstyle={{ \"--marquee-speed\": safeSpeed } as CSSProperties}\n\t\t\tdata-paused-on-hover={pauseOnHover ? \"\" : undefined}\n\t\t\tonPointerEnter={() => conveyor.setEngaged(true)}\n\t\t\tonPointerLeave={() => conveyor.setEngaged(false)}\n\t\t\tonFocus={() => conveyor.setEngaged(true)}\n\t\t\tonBlur={onFocusOut}\n\t\t>\n\t\t\t{Array.from({ length: repeat }, (_, index) => (\n\t\t\t\t<div\n\t\t\t\t\tkey={index}\n\t\t\t\t\tdata-marquee-track=\"\"\n\t\t\t\t\taria-hidden={index > 0 ? \"true\" : undefined}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"flex shrink-0 justify-around [gap:var(--gap)]\",\n\t\t\t\t\t\tvertical ? \"animate-marquee-vertical flex-col\" : \"animate-marquee flex-row\",\n\t\t\t\t\t\tpauseOnHover ? \"group-hover:[animation-play-state:paused]\" : \"\"\n\t\t\t\t\t)}\n\t\t\t\t\tstyle={{ animationDirection: reverse ? \"reverse\" : \"normal\" } as CSSProperties}\n\t\t\t\t>\n\t\t\t\t\t{children}\n\t\t\t\t</div>\n\t\t\t))}\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/marquee/Marquee.tsx"},{"path":"react/src/components/marquee/ReviewCard.tsx","content":"\"use client\";\n\nimport \"./review-card.css\";\n\nexport interface ReviewCardProps {\n\timg: string;\n\tname: string;\n\tusername: string;\n\tbody: string;\n}\n\n/** A ready-made card for testimonial-style marquees: a nested double frame,\n * a hairline shell holding a hairline inner panel. */\nexport function ReviewCard({ img, name, username, body }: ReviewCardProps) {\n\treturn (\n\t\t<figure className=\"review-card relative flex w-72 shrink-0 flex-col rounded-2xl border p-1\">\n\t\t\t<div className=\"review-card-inner relative flex-1 overflow-hidden rounded-[12px] border px-4 pt-3.5 pb-4\">\n\t\t\t\t<span className=\"review-card-sheen\" aria-hidden=\"true\"></span>\n\t\t\t\t<div className=\"relative flex items-center gap-3\">\n\t\t\t\t\t<img\n\t\t\t\t\t\tsrc={img}\n\t\t\t\t\t\tclassName=\"review-card-avatar size-8 rounded-full\"\n\t\t\t\t\t\twidth=\"32\"\n\t\t\t\t\t\theight=\"32\"\n\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t/>\n\t\t\t\t\t<div className=\"flex min-w-0 flex-col leading-tight\">\n\t\t\t\t\t\t<span className=\"review-card-name truncate text-[13px] font-medium tracking-[-0.01em]\">\n\t\t\t\t\t\t\t{name}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span className=\"review-card-handle truncate text-[12px]\">{username}</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<blockquote className=\"review-card-body relative mt-3 text-[13px] leading-relaxed\">\n\t\t\t\t\t{body}\n\t\t\t\t</blockquote>\n\t\t\t</div>\n\t\t</figure>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/marquee/ReviewCard.tsx"},{"path":"react/src/components/marquee/index.ts","content":"\"use client\";\n\nexport { Marquee } from \"./Marquee\";\nexport type { MarqueeProps } from \"./Marquee\";\nexport { ReviewCard } from \"./ReviewCard\";\nexport type { ReviewCardProps } from \"./ReviewCard\";\n","type":"registry:ui","target":"@ui/fancy/components/marquee/index.ts"},{"path":"react/src/components/marquee/marquee.css","content":"/* Anchored on the source's root class `.marquee` (the Svelte compiler scoped\n   these rules; as plain CSS they would leak into any consumer element named\n   .animate-marquee). See PORTING.md, styling rule 2. */\n.marquee > .animate-marquee {\n\tanimation: marquee calc(var(--duration) / var(--marquee-speed, 1)) linear infinite;\n\twill-change: transform;\n}\n\n.marquee > .animate-marquee-vertical {\n\tanimation: marquee-vertical calc(var(--duration) / var(--marquee-speed, 1)) linear infinite;\n\twill-change: transform;\n}\n\n/* Script-owned animations have taken over: silence the CSS keyframe so the\n   track is not moved twice. */\n.marquee.marquee-upgraded > .animate-marquee,\n.marquee.marquee-upgraded > .animate-marquee-vertical {\n\tanimation: none;\n}\n\n/* Edge fades: an eased (not linear) alpha ramp, so items dissolve into the\n   edge instead of hitting a visible line. Width via --marquee-fade. */\n.marquee.marquee-fade {\n\t--_fade: var(--marquee-fade, 12%);\n\t--_mask: linear-gradient(\n\t\tto right,\n\t\ttransparent,\n\t\trgb(0 0 0 / 0.1) calc(var(--_fade) * 0.25),\n\t\trgb(0 0 0 / 0.4) calc(var(--_fade) * 0.5),\n\t\trgb(0 0 0 / 0.8) calc(var(--_fade) * 0.75),\n\t\t#000 var(--_fade),\n\t\t#000 calc(100% - var(--_fade)),\n\t\trgb(0 0 0 / 0.8) calc(100% - var(--_fade) * 0.75),\n\t\trgb(0 0 0 / 0.4) calc(100% - var(--_fade) * 0.5),\n\t\trgb(0 0 0 / 0.1) calc(100% - var(--_fade) * 0.25),\n\t\ttransparent\n\t);\n\t-webkit-mask-image: var(--_mask);\n\tmask-image: var(--_mask);\n}\n\n.marquee.marquee-fade.marquee-vertical {\n\t--_mask: linear-gradient(\n\t\tto bottom,\n\t\ttransparent,\n\t\trgb(0 0 0 / 0.1) calc(var(--_fade) * 0.25),\n\t\trgb(0 0 0 / 0.4) calc(var(--_fade) * 0.5),\n\t\trgb(0 0 0 / 0.8) calc(var(--_fade) * 0.75),\n\t\t#000 var(--_fade),\n\t\t#000 calc(100% - var(--_fade)),\n\t\trgb(0 0 0 / 0.8) calc(100% - var(--_fade) * 0.75),\n\t\trgb(0 0 0 / 0.4) calc(100% - var(--_fade) * 0.5),\n\t\trgb(0 0 0 / 0.1) calc(100% - var(--_fade) * 0.25),\n\t\ttransparent\n\t);\n}\n\n@keyframes marquee {\n\tfrom {\n\t\ttransform: translateX(0);\n\t}\n\tto {\n\t\ttransform: translateX(calc(-100% - var(--gap)));\n\t}\n}\n\n@keyframes marquee-vertical {\n\tfrom {\n\t\ttransform: translateY(0);\n\t}\n\tto {\n\t\ttransform: translateY(calc(-100% - var(--gap)));\n\t}\n}\n\n/* Reduced motion: a still row with the same composition — edge fades stay. */\n@media (prefers-reduced-motion: reduce) {\n\t.marquee > .animate-marquee,\n\t.marquee > .animate-marquee-vertical {\n\t\tanimation: none;\n\t\twill-change: auto;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/marquee/marquee.css"},{"path":"react/src/components/marquee/review-card.css","content":"/* Anchored on the source's root class `.review-card`. The Svelte compiler\n   scoped these rules; here every selector starts from that class. */\n.review-card {\n\t--_shell: var(--review-card-shell, #f4f4f5);\n\t--_panel: var(--review-card-panel, #fafafa);\n\t--_panel-hover: var(--review-card-panel-hover, #ffffff);\n\t--_line: var(--review-card-line, rgba(0, 0, 0, 0.08));\n\t--_ink: var(--review-card-ink, #18181b);\n\t--_muted: var(--review-card-muted, #71717a);\n\t--_body: var(--review-card-body, #52525b);\n\t--_lift: rgba(255, 255, 255, 1);\n\tbackground: var(--_shell);\n\tborder-color: var(--_line);\n\ttransition:\n\t\tborder-color 300ms cubic-bezier(0.4, 0, 0.2, 1),\n\t\tbackground-color 300ms cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.dark .review-card {\n\t--_shell: var(--review-card-shell, #0e0e10);\n\t--_panel: var(--review-card-panel, #141416);\n\t--_panel-hover: var(--review-card-panel-hover, #17171a);\n\t--_line: var(--review-card-line, rgba(255, 255, 255, 0.08));\n\t--_ink: var(--review-card-ink, #f4f4f5);\n\t--_muted: var(--review-card-muted, #71717a);\n\t--_body: var(--review-card-body, #a1a1aa);\n\t--_lift: rgba(255, 255, 255, 0.1);\n}\n\n.review-card .review-card-inner {\n\tbackground: var(--_panel);\n\tborder-color: color-mix(in oklab, var(--_line) 75%, transparent);\n\tbox-shadow: inset 0 1px 0 transparent;\n\ttransition:\n\t\tbox-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1),\n\t\tbackground-color 300ms cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n/* Hover: a quiet top-edge highlight, as if the card caught light from above. */\n.review-card:hover .review-card-inner {\n\tbackground: var(--_panel-hover);\n\tbox-shadow:\n\t\tinset 0 1px 0 var(--_lift),\n\t\t0 1px 2px rgba(0, 0, 0, 0.04),\n\t\t0 6px 16px -8px rgba(0, 0, 0, 0.1);\n}\n\n.review-card .review-card-sheen {\n\tposition: absolute;\n\tinset: 0 0 auto 0;\n\theight: 60%;\n\tpointer-events: none;\n\tbackground: radial-gradient(\n\t\t120% 90% at 50% 0%,\n\t\tcolor-mix(in oklab, var(--_lift) 60%, transparent),\n\t\ttransparent 70%\n\t);\n\topacity: 0;\n\ttransition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.review-card:hover .review-card-sheen {\n\topacity: 1;\n}\n\n.review-card .review-card-avatar {\n\tbackground: var(--_shell);\n\tbox-shadow:\n\t\t0 0 0 2px var(--_panel),\n\t\t0 0 0 3px var(--_line);\n}\n\n@media (prefers-reduced-motion: reduce) {\n\t.review-card,\n\t.review-card .review-card-inner,\n\t.review-card .review-card-sheen {\n\t\ttransition: none;\n\t}\n}\n\n.review-card .review-card-name {\n\tcolor: var(--_ink);\n}\n\n.review-card .review-card-handle {\n\tcolor: var(--_muted);\n}\n\n.review-card .review-card-body {\n\tcolor: var(--_body);\n}\n","type":"registry:ui","target":"@ui/fancy/components/marquee/review-card.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/marquee?framework=react"}}