Web InnoventixFreeCode

Vertical Testimonial Marquee

Original · free

Three 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.

byWeb InnoventixReact + Tailwind
testimonial marqueevertical scrolling testimonialsframer motion testimonialsreviews marqueeinfinite testimonial columnspause on hover testimonials
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/marquee-testimonial-columns.json
marquee-testimonial-columns.tsx
"use client";

import { motion } from "motion/react";

type Testimonial = {
  quote: string;
  name: string;
  role: string;
  initials: string;
  tint: string;
};

const testimonials: Testimonial[] = [
  {
    quote: "We replaced three separate tools in a single afternoon. The team barely noticed the switch, which is exactly what you want.",
    name: "Priya Nadar",
    role: "Head of Ops, Loomly",
    initials: "PN",
    tint: "from-indigo-500 to-violet-500",
  },
  {
    quote: "Support answered in four minutes at midnight. That kind of reliability is why we renewed for three years.",
    name: "Marcus Feld",
    role: "CTO, Palisade",
    initials: "MF",
    tint: "from-emerald-500 to-teal-500",
  },
  {
    quote: "Our reporting used to take a full day. Now the board gets numbers they trust before their first coffee.",
    name: "Ada Okafor",
    role: "CFO, Meridian",
    initials: "AO",
    tint: "from-rose-500 to-orange-500",
  },
  {
    quote: "Onboarding dropped from two weeks to two days. New hires ship real work in their first sprint.",
    name: "Liam Whitfield",
    role: "Eng Lead, Cadence",
    initials: "LW",
    tint: "from-sky-500 to-cyan-500",
  },
  {
    quote: "It just quietly works every single day. I have honestly forgotten the last time something broke on us.",
    name: "Sofia Delgado",
    role: "Founder, Brightfold",
    initials: "SD",
    tint: "from-amber-500 to-yellow-500",
  },
  {
    quote: "The migration I dreaded turned out to be the easiest project of the quarter. Zero downtime, no drama.",
    name: "Kenji Aoyama",
    role: "VP Product, Kestrel",
    initials: "KA",
    tint: "from-fuchsia-500 to-pink-500",
  },
];

function Stars() {
  return (
    <div className="flex items-center gap-0.5" aria-label="Rated 5 out of 5 stars">
      {[0, 1, 2, 3, 4].map((s) => (
        <svg key={s} viewBox="0 0 24 24" className="h-3.5 w-3.5 fill-amber-400" aria-hidden="true">
          <path d="M11.48 3.5a.56.56 0 011.04 0l2.12 5.11a.56.56 0 00.48.35l5.52.44c.5.04.7.66.32.99l-4.2 3.6a.56.56 0 00-.19.56l1.29 5.38a.56.56 0 01-.84.61l-4.73-2.88a.56.56 0 00-.58 0l-4.73 2.88a.56.56 0 01-.84-.61l1.29-5.38a.56.56 0 00-.19-.56l-4.2-3.6a.56.56 0 01.32-.99l5.52-.44a.56.56 0 00.48-.35L11.48 3.5z" />
        </svg>
      ))}
    </div>
  );
}

function Card({ t, dupe }: { t: Testimonial; dupe?: boolean }) {
  return (
    <figure
      aria-hidden={dupe ? true : undefined}
      className="group/card relative overflow-hidden rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-indigo-300 hover:shadow-lg dark:border-white/10 dark:bg-zinc-900/60 dark:hover:border-indigo-400/40"
    >
      <span className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/50 to-transparent transition-transform duration-700 group-hover/card:translate-x-full dark:via-white/10" />
      <div className="relative flex items-center justify-between">
        <Stars />
        <svg viewBox="0 0 24 24" className="h-6 w-6 fill-zinc-200 dark:fill-zinc-700" aria-hidden="true">
          <path d="M7 7h4v6a4 4 0 01-4 4v-2a2 2 0 002-2H7zm8 0h4v6a4 4 0 01-4 4v-2a2 2 0 002-2h-2z" />
        </svg>
      </div>
      <blockquote className="relative mt-3 text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
        {t.quote}
      </blockquote>
      <figcaption className="relative mt-4 flex items-center gap-3">
        <span className={`inline-flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br ${t.tint} text-xs font-bold text-white shadow-sm`}>
          {t.initials}
        </span>
        <span className="flex flex-col">
          <span className="text-sm font-semibold text-zinc-900 dark:text-white">{t.name}</span>
          <span className="text-xs text-zinc-500 dark:text-zinc-400">{t.role}</span>
        </span>
      </figcaption>
    </figure>
  );
}

function Column({
  items,
  dir,
  duration,
  className = "",
}: {
  items: Testimonial[];
  dir: "up" | "down";
  duration: number;
  className?: string;
}) {
  return (
    <div className={`group relative overflow-hidden ${className}`}>
      <div
        className={`tc-col tc-${dir} gap-4`}
        style={{ animationDuration: `${duration}s` }}
      >
        {[...items, ...items].map((t, i) => (
          <Card key={`${dir}-${i}`} t={t} dupe={i >= items.length} />
        ))}
      </div>
    </div>
  );
}

