Text Marquee
Original · freescrolling text marquee
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/mqx-text.json"use client";
import { useId, useState, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";
type Direction = "normal" | "reverse";
type Variant = "solid" | "outline" | "chip";
interface BandConfig {
id: string;
label: string;
variant: Variant;
baseDuration: number;
dir: Direction;
items: string[];
}
interface BandProps {
band: BandConfig;
reduced: boolean;
playing: boolean;
speed: number;
reversed: boolean;
}
const BANDS: BandConfig[] = [
{
id: "capabilities",
label: "What the studio makes",
variant: "solid",
baseDuration: 40,
dir: "normal",
items: [
"Brand Systems",
"Product Design",
"Design Engineering",
"Motion & Interaction",
"Editorial Direction",
"Prototyping",
"Art Direction",
"Design Ops",
],
},
{
id: "stack",
label: "Tools the studio builds with",
variant: "outline",
baseDuration: 30,
dir: "reverse",
items: [
"TypeScript",
"React",
"WebGL",
"GLSL",
"Figma",
"Framer Motion",
"Rive",
"Storybook",
"Radix",
"Vite",
"Playwright",
"Vitest",
],
},
{
id: "principles",
label: "How the studio works",
variant: "chip",
baseDuration: 46,
dir: "normal",
items: [
"Ship weekly",
"Details compound",
"Type-safe by default",
"Accessible from line one",
"Performance is a feature",
"Measure, then decide",
"Craft over trend",
"Design in the browser",
],
},
];
const EDGE_MASK =
"linear-gradient(to right, transparent 0%, #000 7%, #000 93%, transparent 100%)";
const MASK_STYLE: CSSProperties = {
WebkitMaskImage: EDGE_MASK,
maskImage: EDGE_MASK,
};
const MQX_CSS = `
@keyframes mqx-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.mqx-track {
animation-name: mqx-scroll;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
.mqx-root[data-mqx-hoverpause="true"] .mqx-row:hover .mqx-track {
animation-play-state: paused !important;
}
@media (prefers-reduced-motion: reduce) {
.mqx-track {
animation: none !important;
transform: none !important;
}
}
`;
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true" fill="currentColor">
<path d="M7 5h3v14H7zM14 5h3v14h-3z" />
</svg>
);
}
function SwapIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7 7h11l-3-3M17 17H6l3 3" />
</svg>
);
}
function Diamond() {
return (
<svg
viewBox="0 0 12 12"
className="mx-6 h-2.5 w-2.5 shrink-0 text-indigo-500 dark:text-indigo-400"
aria-hidden="true"
fill="currentColor"
>
<path d="M6 0l6 6-6 6-6-6z" />
</svg>
);
}
function Spark() {
return (
<svg
viewBox="0 0 24 24"
className="mx-5 h-4 w-4 shrink-0 text-violet-500 dark:text-violet-400"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
>
<path d="M12 3v18M3 12h18M6 6l12 12M18 6L6 18" />
</svg>
);
}
function Dot() {
return (
<svg
viewBox="0 0 8 8"
className="mr-2 h-1.5 w-1.5 shrink-0 text-emerald-500 dark:text-emerald-400"
aria-hidden="true"
fill="currentColor"
>
<circle cx="4" cy="4" r="4" />
</svg>
);
}
function Sequence({
band,
hidden,
wrap,
}: {
band: BandConfig;
hidden: boolean;
wrap: boolean;
}) {
return (
<ul
className={
"flex items-center " +
(wrap ? "flex-wrap justify-center gap-x-1 gap-y-3" : "shrink-0")
}
aria-hidden={hidden || undefined}
>
{band.items.map((item, i) => (
<li
key={i}
className={band.variant === "chip" ? "pr-3" : "flex items-center"}
>
{band.variant === "solid" && (
<>
<span className="whitespace-nowrap px-6 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl md:text-5xl dark:text-neutral-50">
{item}
</span>
<Diamond />
</>
)}
{band.variant === "outline" && (
<>
<span
className="whitespace-nowrap px-5 text-2xl font-extrabold uppercase tracking-tight text-neutral-700 sm:text-3xl md:text-4xl dark:text-neutral-300"
style={{
WebkitTextStroke: "1.2px currentColor",
WebkitTextFillColor: "transparent",
}}
>
{item}
</span>
<Spark />
</>
)}
{band.variant === "chip" && (
<span className="inline-flex items-center whitespace-nowrap rounded-full border border-neutral-300 bg-white/70 px-4 py-2 text-sm font-medium text-neutral-700 shadow-sm backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/70 dark:text-neutral-200">
<Dot />
{item}
</span>
)}
</li>
))}
</ul>
);
}
function Band({ band, reduced, playing, speed, reversed }: BandProps) {
const effectiveDir: Direction = reversed
? band.dir === "normal"
? "reverse"
: "normal"
: band.dir;
const trackStyle: CSSProperties = {
animationDuration: `${(band.baseDuration / speed).toFixed(2)}s`,
animationDirection: effectiveDir,
animationPlayState: playing ? "running" : "paused",
};
return (
<div role="group" aria-label={band.label} className="mqx-row py-5 sm:py-6">
<div className="overflow-hidden" style={MASK_STYLE}>
{reduced ? (
<div className="px-6">
<Sequence band={band} hidden={false} wrap />
</div>
) : (
<div
className="mqx-track flex w-max flex-nowrap items-center"
style={trackStyle}
>
<Sequence band={band} hidden={false} wrap={false} />
<Sequence band={band} hidden wrap={false} />
</div>
)}
</div>
</div>
);
}
export default function MqxTextMarquee() {
const prefersReduced = useReducedMotion();
const reduced = prefersReduced === true;
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState(1);
const [reversed, setReversed] = useState(false);
const [pauseOnHover, setPauseOnHover] = useState(true);
const speedId = useId();
const disabled = reduced;
const statusText = reduced
? "Reduced motion is on — the ticker is shown as a static list."
: `${playing ? "Scrolling" : "Paused"} at ${speed.toFixed(1)}× · ${
reversed ? "reversed flow" : "default flow"
}${pauseOnHover ? " · pauses on hover" : ""}`;
const ringBase =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900";
return (
<section className="relative w-full overflow-hidden bg-neutral-50 py-20 sm:py-28 dark:bg-neutral-950">
<style>{MQX_CSS}</style>
<div
className="mqx-root relative"
data-mqx-hoverpause={pauseOnHover && !reduced ? "true" : "false"}
>
<motion.div
initial={reduced ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="mx-auto max-w-6xl px-6"
>
<span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-neutral-600 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/70 dark:text-neutral-300">
<span
className={
"h-1.5 w-1.5 rounded-full bg-emerald-500 " +
(reduced ? "" : "animate-pulse")
}
/>
Studio marquee
</span>
<h2 className="mt-5 text-balance text-4xl font-semibold tracking-tight text-neutral-900 sm:text-5xl md:text-6xl dark:text-neutral-50">
Everything we make, always in motion
</h2>
<p className="mt-4 max-w-2xl text-lg leading-relaxed text-neutral-600 dark:text-neutral-400">
A live feed of the studio’s capabilities, tooling, and working
principles. Play it, reverse it, or slow it down — the controls are
yours.
</p>
<div className="mt-8 flex flex-col gap-4 rounded-2xl border border-neutral-200 bg-white/70 p-4 shadow-sm backdrop-blur sm:flex-row sm:items-center sm:justify-between dark:border-neutral-800 dark:bg-neutral-900/60">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
disabled={disabled}
onClick={() => setPlaying((p) => !p)}
className={
"inline-flex items-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200 " +
ringBase
}
>
{playing ? <PauseIcon /> : <PlayIcon />}
<span>{playing ? "Pause" : "Play"}</span>
</button>
<button
type="button"
aria-pressed={reversed}
disabled={disabled}
onClick={() => setReversed((v) => !v)}
className={
"inline-flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50 " +
ringBase +
" " +
(reversed
? "border-indigo-500 bg-indigo-500/10 text-indigo-700 dark:border-indigo-400 dark:bg-indigo-400/10 dark:text-indigo-300"
: "border-neutral-300 bg-white text-neutral-700 hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800")
}
>
<SwapIcon />
Reverse
</button>
<button
type="button"
role="switch"
aria-checked={pauseOnHover}
disabled={disabled}
onClick={() => setPauseOnHover((v) => !v)}
className={
"inline-flex items-center gap-2.5 rounded-xl border border-neutral-300 bg-white px-3.5 py-2.5 text-sm font-semibold text-neutral-700 transition hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800 " +
ringBase
}
>
<span
className={
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors " +
(pauseOnHover
? "bg-indigo-500"
: "bg-neutral-300 dark:bg-neutral-600")
}
>
<span
className={
"inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform " +
(pauseOnHover ? "translate-x-4" : "translate-x-0.5")
}
/>
</span>
Pause on hover
</button>
</div>
<div className="flex items-center gap-3">
<label
htmlFor={speedId}
className="text-sm font-medium text-neutral-600 dark:text-neutral-300"
>
Speed
</label>
<input
id={speedId}
type="range"
min={0.4}
max={2.4}
step={0.1}
value={speed}
disabled={disabled}
onChange={(e) => setSpeed(Number(e.currentTarget.value))}
aria-valuetext={`${speed.toFixed(1)} times normal speed`}
className={
"h-2 w-36 cursor-pointer accent-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 " +
ringBase
}
/>
<span
className="w-9 text-right text-sm font-semibold tabular-nums text-neutral-900 dark:text-neutral-100"
aria-hidden="true"
>
{speed.toFixed(1)}×
</span>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-2">
<p
aria-live="polite"
className="text-sm text-neutral-500 dark:text-neutral-400"
>
{statusText}
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">
Tab to controls · Space toggles · Arrow keys adjust speed
</p>
</div>
</motion.div>
<div className="mt-12 w-full border-y border-neutral-200 dark:border-neutral-800">
<div className="divide-y divide-neutral-200 dark:divide-neutral-800">
{BANDS.map((band) => (
<Band
key={band.id}
band={band}
reduced={reduced}
playing={playing}
speed={speed}
reversed={reversed}
/>
))}
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Glowing Logo Marquee
OriginalA dual-row infinite logo marquee whose rows scroll in opposite directions, pause on hover and lift each logo with a shimmer sweep over an animated gradient glow.

Vertical Testimonial Marquee
OriginalThree vertical testimonial columns scroll at different speeds and opposite directions, pausing on hover with a shine sweep so reviews loop endlessly beside a sticky heading.

Velocity Band Marquee
OriginalA skewed dual-direction headline marquee of big gradient and outlined words plus a pill ticker, with speed and pause controls and pause on hover over an animated mesh background.

Logos Marquee
Originallogo marquee (generic marks)

Cards Marquee
Originalcard marquee

Vertical Marquee
Originalvertical marquee columns

Gradient Fade Marquee
Originalmarquee with edge fade

Reverse Rows Marquee
Originaltwo rows scrolling opposite

Tilted Marquee
Originaltilted marquee band

Image Marquee
Originalimage marquee

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

