Web InnoventixFreeCode

Vertical Marquee

Original · free

vertical marquee columns

byWeb InnoventixReact + Tailwind
mqxverticalmarquees
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-vertical.json
mqx-vertical.tsx
"use client";

import {
  useCallback,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from "react";
import { useReducedMotion } from "motion/react";

type SpeedId = "slow" | "normal" | "fast";

type Speed = { id: SpeedId; label: string; mult: number };

type Testimonial = {
  id: string;
  quote: string;
  name: string;
  role: string;
  company: string;
  rating: number;
  tone: string;
};

const SPEEDS: readonly Speed[] = [
  { id: "slow", label: "Slow", mult: 1.7 },
  { id: "normal", label: "Normal", mult: 1 },
  { id: "fast", label: "Fast", mult: 0.55 },
] as const;

const AVATAR_TONES = [
  "bg-indigo-500/12 text-indigo-700 ring-indigo-500/25 dark:bg-indigo-400/15 dark:text-indigo-300",
  "bg-emerald-500/12 text-emerald-700 ring-emerald-500/25 dark:bg-emerald-400/15 dark:text-emerald-300",
  "bg-violet-500/12 text-violet-700 ring-violet-500/25 dark:bg-violet-400/15 dark:text-violet-300",
  "bg-amber-500/12 text-amber-700 ring-amber-500/25 dark:bg-amber-400/15 dark:text-amber-300",
  "bg-sky-500/12 text-sky-700 ring-sky-500/25 dark:bg-sky-400/15 dark:text-sky-300",
  "bg-rose-500/12 text-rose-700 ring-rose-500/25 dark:bg-rose-400/15 dark:text-rose-300",
] as const;

const COLUMN_A: Testimonial[] = [
  {
    id: "priya",
    quote:
      "We cut on-call pages by 63% in the first month. It finally tells the difference between a deploy blip and a real incident.",
    name: "Priya Nair",
    role: "Staff SRE",
    company: "Northwind Labs",
    rating: 5,
    tone: AVATAR_TONES[0],
  },
  {
    id: "diego",
    quote:
      "Traced a p99 regression to a single N+1 query in about nine minutes. That used to eat a whole afternoon.",
    name: "Diego Mendes",
    role: "Backend Lead",
    company: "Cartogram",
    rating: 5,
    tone: AVATAR_TONES[1],
  },
  {
    id: "hannah",
    quote:
      "The rollout diff view is the feature I didn't know I needed. Every deploy ships with a before/after I can actually read.",
    name: "Hannah Osei",
    role: "Platform Engineer",
    company: "Fielded",
    rating: 4,
    tone: AVATAR_TONES[2],
  },
  {
    id: "sam",
    quote:
      "Set up alerting on a Sunday, muted the noise by Tuesday. My phone stopped buzzing at 3am for things that don't matter.",
    name: "Sam Whitlock",
    role: "DevOps",
    company: "Junegrid",
    rating: 5,
    tone: AVATAR_TONES[3],
  },
];

const COLUMN_B: Testimonial[] = [
  {
    id: "aisha",
    quote:
      "Onboarding took one afternoon. By Friday the whole team was reviewing the same dashboards instead of arguing over Slack screenshots.",
    name: "Aisha Rahman",
    role: "Engineering Manager",
    company: "Postmark Row",
    rating: 5,
    tone: AVATAR_TONES[4],
  },
  {
    id: "lucas",
    quote:
      "Cardinality that doesn't bankrupt us. We kept every label we cared about and the monthly bill still went down.",
    name: "Lucas Byrne",
    role: "SRE",
    company: "Halcyon",
    rating: 5,
    tone: AVATAR_TONES[5],
  },
  {
    id: "mei",
    quote:
      "The query language reads like a sentence. I onboarded a junior in a day, not a quarter, and she was writing alerts by lunch.",
    name: "Mei Lin Chen",
    role: "Infra Lead",
    company: "Brightwater",
    rating: 4,
    tone: AVATAR_TONES[0],
  },
  {
    id: "tomas",
    quote:
      "We caught a memory leak in staging before it ever touched prod. That's the whole pitch, and it actually delivered.",
    name: "Tomás Herrera",
    role: "Staff Engineer",
    company: "Ledgerly",
    rating: 5,
    tone: AVATAR_TONES[1],
  },
];

const COLUMN_C: Testimonial[] = [
  {
    id: "nadia",
    quote:
      "Two vendors collapsed into one. Logs, metrics, and traces finally share a timeline I trust during an incident.",
    name: "Nadia Kovač",
    role: "Head of Platform",
    company: "Sparrow",
    rating: 5,
    tone: AVATAR_TONES[2],
  },
  {
    id: "ben",
    quote:
      "Incident review went from a wiki nobody read to a link everyone opens. The auto-generated timeline does the boring part.",
    name: "Ben Adeyemi",
    role: "Reliability Engineer",
    company: "Tidewater",
    rating: 5,
    tone: AVATAR_TONES[3],
  },
  {
    id: "sofia",
    quote:
      "I'm not an ops person and I still ship with confidence. The defaults are sane and the guardrails are real, not decorative.",
    name: "Sofia Marchetti",
    role: "Full-stack Engineer",
    company: "Quillpad",
    rating: 4,
    tone: AVATAR_TONES[4],
  },
  {
    id: "ryan",
    quote:
      "MTTR down 40% two quarters running. I put the number in the board deck and nobody questioned it.",
    name: "Ryan O'Neill",
    role: "CTO",
    company: "Meadowlark",
    rating: 5,
    tone: AVATAR_TONES[5],
  },
];

const COLUMNS: { items: Testimonial[]; dir: "up" | "down"; base: number }[] = [
  { items: COLUMN_A, dir: "up", base: 42 },
  { items: COLUMN_B, dir: "down", base: 52 },
  { items: COLUMN_C, dir: "up", base: 46 },
];

function initials(name: string): string {
  const parts = name.trim().split(/\s+/);
  const first = parts[0]?.[0] ?? "";
  const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
  return (first + last).toUpperCase();
}

function StarRow({ rating }: { rating: number }) {
  return (
    <span className="inline-flex items-center gap-0.5" role="img" aria-label={`${rating} out of 5 stars`}>
      {[0, 1, 2, 3, 4].map((i) => (
        <svg
          key={i}
          viewBox="0 0 20 20"
          className={
            i < rating
              ? "h-3.5 w-3.5 fill-amber-400 text-amber-400"
              : "h-3.5 w-3.5 fill-zinc-300 text-zinc-300 dark:fill-zinc-700 dark:text-zinc-700"
          }
          aria-hidden="true"
        >
          <path d="M10 1.5l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.1l-4.95 2.6.94-5.5-4-3.9 5.53-.8L10 1.5z" />
        </svg>
      ))}
    </span>
  );
}

function Card({ t }: { t: Testimonial }) {
  return (
    <figure className="group/card relative rounded-2xl border border-zinc-200/80 bg-white/90 p-5 shadow-sm backdrop-blur-sm transition duration-300 hover:-translate-y-0.5 hover:border-indigo-300/70 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/70 dark:hover:border-indigo-500/40">
      <div className="flex items-start justify-between gap-3">
        <StarRow rating={t.rating} />
        <svg
          viewBox="0 0 24 24"
          className="h-6 w-6 shrink-0 fill-indigo-500/15 text-indigo-500/15 transition-colors group-hover/card:fill-indigo-500/30 dark:fill-indigo-400/15 dark:group-hover/card:fill-indigo-400/25"
          aria-hidden="true"
        >
          <path d="M9.6 6C6.5 7.4 5 9.9 5 13.5V18h5.5v-5.5H8c0-2 .9-3.3 2.8-4.1L9.6 6zm9 0C15.5 7.4 14 9.9 14 13.5V18h5.5v-5.5H17c0-2 .9-3.3 2.8-4.1L18.6 6z" />
        </svg>
      </div>

      <blockquote className="mt-3 text-[0.95rem] leading-relaxed text-zinc-700 dark:text-zinc-200">
        {t.quote}
      </blockquote>

      <figcaption className="mt-4 flex items-center gap-3 border-t border-zinc-100 pt-4 dark:border-zinc-800/80">
        <span
          className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-semibold ring-1 ${t.tone}`}
          aria-hidden="true"
        >
          {initials(t.name)}
        </span>
        <span className="min-w-0">
          <span className="block truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
            {t.name}
          </span>
          <span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">
            {t.role} · {t.company}
          </span>
        </span>
      </figcaption>
    </figure>
  );
}

export default function MqxVertical() {
  const prefersReduced = useReducedMotion();
  const staticMode = prefersReduced === true;

  const [playing, setPlaying] = useState(true);
  const [speed, setSpeed] = useState<SpeedId>("normal");
  const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const mult = SPEEDS.find((s) => s.id === speed)?.mult ?? 1;

  const onRadioKeyDown = useCallback(
    (e: KeyboardEvent<HTMLButtonElement>) => {
      const cur = SPEEDS.findIndex((s) => s.id === speed);
      let next = cur;
      switch (e.key) {
        case "ArrowRight":
        case "ArrowDown":
          next = (cur + 1) % SPEEDS.length;
          break;
        case "ArrowLeft":
        case "ArrowUp":
          next = (cur - 1 + SPEEDS.length) % SPEEDS.length;
          break;
        case "Home":
          next = 0;
          break;
        case "End":
          next = SPEEDS.length - 1;
          break;
        default:
          return;
      }
      e.preventDefault();
      setSpeed(SPEEDS[next].id);
      radioRefs.current[next]?.focus();
    },
    [speed],
  );

  const regionId = useId();
  const rootPlaying = playing && !staticMode;

  return (
    <section
      className="mqxv-root relative w-full overflow-hidden bg-slate-50 py-20 dark:bg-zinc-950 sm:py-28"
      aria-labelledby={`${regionId}-title`}
      data-mqxv-playing={rootPlaying ? "true" : "false"}
    >
      <style>{`
        @keyframes mqxv-scroll {
          from { transform: translate3d(0, 0, 0); }
          to { transform: translate3d(0, -50%, 0); }
        }
        @keyframes mqxv-ping {
          0% { transform: scale(1); opacity: 0.7; }
          75%, 100% { transform: scale(2.2); opacity: 0; }
        }
        .mqxv-track {
          animation: mqxv-scroll var(--mqxv-dur, 44s) linear infinite;
          will-change: transform;
        }
        .mqxv-track[data-dir="down"] { animation-direction: reverse; }
        .mqxv-root[data-mqxv-playing="false"] .mqxv-track { animation-play-state: paused; }
        .mqxv-col:hover .mqxv-track,
        .mqxv-col:focus-within .mqxv-track { animation-play-state: paused; }
        .mqxv-fade {
          -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 11%, #000 89%, transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0, #000 11%, #000 89%, transparent 100%);
        }
        .mqxv-dot { animation: mqxv-ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .mqxv-track { animation: none !important; }
          .mqxv-dot { animation: none !important; }
        }
      `}</style>

      {/* atmospheric backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-70 [background:radial-gradient(60%_50%_at_50%_-5%,rgba(99,102,241,0.14),transparent_70%)] dark:opacity-100 dark:[background:radial-gradient(60%_50%_at_50%_-5%,rgba(99,102,241,0.18),transparent_70%)]"
      />

      <div className="relative mx-auto w-full max-w-6xl px-6">
        {/* header + controls */}
        <div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
          <div className="max-w-xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/70 px-3 py-1 text-xs font-medium text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-400">
              <span className="relative flex h-2 w-2">
                <span className="mqxv-dot absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
                <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
              </span>
              Verified reviews · updated weekly
            </span>

            <h2
              id={`${regionId}-title`}
              className="mt-4 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50 sm:text-4xl"
            >
              Signal, not noise — straight from the on-call rotation
            </h2>
            <p className="mt-3 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
              Real teams, real incidents. Here&apos;s what changed once the alerts
              started telling the truth.
            </p>
          </div>

          {/* controls */}
          <div className="flex shrink-0 flex-wrap items-center gap-3">
            {staticMode ? (
              <p className="max-w-[15rem] text-sm text-zinc-500 dark:text-zinc-400">
                Motion paused to match your system&apos;s reduced-motion setting.
                Scroll each column to read more.
              </p>
            ) : (
              <>
                <button
                  type="button"
                  onClick={() => setPlaying((p) => !p)}
                  aria-pressed={!playing}
                  aria-label={playing ? "Pause scrolling reviews" : "Play scrolling reviews"}
                  className="inline-flex items-center gap-2 rounded-full border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm transition hover:border-indigo-400 hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:border-indigo-500 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-950"
                >
                  {playing ? (
                    <svg viewBox="0 0 20 20" className="h-4 w-4 fill-current" aria-hidden="true">
                      <rect x="5" y="4" width="3.5" height="12" rx="1" />
                      <rect x="11.5" y="4" width="3.5" height="12" rx="1" />
                    </svg>
                  ) : (
                    <svg viewBox="0 0 20 20" className="h-4 w-4 fill-current" aria-hidden="true">
                      <path d="M6 4.5l9 5.5-9 5.5V4.5z" />
                    </svg>
                  )}
                  {playing ? "Pause" : "Play"}
                </button>

                <div
                  role="radiogroup"
                  aria-label="Scroll speed"
                  className="inline-flex items-center rounded-full border border-zinc-300 bg-white p-1 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
                >
                  {SPEEDS.map((s, i) => {
                    const active = s.id === speed;
                    return (
                      <button
                        key={s.id}
                        type="button"
                        role="radio"
                        aria-checked={active}
                        tabIndex={active ? 0 : -1}
                        ref={(el) => {
                          radioRefs.current[i] = el;
                        }}
                        onClick={() => setSpeed(s.id)}
                        onKeyDown={onRadioKeyDown}
                        className={`rounded-full px-3 py-1 text-sm font-medium transition 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-zinc-900 ${
                          active
                            ? "bg-indigo-600 text-white shadow-sm dark:bg-indigo-500"
                            : "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
                        }`}
                      >
                        {s.label}
                      </button>
                    );
                  })}
                </div>
              </>
            )}
          </div>
        </div>

        {/* marquee */}
        <div
          className="relative mt-12 grid h-[560px] grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-5 lg:grid-cols-3"
          role="region"
          aria-label="Customer testimonials"
        >
          {COLUMNS.map((col, ci) => {
            const dur = (col.base * mult).toFixed(1);
            const colStyle = { ["--mqxv-dur"]: `${dur}s` } as CSSProperties;
            const hideClass = ci === 1 ? "hidden sm:block" : ci === 2 ? "hidden lg:block" : "";

            return (
              <div
                key={ci}
                className={`mqxv-col mqxv-fade relative h-full overflow-hidden ${hideClass}`}
              >
                {staticMode ? (
                  <ul className="flex h-full flex-col gap-4 overflow-y-auto pr-1">
                    {col.items.map((t) => (
                      <li key={t.id}>
                        <Card t={t} />
                      </li>
                    ))}
                  </ul>
                ) : (
                  <ul className="mqxv-track flex flex-col" data-dir={col.dir} style={colStyle}>
                    {[0, 1].map((dup) =>
                      col.items.map((t) => (
                        <li key={`${t.id}-${dup}`} aria-hidden={dup === 1} className="pb-4 sm:pb-5">
                          <Card t={t} />
                        </li>
                      )),
                    )}
                  </ul>
                )}
              </div>
            );
          })}
        </div>

        {!staticMode && (
          <p className="mt-6 text-center text-xs text-zinc-400 dark:text-zinc-500">
            Hover a column to pause it — or use the controls above.
          </p>
        )}
      </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 →