← Cards & images

Testimonial Columns

Your testimonials are shared out into columns that scroll on without end, each at its own speed, fading out at the top and bottom. Point at it and it slows to a stop, so a quote can be read.

★★★★★
“It took ten minutes to add and our landing page finally feels alive.”
Ana RuizProduct designer
★★★★★
“Copy, paste, done. I didn’t have to learn a single new tool.”
Ben KatoIndie developer
★★★★★
“Subtle in the right way. Our clients noticed straight away.”
Cai MorganStudio founder
★★★★★
“Motion that works on phones as well as it does on my big screen.”
Dana OkaforFront-end lead
★★★★★
“The reduced-motion support alone saved me an afternoon.”
Eli NovakAccessibility lead
★★★★★
“I swapped three libraries for a few small scripts. Pages load faster too.”
Fen LiEngineer
★★★★★
“Our sign-ups went up the week we added the hero animation.”
Gia RossiMarketing
★★★★★
“Readable code I can actually tweak. Rare, and lovely.”
Hal SteenCreative coder
★★★★★
“Every demo works on my old phone. That never happens.”
Ivy MossStudent

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-testimonial-columns to a box with a height, holding your testimonials.
  3. They’re dealt into columns in turn; the number of columns follows the width, so a phone gets fewer.

Options

Add any of these attributes to the same element.

data-columnsMost columns. Default 3.
data-minNarrowest a column may be, in px. Default 220.
data-speedSpeed in px a second; each column goes a little faster or slower. Default 28.
data-directionup, down or alternate. Default up.
data-gapSpace between cards and columns, in px. Default 16.
data-fade="false"No fading at the top and bottom.
data-pause="false"Keep moving under the mouse.

Each column is filled with copies so it never runs out; the copies are hidden from screen readers. It only moves while on screen, and with reduced motion the columns stand still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Testimonial Columns: your testimonials are shared out into columns that
    // scroll upwards (or alternately up and down) without end, each at its
    // own speed, fading out at the top and bottom. A mouse over it slows it
    // to a stop so a quote can be read.
    // Usage: <div data-testimonial-columns style="height: 420px">
    //          <figure>…</figure> <figure>…</figure> …
    //        </div>
    // Give it a height. The number of columns follows the width.
    // Optional: data-columns (most columns, default 3), data-min (narrowest a
    // column may be, px, default 220), data-speed (px a second, default 28),
    // data-direction ("up", default, "down" or "alternate"), data-gap (px
    // between cards and columns, default 16), data-fade="false" (no fading
    // edges), data-pause="false" (keep moving under the mouse).
    var DEFAULTS = { columns: 3, min: 220, speed: 28, gap: 16 };
    var PACE = [1, 0.7, 1.28, 0.86];

    var css =
      '.tsc{display:flex;overflow:hidden;align-items:flex-start}' +
      '.tsc.tsc-fade{-webkit-mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent);mask-image:linear-gradient(transparent,#000 14%,#000 86%,transparent)}' +
      '.tsc-col{flex:1 1 0;min-width:0}' +
      '.tsc-track{will-change:transform}' +
      '.tsc-track>*{margin:0 0 var(--tsc-gap)!important}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var cards = Array.prototype.slice.call(el.children);
      if (!cards.length) return;
      cards.forEach(function (c) { el.removeChild(c); });
      el.classList.add('tsc');
      if (d.fade !== 'false') el.classList.add('tsc-fade');
      var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
      el.style.setProperty('--tsc-gap', gap + 'px');
      el.style.gap = gap + 'px';
      var s = el._tscS = { cards: cards, cols: [], n: 0, t: 0, last: 0, raf: 0, visible: false, hover: false, rate: 1 };

      function build() {
        var maxCols = parseInt(d.columns, 10) || DEFAULTS.columns;
        var min = +d.min || DEFAULTS.min;
        var n = Math.max(1, Math.min(maxCols, cards.length, Math.floor((el.clientWidth + gap) / (min + gap))));
        if (n === s.n) { fill(); return; }
        s.n = n;
        el.textContent = '';
        s.cols = [];
        for (var k = 0; k < n; k++) {
          var col = document.createElement('div');
          col.className = 'tsc-col';
          var track = document.createElement('div');
          track.className = 'tsc-track';
          col.appendChild(track);
          el.appendChild(col);
          var dir = d.direction === 'down' ? -1 : d.direction === 'alternate' && k % 2 ? -1 : 1;
          s.cols.push({ track: track, own: [], dir: dir, pace: PACE[k % PACE.length], setH: 0 });
        }
        cards.forEach(function (c, i) {
          var col = s.cols[i % n];
          col.own.push(c);
        });
        fill();
      }

      // Each column holds its own cards, then copies of them until it is
      // tall enough to scroll on without a gap.
      function fill() {
        var H = el.clientHeight;
        s.cols.forEach(function (col) {
          col.track.textContent = '';
          col.own.forEach(function (c) { col.track.appendChild(c); });
          col.setH = col.track.offsetHeight;
          if (reduced() || !col.setH) return;
          var copies = Math.ceil(H / col.setH) + 1;
          for (var r = 0; r < copies; r++) {
            col.own.forEach(function (c) {
              var copy = c.cloneNode(true);
              copy.setAttribute('aria-hidden', 'true');
              copy.removeAttribute('id');
              col.track.appendChild(copy);
            });
          }
        });
        draw();
      }

      function draw() {
        s.cols.forEach(function (col, k) {
          if (!col.setH || reduced()) return;
          var off = (s.t * col.pace + k * 97) % col.setH;
          var y = col.dir > 0 ? -off : -(col.setH - off);
          col.track.style.transform = 'translate3d(0,' + y.toFixed(2) + 'px,0)';
        });
      }

      function frame(now) {
        s.raf = 0;
        if (!s.visible) return;
        var dt = s.last ? Math.min(64, now - s.last) : 0;
        s.last = now;
        s.rate += ((s.hover ? 0 : 1) - s.rate) * Math.min(1, dt / 300);
        s.t += dt / 1000 * (+d.speed || DEFAULTS.speed) * s.rate;
        draw();
        s.raf = requestAnimationFrame(frame);
      }

      build();
      if (document.fonts) document.fonts.ready.then(fill);
      var lastW = el.clientWidth, lastH = el.clientHeight;
      new ResizeObserver(function () {
        if (el.clientWidth === lastW && el.clientHeight === lastH) return;
        lastW = el.clientWidth; lastH = el.clientHeight;
        build();
      }).observe(el);
      if (reduced()) return;

      if (d.pause !== 'false') {
        el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') s.hover = true; });
        el.addEventListener('pointerleave', function () { s.hover = false; });
      }
      new IntersectionObserver(function (en) {
        s.visible = en[0].isIntersecting;
        if (s.visible && !s.raf) { s.last = 0; s.raf = requestAnimationFrame(frame); }
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-testimonial-columns]').forEach(function (el) {
        if (el._tsc) return;
        el._tsc = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-testimonial-columns data-direction="alternate" style="height: 420px">
  <figure>
    <blockquote>"Copy, paste, done."</blockquote>
    <figcaption>Ben Kato</figcaption>
  </figure>
  <!-- … six or more -->
</div>