Web InnoventixFreeCode

Text Marquee

Original · free

scrolling text marquee

byWeb InnoventixReact + Tailwind
mqxtextmarquees
Open

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
mqx-text.tsx
"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&rsquo;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

motion

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 quote

Similar components

Browse all →