Media

Marquee

A pure-CSS infinite scrolling belt for logo walls and testimonial strips — seamless loop, edge fade, hover pause.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

/** Keyframes ship via React 19 hoisted <style> — dedupe by href, no Tailwind config edits. */
const KEYFRAMES = `@keyframes zy-marquee{to{transform:translateX(-50%)}}`

export interface MarqueeProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Seconds per full loop (one content copy). Lower is faster. */
  speed?: number
  /** Which way the belt travels. */
  direction?: "left" | "right"
  /** Freeze the belt while the pointer is over it. */
  pauseOnHover?: boolean
  /** Fade both edges out with an alpha mask. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/marquee.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Marquee" component — a pure-CSS
infinite horizontal scrolling belt. No animation library, no JS state.

Contract
- export const Marquee = forwardRef<HTMLDivElement, MarqueeProps>;
  MarqueeProps extends React.HTMLAttributes<HTMLDivElement>, remaining props
  spread onto the root div.
- Props: children (the items — rendered twice internally), speed?: number = 30
  (seconds per full loop, lower is faster), direction?: "left" | "right" =
  "left", pauseOnHover?: boolean = true, fade?: boolean = true (alpha fade at
  both edges), className.
- No "use client": the component uses no hooks, events or browser APIs.

Behavior
- Render children twice inside a single flex track (flex w-max). The second
  copy is aria-hidden AND inert — it exists only to fill the loop, so it is
  never read by assistive tech and never focusable.
- Animate the track with one keyframe, translateX(0 → -50%), linear infinite;
  duration comes from a CSS variable (--marquee-duration) set via inline style
  from the speed prop. Ship the keyframe inside the component with a React 19
  hoisted <style href="..." precedence="medium"> tag (instances dedupe).
- Seamless-loop invariant (the classic trap): each copy is a flex group with
  gap var(--marquee-gap) between items AND padding-right of the same variable.
  The trailing padding makes copy width == one loop period, so -50% of the
  track lands exactly where the second copy started. Never put the gap on the
  track between the two copies — that offsets the wrap point by gap/2 and
  causes a visible jump.
- direction="right" adds animation-direction: reverse — same keyframe, still
  seamless.
- pauseOnHover: the root is a named group (group/marquee); the track gets
  group-hover/marquee:[animation-play-state:paused].
- The belt does not measure or auto-fill. Consumers must pass enough items
  (or repeat them) so one copy is at least as wide as the container.
- prefers-reduced-motion: animation removed, duplicate copy hidden, root swaps
  overflow-hidden for overflow-x-auto and drops the fade mask — the belt
  degrades to a static strip the user can scroll by hand; no content is lost.

Rendering & styling
- The component paints nothing itself; items keep their own styling. The only
  owned visual is the edge fade: mask-image: linear-gradient(to right,
  transparent, black var(--marquee-fade), black calc(100% -
  var(--marquee-fade)), transparent) with --marquee-fade defaulting to 10%.
  Masks affect alpha only — no color tokens involved, works over any surface
  in light and dark mode.
- Root: relative w-full overflow-hidden; merge consumer className via cn() so
  arbitrary-property classes can retune the CSS variables.
- Defaults exposed as CSS variables on the root: --marquee-gap: 1.5rem,
  --marquee-fade: 10%; --marquee-duration comes from the speed prop.

Customization levers
- Speed & direction: speed is seconds-per-loop (ambient logo walls read well
  at 20–40s; content you expect people to read, like quotes, should be 40s+);
  direction is a per-instance prop — no structural change.
- Gap density: override with className="[--marquee-gap:3rem]" — items and the
  loop math stay in sync automatically because both gap and trailing padding
  read the same variable.
- Fade width: className="[--marquee-fade:16%]" widens the edge fog; set the
  fade prop to false for hard edges (e.g. inside a bordered card).
- Vertical variant: switch the track to flex-col h-max, animate
  translateY(0 → -50%), swap padding-right for padding-bottom and point the
  mask gradient to bottom — the -50% + trailing-gap invariant is identical.
- Double-row combo: stack two Marquees with opposite directions and slightly
  different speeds (e.g. 28s and 36s) for a woven logo-wall effect.
- Interactive children: if items are links, keep pauseOnHover true so users
  can aim; the duplicate copy is already inert so tab order never doubles.

Concepts

  • Seamless −50% loop — the track holds two copies of the content, each carrying its own trailing gap, so translating the track by exactly half its width lands on a pixel-identical frame; the animation wraps with no visible seam.
  • Gap-in-the-period trap — putting the gap on the track between the copies breaks the −50% math by half a gap and produces a periodic jump; the fix is structural: every copy ends with padding equal to the item gap, both reading one CSS variable.
  • Duplicate honesty — the second copy is pure loop filler: aria-hidden keeps screen readers from hearing everything twice, inert keeps links inside it out of the tab order.
  • CSS-variable contract — speed, gap and fade width all flow through --marquee-* variables, so consumers retune the belt from className without touching structure, and JS never measures anything.
  • Alpha edge fademask-image erases pixels by alpha rather than painting a background-colored gradient over them, so the fade is theme-agnostic and works over images, gradients or any surface.
  • Reduced-motion fallback — under prefers-reduced-motion the belt stops, drops the duplicate and the mask, and becomes a plain horizontally scrollable strip: same content, user-controlled.

On This Page