Testimonial Wall — Free HTML CSS JS Marquee Snippet

Testimonial Wall · Cards · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Seamless infinite loop
Duplicated card sets wrap with no visible seam.
Delta-timed animation
rAF with dt keeps speed constant across refresh rates.
Per-column speeds
data-speed drives the layered, organic drift.
Alternating directions
Two-way wrap logic supports up and down scroll.
CSS mask edge fade
Cards fade at the top and bottom with no overlays.
Pause on hover
Stop a column to read, then resume in place.
Data-driven cards
One array with auto-derived avatar initials.
Responsive columns
Drops to two then one column on small screens.

About this UI Snippet

Testimonial Wall — Infinite Scrolling Social-Proof Columns

Screenshot of the Testimonial Wall snippet rendered live

The testimonial wall is the social-proof section every modern landing page reaches for: multiple columns of review cards that scroll vertically and endlessly, each column drifting at a slightly different speed so the motion feels organic rather than mechanical. This snippet builds the whole effect — seamless looping, edge fades, alternating directions, and pause-on-hover — in plain HTML, CSS, and vanilla JavaScript with no animation library.

Seamless infinite loop

The trick to an endless scroll is duplication. Each column renders its set of cards twice (col.innerHTML = html + html), and the animation translates the column upward. The instant the offset reaches exactly one set's height — measured as scrollHeight / 2 — the position wraps by adding that height back, which is visually identical because the second set is a perfect copy of the first. The viewer never sees a seam or a reset jump; the cards appear to flow forever.

A real-time animation loop

Rather than a fixed CSS keyframe, the motion runs on requestAnimationFrame with delta timing. Each frame computes dt, the seconds since the previous frame, and advances the position by speed * dt. Because it is time-based instead of frame-based, the scroll runs at the same real-world velocity whether the display is 60Hz or 120Hz, and a dropped frame doesn't cause a stutter in distance traveled. Each column reads its own data-speed (in pixels per second), so the three columns drift at 38, 50, and 44 px/s for that layered, living feel.

Alternating directions

The middle column scrolls the opposite way from its neighbors via an offset of -1 versus 1. The wrap logic handles both: when scrolling up, the position resets after passing -half; when scrolling down, it resets after crossing 0. This two-way wrap means a single loop body supports both directions without special-casing.

Edge fade with a mask

To avoid hard cut-offs where cards appear and disappear, the wall uses a CSS mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent). The mask fades the top and bottom 12% of the container to transparent, so cards melt in and out at the edges instead of popping. This is a pure compositing effect — no extra overlay elements and no impact on layout.

Pause on hover

Each column listens for mouseenter and mouseleave to set a paused flag. While paused, the animation loop keeps running but skips the position update, so a visitor can stop a column and actually read a testimonial — then it resumes exactly where it left off. This is the small touch that turns a decorative marquee into something usable.

Data-driven cards

All testimonials live in one DATA array of quote, name, role, and accent color. A cardHTML function builds each card, deriving the avatar's initials from the name automatically. Each column gets a rotated slice of the array so the three columns don't show the same quotes in the same order. To wire it to real reviews, replace DATA with your API response — the rendering and looping need no changes.

Customizing it

Adjust the data-speed values for faster or calmer motion, change the mask percentages to widen or tighten the fade, add a fourth column on wide screens, or hide the third column on tablets (the snippet already drops to two columns under 760px and one under 480px). Pair it with a rating breakdown summary or a logo marquee for a complete social-proof block.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSThree columns of review cards scroll vertically and endlessly.
  2. 2
    Watch the layered motionEach column drifts at a different speed and the middle one reverses.
  3. 3
    Note the edge fadesCards melt in and out at the top and bottom via a CSS mask.
  4. 4
    Hover a columnIt pauses so you can read a testimonial, then resumes.
  5. 5
    Swap in real reviewsReplace the DATA array with your own quotes and avatars.
  6. 6
    Tune speeds and fadeEdit data-speed and the mask percentages to taste.

Real-world uses

Common Use Cases

Landing-page social proof
Anchor it below a feature tabs showcase.
SaaS homepages
Pair with a rating breakdown summary card.
Agency sites
Follow a logo marquee of client brands.
Product pages
Complement a static testimonial masonry grid.
App marketing
Reinforce a testimonial slider above it.
Marquee learning
A reference for seamless rAF-based infinite scroll.

Got questions?

Frequently Asked Questions

Each column renders its cards twice and scrolls by translateY. When the offset reaches one set's height (scrollHeight / 2), the position wraps by adding that height back. Because the second set is an exact copy of the first, the reset lands on an identical frame, so the loop is seamless.

