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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/marquee.jsonPrompt
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-hiddenkeeps screen readers from hearing everything twice,inertkeeps 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 fromclassNamewithout touching structure, and JS never measures anything. - Alpha edge fade —
mask-imageerases 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-motionthe belt stops, drops the duplicate and the mask, and becomes a plain horizontally scrollable strip: same content, user-controlled.
Environment Switcher
A four-state environment switcher that tints the target you are pointed at, compares deploy versions as real semver so a stale preview says so, blocks what your role cannot enter with the reason, and gates production behind a typed confirm.
Comparison Slider
A draggable before/after slider — two aligned layers, one clip-path divider, works with any ReactNode.