Web InnoventixFreeCode

Reverse Rows Marquee

Original · free

two rows scrolling opposite

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

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

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

type SpeedOption = {
  id: string;
  label: string;
  multiplier: number;
};

const ROW_A: Testimonial[] = [
  {
    quote:
      "We shipped the redesign in three weeks and watched signups climb 41% the month after. The team just knows how to keep momentum.",
    name: "Priya Nandakumar",
    role: "Head of Growth, Loomwork",
    rating: 5,
  },
  {
    quote:
      "Support tickets asking 'where do I click' basically vanished. That was the clearest signal our navigation finally made sense.",
    name: "Marcus Feld",
    role: "Product Lead, Northbeam",
    rating: 5,
  },
  {
    quote:
      "They rebuilt our onboarding and cut the drop-off between step two and three almost in half. The numbers held for a full quarter.",
    name: "Elena Vasquez",
    role: "VP Product, Cadence",
    rating: 5,
  },
  {
    quote:
      "Fastest handoff I've had with any studio. Design tokens, docs, and a recorded walkthrough were waiting for me on day one.",
    name: "Devon Park",
    role: "Staff Engineer, Riverstone",
    rating: 5,
  },
  {
    quote:
      "Our marketing site went from a 62 to a 98 on Lighthouse. Sales stopped apologizing for the load time on every demo call.",
    name: "Aisha Rahman",
    role: "CMO, Fielded",
    rating: 5,
  },
  {
    quote:
      "The motion work is restrained in the best way. It guides the eye without ever getting in the way of the actual task.",
    name: "Tomas Berg",
    role: "Design Director, Halo Labs",
    rating: 4,
  },
];

const ROW_B: Testimonial[] = [
  {
    quote:
      "Migrated 400 pages with zero broken links and a redirect map that actually made sense when I opened it six months later.",
    name: "Owen Cassidy",
    role: "Platform Engineer, Brightseed",
    rating: 5,
  },
  {
    quote:
      "Accessibility went from an afterthought to a passing audit. Keyboard navigation works everywhere now, not just the happy path.",
    name: "Naomi Oduya",
    role: "Accessibility Consultant, Openfield",
    rating: 5,
  },
  {
    quote:
      "The component library paid for itself by the second sprint. New features drop in without a full design review every time.",
    name: "Rafael Mendes",
    role: "Engineering Manager, Slate & Co",
    rating: 5,
  },
  {
    quote:
      "They caught a layout-shift bug our own team had been chasing for a month, then fixed it in a single afternoon.",
    name: "Hannah Sorensen",
    role: "Senior Developer, Kestrel",
    rating: 5,
  },
  {
    quote:
      "Conversion on the pricing page moved 18% after the copy and layout pass. A small change that turned into real revenue.",
    name: "Julian Wei",
    role: "Founder, Tidepool",
    rating: 5,
  },
  {
    quote:
      "Clear communication, honest timelines, and no surprises on the invoice. Rarer than it should be, and deeply appreciated.",
    name: "Bianca Rossi",
    role: "Operations Director, Vantage",
    rating: 4,
  },
];

const SPEEDS: SpeedOption[] = [
  { id: "slow", label: "0.5×", multiplier: 1.9 },
  { id: "normal", label: "1×", multiplier: 1 },
  { id: "fast", label: "1.5×", multiplier: 0.6 },
];

function initialsOf(name: string): string {
  return name
    .split(" ")
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase() ?? "")
    .join("");
}

function StarRating({ 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"
              : "h-3.5 w-3.5 fill-slate-300 dark:fill-slate-700"
          }
          aria-hidden="true"
        >
          <path d="M10 1.6l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.2l-4.94 2.6.94-5.5-4-3.9 5.53-.8z" />
        </svg>
      ))}
    </span>
  );
}