The rAF loop uses delta timing — it advances by speed times the elapsed seconds each frame — so the velocity is identical on 60Hz and 120Hz displays and survives dropped frames. It also makes pause-on-hover trivial: the loop keeps running but skips the position update while a paused flag is set.

The wall has a CSS mask-image set to a vertical linear-gradient that is transparent at the very top and bottom and opaque in the middle. The mask hides the top and bottom 12% of pixels, so cards fade in and out at the edges with no extra overlay elements and no effect on layout.

Yes. All content lives in the DATA array of quote, name, role, and color. The cardHTML function derives avatar initials from the name automatically. Replace DATA with your reviews API response and the duplication, looping, and masking all keep working unchanged.

Render the columns from your data and keep the rAF loop in a mount effect, storing the animation frame id so you can cancel it on unmount (useEffect cleanup in React, onUnmounted in Vue, ngOnDestroy in Angular). Use refs for the column elements rather than getElementById. The CSS — including the mask-image — ports directly, and in Tailwind the fade can use [mask-image:...] arbitrary values.

Testimonial Wall — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Testimonial Wall</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#e7e7ef}
    
    .tw-section{max-width:1040px;margin:0 auto;padding:48px 18px}
    .tw-head{text-align:center;font-size:clamp(24px,5vw,38px);font-weight:800;letter-spacing:-.02em;margin-bottom:34px;background:linear-gradient(120deg,#fff,#a78bfa);-webkit-background-clip:text;background-clip:text;color:transparent}
    
    .tw-wall{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;height:560px;overflow:hidden;position:relative;-webkit-mask-image:linear-gradient(180deg,transparent,#000 12%,#000 88%,transparent);mask-image:linear-gradient(180deg,transparent,#000 12%,#000 88%,transparent)}
    .tw-col{display:flex;flex-direction:column;gap:16px;will-change:transform}
    
    .tw-card{background:#15151f;border:1px solid #26263a;border-radius:14px;padding:17px;flex-shrink:0}
    .tw-quote{font-size:13.5px;line-height:1.6;color:#cfcfe0}
    .tw-who{display:flex;align-items:center;gap:10px;margin-top:13px}
    .tw-av{width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px;color:#0a0a0f}
    .tw-name{font-size:12.5px;font-weight:700;color:#fff}
    .tw-role{font-size:11px;color:#8b8ba3}
    .tw-stars{color:#fbbf24;font-size:12px;letter-spacing:1px;margin-bottom:9px}
    
    @media(max-width:760px){.tw-wall{grid-template-columns:repeat(2,1fr);height:520px}.tw-hide{display:none}}
    @media(max-width:480px){.tw-wall{grid-template-columns:1fr}}
  </style>
</head>
<body>
  <section class="tw-section">
    <h2 class="tw-head">Loved by builders</h2>
    <div class="tw-wall" id="twWall" aria-label="Customer testimonials">
      <div class="tw-col" data-speed="38"></div>
      <div class="tw-col" data-speed="50"></div>
      <div class="tw-col tw-hide" data-speed="44"></div>
    </div>
  </section>
  <script>
    var DATA = [
      { q: 'Shipped our marketing site in a weekend. The snippets just drop in.', n: 'Mara Okafor', r: 'Founder, Tilt', c: '#a78bfa' },
      { q: 'The export-to-React button saved me hours of porting plain HTML.', n: 'Devon Reyes', r: 'Frontend Lead', c: '#22d3ee' },
      { q: 'Finally a library that is real UI, not yet another color converter.', n: 'Priya Nair', r: 'Design Engineer', c: '#f472b6' },
      { q: 'Copy, paste, customize. My team uses it as a starter every sprint.', n: 'Liam Chen', r: 'CTO, Forge', c: '#34d399' },
      { q: 'Accessible defaults out of the box meant fewer audit fixes later.', n: 'Sofia Marenco', r: 'Accessibility Eng.', c: '#fbbf24' },
      { q: 'The dark, modern aesthetic matched our brand with zero restyling.', n: 'Noah Patel', r: 'Product Designer', c: '#60a5fa' },
      { q: 'I prototype client pitches twice as fast now. Total game changer.', n: 'Ava Lindqvist', r: 'Freelancer', c: '#fb7185' },
      { q: 'Vanilla JS means no dependency headaches in our legacy stack.', n: 'Marcus Bauer', r: 'Staff Engineer', c: '#c084fc' },
      { q: 'Our conversion bump came straight from these polished components.', n: 'Yuki Tanaka', r: 'Growth, Bloom', c: '#2dd4bf' }
    ];
    
    function cardHTML(t) {
      var initials = t.n.split(' ').map(function (w) { return w[0]; }).join('');
      return '<div class="tw-card"><div class="tw-stars">★★★★★</div>' +
        '<p class="tw-quote">' + t.q + '</p>' +
        '<div class="tw-who"><span class="tw-av" style="background:' + t.c + '">' + initials + '</span>' +
        '<span><span class="tw-name">' + t.n + '</span><br><span class="tw-role">' + t.r + '</span></span></div></div>';
    }
    
    var cols = Array.prototype.slice.call(document.querySelectorAll('.tw-col'));
    
    cols.forEach(function (col, i) {
      // Each column gets a rotated slice of the data, then a duplicate set so the
      // loop is seamless: when we scroll exactly one set height, we reset to 0.
      var slice = DATA.slice(i * 3).concat(DATA.slice(0, i * 3));
      var html = slice.map(cardHTML).join('');
      col.innerHTML = html + html;
    
      var speed = parseFloat(col.getAttribute('data-speed'));   // px per second
      var offset = i % 2 ? -1 : 1;                               // alternate direction
      var pos = 0, half = 0, paused = false, last = performance.now();
    
      requestAnimationFrame(function measure() { half = col.scrollHeight / 2; });
    
      col.addEventListener('mouseenter', function () { paused = true; });
      col.addEventListener('mouseleave', function () { paused = false; });
    
      function tick(now) {
        var dt = (now - last) / 1000; last = now;
        if (!half) half = col.scrollHeight / 2;
        if (!paused) {
          pos += speed * dt * offset;
          if (pos <= -half) pos += half;     // wrap upward scroll
          if (pos >= 0) pos -= half;         // wrap downward scroll
          col.style.transform = 'translateY(' + pos + 'px)';
        }
        requestAnimationFrame(tick);
      }
      requestAnimationFrame(tick);
    });
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Testimonial Wall</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#e7e7ef
    }

    .tw-card {
      background:#15151f;border:1px solid #26263a;border-radius:14px;padding:17px;flex-shrink:0
    }

    .tw-quote {
      font-size:13.5px;line-height:1.6;color:#cfcfe0
    }

    .tw-who {
      display:flex;align-items:center;gap:10px;margin-top:13px
    }

    .tw-av {
      width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px;color:#0a0a0f
    }

    .tw-name {
      font-size:12.5px;font-weight:700;color:#fff
    }

    .tw-role {
      font-size:11px;color:#8b8ba3
    }

    .tw-stars {
      color:#fbbf24;font-size:12px;letter-spacing:1px;margin-bottom:9px
    }
  </style>
</head>
<body>
  <section class="max-w-[1040px] my-0 mx-auto py-12 px-[18px]">
    <h2 class="text-center text-[clamp(24px,5vw,38px)] font-extrabold tracking-[-.02em] mb-[34px] [background:linear-gradient(120deg,#fff,#a78bfa)] [-webkit-background-clip:text] bg-clip-text text-transparent">Loved by builders</h2>
    <div class="grid grid-cols-3 gap-4 h-[560px] overflow-hidden relative [-webkit-mask-image:linear-gradient(180deg,transparent,#000_12%,#000_88%,transparent)] [mask-image:linear-gradient(180deg,transparent,#000_12%,#000_88%,transparent)] max-[760px]:grid-cols-2 max-[760px]:h-[520px] max-[480px]:grid-cols-1" id="twWall" aria-label="Customer testimonials">
      <div class="tw-col flex flex-col gap-4 [will-change:transform]" data-speed="38"></div>
      <div class="tw-col flex flex-col gap-4 [will-change:transform]" data-speed="50"></div>
      <div class="tw-col flex flex-col gap-4 [will-change:transform] max-[760px]:hidden" data-speed="44"></div>
    </div>
  </section>
  <script>
    var DATA = [
      { q: 'Shipped our marketing site in a weekend. The snippets just drop in.', n: 'Mara Okafor', r: 'Founder, Tilt', c: '#a78bfa' },
      { q: 'The export-to-React button saved me hours of porting plain HTML.', n: 'Devon Reyes', r: 'Frontend Lead', c: '#22d3ee' },
      { q: 'Finally a library that is real UI, not yet another color converter.', n: 'Priya Nair', r: 'Design Engineer', c: '#f472b6' },
      { q: 'Copy, paste, customize. My team uses it as a starter every sprint.', n: 'Liam Chen', r: 'CTO, Forge', c: '#34d399' },
      { q: 'Accessible defaults out of the box meant fewer audit fixes later.', n: 'Sofia Marenco', r: 'Accessibility Eng.', c: '#fbbf24' },
      { q: 'The dark, modern aesthetic matched our brand with zero restyling.', n: 'Noah Patel', r: 'Product Designer', c: '#60a5fa' },
      { q: 'I prototype client pitches twice as fast now. Total game changer.', n: 'Ava Lindqvist', r: 'Freelancer', c: '#fb7185' },
      { q: 'Vanilla JS means no dependency headaches in our legacy stack.', n: 'Marcus Bauer', r: 'Staff Engineer', c: '#c084fc' },
      { q: 'Our conversion bump came straight from these polished components.', n: 'Yuki Tanaka', r: 'Growth, Bloom', c: '#2dd4bf' }
    ];
    
    function cardHTML(t) {
      var initials = t.n.split(' ').map(function (w) { return w[0]; }).join('');
      return '<div class="tw-card"><div class="tw-stars">★★★★★</div>' +
        '<p class="tw-quote">' + t.q + '</p>' +
        '<div class="tw-who"><span class="tw-av" style="background:' + t.c + '">' + initials + '</span>' +
        '<span><span class="tw-name">' + t.n + '</span><br><span class="tw-role">' + t.r + '</span></span></div></div>';
    }
    
    var cols = Array.prototype.slice.call(document.querySelectorAll('.tw-col'));
    
    cols.forEach(function (col, i) {
      // Each column gets a rotated slice of the data, then a duplicate set so the
      // loop is seamless: when we scroll exactly one set height, we reset to 0.
      var slice = DATA.slice(i * 3).concat(DATA.slice(0, i * 3));
      var html = slice.map(cardHTML).join('');
      col.innerHTML = html + html;
    
      var speed = parseFloat(col.getAttribute('data-speed'));   // px per second
      var offset = i % 2 ? -1 : 1;                               // alternate direction
      var pos = 0, half = 0, paused = false, last = performance.now();
    
      requestAnimationFrame(function measure() { half = col.scrollHeight / 2; });
    
      col.addEventListener('mouseenter', function () { paused = true; });
      col.addEventListener('mouseleave', function () { paused = false; });
    
      function tick(now) {
        var dt = (now - last) / 1000; last = now;
        if (!half) half = col.scrollHeight / 2;
        if (!paused) {
          pos += speed * dt * offset;
          if (pos <= -half) pos += half;     // wrap upward scroll
          if (pos >= 0) pos -= half;         // wrap downward scroll
          col.style.transform = 'translateY(' + pos + 'px)';
        }
        requestAnimationFrame(tick);
      }
      requestAnimationFrame(tick);
    });
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to TestimonialWall.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#e7e7ef}

.tw-section{max-width:1040px;margin:0 auto;padding:48px 18px}
.tw-head{text-align:center;font-size:clamp(24px,5vw,38px);font-weight:800;letter-spacing:-.02em;margin-bottom:34px;background:linear-gradient(120deg,#fff,#a78bfa);-webkit-background-clip:text;background-clip:text;color:transparent}

.tw-wall{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;height:560px;overflow:hidden;position:relative;-webkit-mask-image:linear-gradient(180deg,transparent,#000 12%,#000 88%,transparent);mask-image:linear-gradient(180deg,transparent,#000 12%,#000 88%,transparent)}
.tw-col{display:flex;flex-direction:column;gap:16px;will-change:transform}

.tw-card{background:#15151f;border:1px solid #26263a;border-radius:14px;padding:17px;flex-shrink:0}
.tw-quote{font-size:13.5px;line-height:1.6;color:#cfcfe0}
.tw-who{display:flex;align-items:center;gap:10px;margin-top:13px}
.tw-av{width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px;color:#0a0a0f}
.tw-name{font-size:12.5px;font-weight:700;color:#fff}
.tw-role{font-size:11px;color:#8b8ba3}
.tw-stars{color:#fbbf24;font-size:12px;letter-spacing:1px;margin-bottom:9px}

@media(max-width:760px){.tw-wall{grid-template-columns:repeat(2,1fr);height:520px}.tw-hide{display:none}}
@media(max-width:480px){.tw-wall{grid-template-columns:1fr}}
`;

export default function TestimonialWall() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var DATA = [
      { q: 'Shipped our marketing site in a weekend. The snippets just drop in.', n: 'Mara Okafor', r: 'Founder, Tilt', c: '#a78bfa' },
      { q: 'The export-to-React button saved me hours of porting plain HTML.', n: 'Devon Reyes', r: 'Frontend Lead', c: '#22d3ee' },
      { q: 'Finally a library that is real UI, not yet another color converter.', n: 'Priya Nair', r: 'Design Engineer', c: '#f472b6' },
      { q: 'Copy, paste, customize. My team uses it as a starter every sprint.', n: 'Liam Chen', r: 'CTO, Forge', c: '#34d399' },
      { q: 'Accessible defaults out of the box meant fewer audit fixes later.', n: 'Sofia Marenco', r: 'Accessibility Eng.', c: '#fbbf24' },
      { q: 'The dark, modern aesthetic matched our brand with zero restyling.', n: 'Noah Patel', r: 'Product Designer', c: '#60a5fa' },
      { q: 'I prototype client pitches twice as fast now. Total game changer.', n: 'Ava Lindqvist', r: 'Freelancer', c: '#fb7185' },
      { q: 'Vanilla JS means no dependency headaches in our legacy stack.', n: 'Marcus Bauer', r: 'Staff Engineer', c: '#c084fc' },
      { q: 'Our conversion bump came straight from these polished components.', n: 'Yuki Tanaka', r: 'Growth, Bloom', c: '#2dd4bf' }
    ];
    
    function cardHTML(t) {
      var initials = t.n.split(' ').map(function (w) { return w[0]; }).join('');
      return '<div class="tw-card"><div class="tw-stars">★★★★★</div>' +
        '<p class="tw-quote">' + t.q + '</p>' +
        '<div class="tw-who"><span class="tw-av" style="background:' + t.c + '">' + initials + '</span>' +
        '<span><span class="tw-name">' + t.n + '</span><br><span class="tw-role">' + t.r + '</span></span></div></div>';
    }
    
    var cols = Array.prototype.slice.call(document.querySelectorAll('.tw-col'));
    
    cols.forEach(function (col, i) {
      // Each column gets a rotated slice of the data, then a duplicate set so the
      // loop is seamless: when we scroll exactly one set height, we reset to 0.
      var slice = DATA.slice(i * 3).concat(DATA.slice(0, i * 3));
      var html = slice.map(cardHTML).join('');
      col.innerHTML = html + html;
    
      var speed = parseFloat(col.getAttribute('data-speed'));   // px per second
      var offset = i % 2 ? -1 : 1;                               // alternate direction
      var pos = 0, half = 0, paused = false, last = performance.now();
    
      requestAnimationFrame(function measure() { half = col.scrollHeight / 2; });
    
      col.addEventListener('mouseenter', function () { paused = true; });
      col.addEventListener('mouseleave', function () { paused = false; });
    
      function tick(now) {
        var dt = (now - last) / 1000; last = now;
        if (!half) half = col.scrollHeight / 2;
        if (!paused) {
          pos += speed * dt * offset;
          if (pos <= -half) pos += half;     // wrap upward scroll
          if (pos >= 0) pos -= half;         // wrap downward scroll
          col.style.transform = 'translateY(' + pos + 'px)';
        }
        requestAnimationFrame(tick);
      }
      requestAnimationFrame(tick);
    });
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <section className="tw-section">
        <h2 className="tw-head">Loved by builders</h2>
        <div className="tw-wall" id="twWall" aria-label="Customer testimonials">
          <div className="tw-col" data-speed="38"></div>
          <div className="tw-col" data-speed="50"></div>
          <div className="tw-col tw-hide" data-speed="44"></div>
        </div>
      </section>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function TestimonialWall() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var DATA = [
      { q: 'Shipped our marketing site in a weekend. The snippets just drop in.', n: 'Mara Okafor', r: 'Founder, Tilt', c: '#a78bfa' },
      { q: 'The export-to-React button saved me hours of porting plain HTML.', n: 'Devon Reyes', r: 'Frontend Lead', c: '#22d3ee' },
      { q: 'Finally a library that is real UI, not yet another color converter.', n: 'Priya Nair', r: 'Design Engineer', c: '#f472b6' },
      { q: 'Copy, paste, customize. My team uses it as a starter every sprint.', n: 'Liam Chen', r: 'CTO, Forge', c: '#34d399' },
      { q: 'Accessible defaults out of the box meant fewer audit fixes later.', n: 'Sofia Marenco', r: 'Accessibility Eng.', c: '#fbbf24' },
      { q: 'The dark, modern aesthetic matched our brand with zero restyling.', n: 'Noah Patel', r: 'Product Designer', c: '#60a5fa' },
      { q: 'I prototype client pitches twice as fast now. Total game changer.', n: 'Ava Lindqvist', r: 'Freelancer', c: '#fb7185' },
      { q: 'Vanilla JS means no dependency headaches in our legacy stack.', n: 'Marcus Bauer', r: 'Staff Engineer', c: '#c084fc' },
      { q: 'Our conversion bump came straight from these polished components.', n: 'Yuki Tanaka', r: 'Growth, Bloom', c: '#2dd4bf' }
    ];
    
    function cardHTML(t) {
      var initials = t.n.split(' ').map(function (w) { return w[0]; }).join('');
      return '<div class="tw-card"><div class="tw-stars">★★★★★</div>' +
        '<p class="tw-quote">' + t.q + '</p>' +
        '<div class="tw-who"><span class="tw-av" style="background:' + t.c + '">' + initials + '</span>' +
        '<span><span class="tw-name">' + t.n + '</span><br><span class="tw-role">' + t.r + '</span></span></div></div>';
    }
    
    var cols = Array.prototype.slice.call(document.querySelectorAll('.tw-col'));
    
    cols.forEach(function (col, i) {
      // Each column gets a rotated slice of the data, then a duplicate set so the
      // loop is seamless: when we scroll exactly one set height, we reset to 0.
      var slice = DATA.slice(i * 3).concat(DATA.slice(0, i * 3));
      var html = slice.map(cardHTML).join('');
      col.innerHTML = html + html;
    
      var speed = parseFloat(col.getAttribute('data-speed'));   // px per second
      var offset = i % 2 ? -1 : 1;                               // alternate direction
      var pos = 0, half = 0, paused = false, last = performance.now();
    
      requestAnimationFrame(function measure() { half = col.scrollHeight / 2; });
    
      col.addEventListener('mouseenter', function () { paused = true; });
      col.addEventListener('mouseleave', function () { paused = false; });
    
      function tick(now) {
        var dt = (now - last) / 1000; last = now;
        if (!half) half = col.scrollHeight / 2;
        if (!paused) {
          pos += speed * dt * offset;
          if (pos <= -half) pos += half;     // wrap upward scroll
          if (pos >= 0) pos -= half;         // wrap downward scroll
          col.style.transform = 'translateY(' + pos + 'px)';
        }
        requestAnimationFrame(tick);
      }
      requestAnimationFrame(tick);
    });
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#e7e7ef
}

.tw-card {
  background:#15151f;border:1px solid #26263a;border-radius:14px;padding:17px;flex-shrink:0
}

.tw-quote {
  font-size:13.5px;line-height:1.6;color:#cfcfe0
}

.tw-who {
  display:flex;align-items:center;gap:10px;margin-top:13px
}

.tw-av {
  width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px;color:#0a0a0f
}

.tw-name {
  font-size:12.5px;font-weight:700;color:#fff
}

.tw-role {
  font-size:11px;color:#8b8ba3
}

.tw-stars {
  color:#fbbf24;font-size:12px;letter-spacing:1px;margin-bottom:9px
}
      `}</style>
      <section className="max-w-[1040px] my-0 mx-auto py-12 px-[18px]">
        <h2 className="text-center text-[clamp(24px,5vw,38px)] font-extrabold tracking-[-.02em] mb-[34px] [background:linear-gradient(120deg,#fff,#a78bfa)] [-webkit-background-clip:text] bg-clip-text text-transparent">Loved by builders</h2>
        <div className="grid grid-cols-3 gap-4 h-[560px] overflow-hidden relative [-webkit-mask-image:linear-gradient(180deg,transparent,#000_12%,#000_88%,transparent)] [mask-image:linear-gradient(180deg,transparent,#000_12%,#000_88%,transparent)] max-[760px]:grid-cols-2 max-[760px]:h-[520px] max-[480px]:grid-cols-1" id="twWall" aria-label="Customer testimonials">
          <div className="tw-col flex flex-col gap-4 [will-change:transform]" data-speed="38"></div>
          <div className="tw-col flex flex-col gap-4 [will-change:transform]" data-speed="50"></div>
          <div className="tw-col flex flex-col gap-4 [will-change:transform] max-[760px]:hidden" data-speed="44"></div>
        </div>
      </section>
    </>
  );
}
Vue
<template>
  <section class="tw-section">
    <h2 class="tw-head">Loved by builders</h2>
    <div class="tw-wall" id="twWall" aria-label="Customer testimonials">
      <div class="tw-col" data-speed="38"></div>
      <div class="tw-col" data-speed="50"></div>
      <div class="tw-col tw-hide" data-speed="44"></div>
    </div>
  </section>
</template>

<script setup>
import { onMounted } from 'vue';

var DATA = [
  { q: 'Shipped our marketing site in a weekend. The snippets just drop in.', n: 'Mara Okafor', r: 'Founder, Tilt', c: '#a78bfa' },
  { q: 'The export-to-React button saved me hours of porting plain HTML.', n: 'Devon Reyes', r: 'Frontend Lead', c: '#22d3ee' },
  { q: 'Finally a library that is real UI, not yet another color converter.', n: 'Priya Nair', r: 'Design Engineer', c: '#f472b6' },
  { q: 'Copy, paste, customize. My team uses it as a starter every sprint.', n: 'Liam Chen', r: 'CTO, Forge', c: '#34d399' },
  { q: 'Accessible defaults out of the box meant fewer audit fixes later.', n: 'Sofia Marenco', r: 'Accessibility Eng.', c: '#fbbf24' },
  { q: 'The dark, modern aesthetic matched our brand with zero restyling.', n: 'Noah Patel', r: 'Product Designer', c: '#60a5fa' },
  { q: 'I prototype client pitches twice as fast now. Total game changer.', n: 'Ava Lindqvist', r: 'Freelancer', c: '#fb7185' },
  { q: 'Vanilla JS means no dependency headaches in our legacy stack.', n: 'Marcus Bauer', r: 'Staff Engineer', c: '#c084fc' },
  { q: 'Our conversion bump came straight from these polished components.', n: 'Yuki Tanaka', r: 'Growth, Bloom', c: '#2dd4bf' }
];
function cardHTML(t) {
  var initials = t.n.split(' ').map(function (w) { return w[0]; }).join('');
  return '<div class="tw-card"><div class="tw-stars">★★★★★</div>' +
    '<p class="tw-quote">' + t.q + '</p>' +
    '<div class="tw-who"><span class="tw-av" style="background:' + t.c + '">' + initials + '</span>' +
    '<span><span class="tw-name">' + t.n + '</span><br><span class="tw-role">' + t.r + '</span></span></div></div>';
}
var cols = Array.prototype.slice.call(document.querySelectorAll('.tw-col'));

onMounted(() => {
  cols.forEach(function (col, i) {
    // Each column gets a rotated slice of the data, then a duplicate set so the
    // loop is seamless: when we scroll exactly one set height, we reset to 0.
    var slice = DATA.slice(i * 3).concat(DATA.slice(0, i * 3));
    var html = slice.map(cardHTML).join('');
    col.innerHTML = html + html;
  
    var speed = parseFloat(col.getAttribute('data-speed'));   // px per second
    var offset = i % 2 ? -1 : 1;                               // alternate direction
    var pos = 0, half = 0, paused = false, last = performance.now();
  
    requestAnimationFrame(function measure() { half = col.scrollHeight / 2; });
  
    col.addEventListener('mouseenter', function () { paused = true; });
    col.addEventListener('mouseleave', function () { paused = false; });
  
    function tick(now) {
      var dt = (now - last) / 1000; last = now;
      if (!half) half = col.scrollHeight / 2;
      if (!paused) {
        pos += speed * dt * offset;
        if (pos <= -half) pos += half;     // wrap upward scroll
        if (pos >= 0) pos -= half;         // wrap downward scroll
        col.style.transform = 'translateY(' + pos + 'px)';
      }
      requestAnimationFrame(tick);
    }
    requestAnimationFrame(tick);
  });
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#e7e7ef}

.tw-section{max-width:1040px;margin:0 auto;padding:48px 18px}
.tw-head{text-align:center;font-size:clamp(24px,5vw,38px);font-weight:800;letter-spacing:-.02em;margin-bottom:34px;background:linear-gradient(120deg,#fff,#a78bfa);-webkit-background-clip:text;background-clip:text;color:transparent}

.tw-wall{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;height:560px;overflow:hidden;position:relative;-webkit-mask-image:linear-gradient(180deg,transparent,#000 12%,#000 88%,transparent);mask-image:linear-gradient(180deg,transparent,#000 12%,#000 88%,transparent)}
.tw-col{display:flex;flex-direction:column;gap:16px;will-change:transform}

.tw-card{background:#15151f;border:1px solid #26263a;border-radius:14px;padding:17px;flex-shrink:0}
.tw-quote{font-size:13.5px;line-height:1.6;color:#cfcfe0}
.tw-who{display:flex;align-items:center;gap:10px;margin-top:13px}
.tw-av{width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px;color:#0a0a0f}
.tw-name{font-size:12.5px;font-weight:700;color:#fff}
.tw-role{font-size:11px;color:#8b8ba3}
.tw-stars{color:#fbbf24;font-size:12px;letter-spacing:1px;margin-bottom:9px}

@media(max-width:760px){.tw-wall{grid-template-columns:repeat(2,1fr);height:520px}.tw-hide{display:none}}
@media(max-width:480px){.tw-wall{grid-template-columns:1fr}}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-testimonial-wall',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <section class="tw-section">
      <h2 class="tw-head">Loved by builders</h2>
      <div class="tw-wall" id="twWall" aria-label="Customer testimonials">
        <div class="tw-col" data-speed="38"></div>
        <div class="tw-col" data-speed="50"></div>
        <div class="tw-col tw-hide" data-speed="44"></div>
      </div>
    </section>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#e7e7ef}
    
    .tw-section{max-width:1040px;margin:0 auto;padding:48px 18px}
    .tw-head{text-align:center;font-size:clamp(24px,5vw,38px);font-weight:800;letter-spacing:-.02em;margin-bottom:34px;background:linear-gradient(120deg,#fff,#a78bfa);-webkit-background-clip:text;background-clip:text;color:transparent}
    
    .tw-wall{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;height:560px;overflow:hidden;position:relative;-webkit-mask-image:linear-gradient(180deg,transparent,#000 12%,#000 88%,transparent);mask-image:linear-gradient(180deg,transparent,#000 12%,#000 88%,transparent)}
    .tw-col{display:flex;flex-direction:column;gap:16px;will-change:transform}
    
    .tw-card{background:#15151f;border:1px solid #26263a;border-radius:14px;padding:17px;flex-shrink:0}
    .tw-quote{font-size:13.5px;line-height:1.6;color:#cfcfe0}
    .tw-who{display:flex;align-items:center;gap:10px;margin-top:13px}
    .tw-av{width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:13px;color:#0a0a0f}
    .tw-name{font-size:12.5px;font-weight:700;color:#fff}
    .tw-role{font-size:11px;color:#8b8ba3}
    .tw-stars{color:#fbbf24;font-size:12px;letter-spacing:1px;margin-bottom:9px}
    
    @media(max-width:760px){.tw-wall{grid-template-columns:repeat(2,1fr);height:520px}.tw-hide{display:none}}
    @media(max-width:480px){.tw-wall{grid-template-columns:1fr}}
  `]
})
export class TestimonialWallComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var DATA = [
      { q: 'Shipped our marketing site in a weekend. The snippets just drop in.', n: 'Mara Okafor', r: 'Founder, Tilt', c: '#a78bfa' },
      { q: 'The export-to-React button saved me hours of porting plain HTML.', n: 'Devon Reyes', r: 'Frontend Lead', c: '#22d3ee' },
      { q: 'Finally a library that is real UI, not yet another color converter.', n: 'Priya Nair', r: 'Design Engineer', c: '#f472b6' },
      { q: 'Copy, paste, customize. My team uses it as a starter every sprint.', n: 'Liam Chen', r: 'CTO, Forge', c: '#34d399' },
      { q: 'Accessible defaults out of the box meant fewer audit fixes later.', n: 'Sofia Marenco', r: 'Accessibility Eng.', c: '#fbbf24' },
      { q: 'The dark, modern aesthetic matched our brand with zero restyling.', n: 'Noah Patel', r: 'Product Designer', c: '#60a5fa' },
      { q: 'I prototype client pitches twice as fast now. Total game changer.', n: 'Ava Lindqvist', r: 'Freelancer', c: '#fb7185' },
      { q: 'Vanilla JS means no dependency headaches in our legacy stack.', n: 'Marcus Bauer', r: 'Staff Engineer', c: '#c084fc' },
      { q: 'Our conversion bump came straight from these polished components.', n: 'Yuki Tanaka', r: 'Growth, Bloom', c: '#2dd4bf' }
    ];
    
    function cardHTML(t) {
      var initials = t.n.split(' ').map(function (w) { return w[0]; }).join('');
      return '<div class="tw-card"><div class="tw-stars">★★★★★</div>' +
        '<p class="tw-quote">' + t.q + '</p>' +
        '<div class="tw-who"><span class="tw-av" style="background:' + t.c + '">' + initials + '</span>' +
        '<span><span class="tw-name">' + t.n + '</span><br><span class="tw-role">' + t.r + '</span></span></div></div>';
    }
    
    var cols = Array.prototype.slice.call(document.querySelectorAll('.tw-col'));
    
    cols.forEach(function (col, i) {
      // Each column gets a rotated slice of the data, then a duplicate set so the
      // loop is seamless: when we scroll exactly one set height, we reset to 0.
      var slice = DATA.slice(i * 3).concat(DATA.slice(0, i * 3));
      var html = slice.map(cardHTML).join('');
      col.innerHTML = html + html;
    
      var speed = parseFloat(col.getAttribute('data-speed'));   // px per second
      var offset = i % 2 ? -1 : 1;                               // alternate direction
      var pos = 0, half = 0, paused = false, last = performance.now();
    
      requestAnimationFrame(function measure() { half = col.scrollHeight / 2; });
    
      col.addEventListener('mouseenter', function () { paused = true; });
      col.addEventListener('mouseleave', function () { paused = false; });
    
      function tick(now) {
        var dt = (now - last) / 1000; last = now;
        if (!half) half = col.scrollHeight / 2;
        if (!paused) {
          pos += speed * dt * offset;
          if (pos <= -half) pos += half;     // wrap upward scroll
          if (pos >= 0) pos -= half;         // wrap downward scroll
          col.style.transform = 'translateY(' + pos + 'px)';
        }
        requestAnimationFrame(tick);
      }
      requestAnimationFrame(tick);
    });

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { cardHTML });
  }
}