export default function MarqueeTestimonialColumns() {
  const colA = [testimonials[0], testimonials[3], testimonials[5]];
  const colB = [testimonials[1], testimonials[4], testimonials[2]];
  const colC = [testimonials[2], testimonials[0], testimonials[4]];

  return (
    <section className="relative overflow-hidden bg-zinc-50 px-6 py-20 dark:bg-zinc-950 md:py-28">
      <style>{`
        @keyframes tc-up { from { transform: translateY(0); } to { transform: translateY(-50%); } }
        @keyframes tc-down { from { transform: translateY(-50%); } to { transform: translateY(0); } }
        .tc-col { display: flex; flex-direction: column; width: 100%; animation-timing-function: linear; animation-iteration-count: infinite; will-change: transform; }
        .tc-up { animation-name: tc-up; }
        .tc-down { animation-name: tc-down; }
        .group:hover .tc-col { animation-play-state: paused; }
        @media (prefers-reduced-motion: reduce) {
          .tc-col { animation: none; }
        }
      `}</style>

      <div aria-hidden="true" className="pointer-events-none absolute -top-24 left-1/2 h-96 w-[42rem] -translate-x-1/2 rounded-full bg-gradient-to-br from-indigo-400/20 via-fuchsia-400/10 to-transparent blur-3xl dark:from-indigo-600/20 dark:via-fuchsia-600/10" />

      <div className="relative mx-auto grid max-w-6xl gap-12 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-10">
        <div className="lg:sticky lg:top-24 lg:self-start">
          <motion.span
            initial={{ opacity: 0, y: 14 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true, margin: "-80px" }}
            transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
            className="text-sm font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400"
          >
            Loved by builders
          </motion.span>
          <motion.h2
            initial={{ opacity: 0, y: 18 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true, margin: "-80px" }}
            transition={{ duration: 0.55, delay: 0.08, ease: [0.22, 1, 0.36, 1] }}
            className="mt-4 text-balance text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-white"
          >
            The reviews keep rolling in
          </motion.h2>
          <motion.p
            initial={{ opacity: 0, y: 18 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true, margin: "-80px" }}
            transition={{ duration: 0.55, delay: 0.16, ease: [0.22, 1, 0.36, 1] }}
            className="mt-4 text-lg text-zinc-600 dark:text-zinc-400"
          >
            Real words from teams who put us to work every day. Hover over any column to pause and read.
          </motion.p>
          <motion.div
            initial={{ opacity: 0, y: 18 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true, margin: "-80px" }}
            transition={{ duration: 0.55, delay: 0.24, ease: [0.22, 1, 0.36, 1] }}
            className="mt-8 flex items-center gap-4"
          >
            <div className="flex items-center gap-1">
              <span className="text-3xl font-bold text-zinc-900 dark:text-white">4.9</span>
              <Stars />
            </div>
            <span className="h-8 w-px bg-zinc-300 dark:bg-zinc-700" />
            <span className="text-sm text-zinc-600 dark:text-zinc-400">
              from <span className="font-semibold text-zinc-900 dark:text-white">2,400+</span> reviews
            </span>
          </motion.div>
        </div>

        <motion.div
          initial={{ opacity: 0, y: 30 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-60px" }}
          transition={{ duration: 0.6, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
          className="relative grid h-[34rem] grid-cols-1 gap-4 [-webkit-mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)] [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)] sm:grid-cols-2 lg:grid-cols-3"
          aria-label="Customer testimonials"
        >
          <Column items={colA} dir="up" duration={28} />
          <Column items={colB} dir="down" duration={34} />
          <Column items={colC} dir="up" duration={40} className="hidden lg:block" />
        </motion.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 →
Glowing Logo Marquee

Glowing Logo Marquee

Original

A 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.

Velocity Band Marquee

Velocity Band Marquee

Original

A 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.

Spotlight Hero

Spotlight Hero

Original

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

Split Hero

Split Hero

Original

A two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero

Gradient Spotlight Hero

Original

A minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero

App Preview Hero

Original

A centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero

Waitlist Capture Hero

Original

A dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

Image Backdrop Hero

Image Backdrop Hero

Original

A cinematic full-bleed hero with a layered image-style backdrop, overlaid left-aligned copy and a trusted-by logos strip.

Gradient Mesh Hero with Staggered Text Reveal

Gradient Mesh Hero with Staggered Text Reveal

Original

A full-screen hero pairing a drifting animated gradient-mesh backdrop with a word-by-word staggered blur-in headline and a logo marquee under the fold.

Typewriter Rotating Words Hero

Typewriter Rotating Words Hero

Original

A hero whose headline cycles through rotating words with a live typewriter caret over an animated grid backdrop, degrading to a gentle word swap when reduced motion is on.

Floating UI Cards Hero with Mouse Parallax

Floating UI Cards Hero with Mouse Parallax

Original

A hero with glassy floating UI cards that drift continuously and shift on mouse-parallax depth around a staggered centre headline.

Aurora Hero with Shimmer CTA and Logo Marquee

Aurora Hero with Shimmer CTA and Logo Marquee

Original

A dark hero with animated aurora light beams, a shimmering sweep across the primary CTA, and dual-direction logo marquees beneath the fold.