function QuoteGlyph() {
  return (
    <svg
      viewBox="0 0 32 32"
      className="h-7 w-7 text-indigo-400 dark:text-indigo-500"
      aria-hidden="true"
      fill="currentColor"
    >
      <path d="M12.5 8c-3.6 1.5-6.2 4.9-6.2 9.3 0 3.3 2 5.7 4.9 5.7 2.5 0 4.3-1.9 4.3-4.3 0-2.4-1.7-4.1-3.9-4.1-.4 0-1 .1-1.1.1.4-1.9 2.2-4.1 4.4-5.2L12.5 8zm11.6 0c-3.6 1.5-6.2 4.9-6.2 9.3 0 3.3 2 5.7 4.9 5.7 2.5 0 4.3-1.9 4.3-4.3 0-2.4-1.7-4.1-3.9-4.1-.4 0-1 .1-1.1.1.4-1.9 2.2-4.1 4.4-5.2L24.1 8z" />
    </svg>
  );
}

function Card({ t, duplicate }: { t: Testimonial; duplicate?: boolean }) {
  return (
    <li
      aria-hidden={duplicate || undefined}
      className="mr-5 w-[300px] shrink-0 sm:mr-6 sm:w-[380px]"
    >
      <figure className="flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
        <div className="flex items-center justify-between">
          <QuoteGlyph />
          <StarRating rating={t.rating} />
        </div>
        <blockquote className="mt-4 flex-1 text-[15px] leading-relaxed text-slate-700 dark:text-slate-200">
          {t.quote}
        </blockquote>
        <figcaption className="mt-6 flex items-center gap-3">
          <span
            aria-hidden="true"
            className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 text-xs font-semibold text-white"
          >
            {initialsOf(t.name)}
          </span>
          <span className="min-w-0">
            <span className="block truncate text-sm font-semibold text-slate-900 dark:text-white">
              {t.name}
            </span>
            <span className="block truncate text-xs text-slate-500 dark:text-slate-400">
              {t.role}
            </span>
          </span>
        </figcaption>
      </figure>
    </li>
  );
}

const EDGE_MASK: CSSProperties = {
  WebkitMaskImage:
    "linear-gradient(to right, transparent, #000 7%, #000 93%, transparent)",
  maskImage:
    "linear-gradient(to right, transparent, #000 7%, #000 93%, transparent)",
};

export default function MqxReverseRows() {
  const reduced = useReducedMotion();
  const [isPlaying, setIsPlaying] = useState(true);
  const [swapped, setSwapped] = useState(false);
  const [speedIndex, setSpeedIndex] = useState(1);
  const [hovered, setHovered] = useState<"a" | "b" | null>(null);
  const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const groupId = useId();

  const speed = SPEEDS[speedIndex];
  const running = isPlaying && !reduced;

  const rowStyle = (base: number, dir: "left" | "right"): CSSProperties => ({
    animationName: dir === "left" ? "mqxRR-scroll-left" : "mqxRR-scroll-right",
    animationDuration: `${(base * speed.multiplier).toFixed(2)}s`,
    animationTimingFunction: "linear",
    animationIterationCount: "infinite",
  });

  const dirA: "left" | "right" = swapped ? "right" : "left";
  const dirB: "left" | "right" = swapped ? "left" : "right";
  const stateA = running && hovered !== "a" ? "running" : "paused";
  const stateB = running && hovered !== "b" ? "running" : "paused";

  const onSpeedKeys = (e: KeyboardEvent<HTMLDivElement>) => {
    const last = SPEEDS.length - 1;
    let next = speedIndex;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      next = speedIndex === last ? 0 : speedIndex + 1;
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      next = speedIndex === 0 ? last : speedIndex - 1;
    } else if (e.key === "Home") {
      next = 0;
    } else if (e.key === "End") {
      next = last;
    } else {
      return;
    }
    e.preventDefault();
    setSpeedIndex(next);
    radioRefs.current[next]?.focus();
  };

  const ringBase =
    "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:focus-visible:ring-offset-slate-950";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 py-20 sm:py-28 dark:bg-slate-950">
      <style>{`
        @keyframes mqxRR-scroll-left {
          from { transform: translate3d(0, 0, 0); }
          to { transform: translate3d(-50%, 0, 0); }
        }
        @keyframes mqxRR-scroll-right {
          from { transform: translate3d(-50%, 0, 0); }
          to { transform: translate3d(0, 0, 0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .mqxRR-track { animation: none !important; transform: none !important; }
        }
      `}</style>

      <div className="mx-auto mb-12 max-w-6xl px-4 sm:mb-14 sm:px-6">
        <div className="flex flex-col items-start gap-6 lg:flex-row lg:items-end lg:justify-between">
          <div className="max-w-2xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
              Wall of love
            </span>
            <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
              What teams say after we ship
            </h2>
            <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
              Real words from the people who launched with us — two rows
              running in opposite directions so every voice gets its moment.
            </p>
          </div>

          <div className="flex flex-wrap items-center gap-3">
            <button
              type="button"
              onClick={() => setIsPlaying((p) => !p)}
              aria-label={isPlaying ? "Pause the marquee" : "Play the marquee"}
              className={`inline-flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 ${ringBase}`}
            >
              {isPlaying ? (
                <svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" 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="currentColor" aria-hidden="true">
                  <path d="M6 4.5l9 5.5-9 5.5z" />
                </svg>
              )}
              {isPlaying ? "Pause" : "Play"}
            </button>

            <button
              type="button"
              onClick={() => setSwapped((s) => !s)}
              aria-pressed={swapped}
              className={`inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:border-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-500 ${ringBase}`}
            >
              <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
                <path d="M4 7h10l-2.5-2.5M16 13H6l2.5 2.5" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              Swap direction
            </button>

            <div
              role="radiogroup"
              aria-label="Scroll speed"
              onKeyDown={onSpeedKeys}
              className="inline-flex items-center rounded-full bg-slate-200/70 p-1 dark:bg-slate-800/70"
            >
              {SPEEDS.map((opt, i) => {
                const active = i === speedIndex;
                return (
                  <button
                    key={opt.id}
                    ref={(el) => {
                      radioRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    tabIndex={active ? 0 : -1}
                    id={`${groupId}-${opt.id}`}
                    onClick={() => setSpeedIndex(i)}
                    className={`rounded-full px-3 py-1.5 text-xs font-semibold transition-colors ${ringBase} ${
                      active
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
                    }`}
                  >
                    {opt.label}
                  </button>
                );
              })}
            </div>
          </div>
        </div>

        <p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
          Hover a row to pause it. Motion respects your reduced-motion setting.
        </p>
        <span className="sr-only" role="status" aria-live="polite">
          {`Marquee ${isPlaying ? "playing" : "paused"} at ${speed.label} speed, ${
            swapped ? "directions swapped" : "top row moving left"
          }.`}
        </span>
      </div>

      <div className="flex flex-col gap-5 sm:gap-6" style={EDGE_MASK}>
        <div
          role="group"
          aria-label="Customer testimonials, first row"
          className="overflow-hidden"
          onMouseEnter={() => setHovered("a")}
          onMouseLeave={() => setHovered((h) => (h === "a" ? null : h))}
        >
          <ul
            className="mqxRR-track flex w-max list-none will-change-transform"
            style={{ ...rowStyle(46, dirA), animationPlayState: stateA }}
          >
            {ROW_A.map((t, i) => (
              <Card key={`a-${i}`} t={t} />
            ))}
            {ROW_A.map((t, i) => (
              <Card key={`a-dup-${i}`} t={t} duplicate />
            ))}
          </ul>
        </div>

        <div
          role="group"
          aria-label="Customer testimonials, second row"
          className="overflow-hidden"
          onMouseEnter={() => setHovered("b")}
          onMouseLeave={() => setHovered((h) => (h === "b" ? null : h))}
        >
          <ul
            className="mqxRR-track flex w-max list-none will-change-transform"
            style={{ ...rowStyle(54, dirB), animationPlayState: stateB }}
          >
            {ROW_B.map((t, i) => (
              <Card key={`b-${i}`} t={t} />
            ))}
            {ROW_B.map((t, i) => (
              <Card key={`b-dup-${i}`} t={t} duplicate />
            ))}
          </ul>
        </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 →