← Cards & images

Testimonial Rotator

One quote at a time: the current one slides away and fades, the next slides in, its avatar pops into place with a little wobble and the name follows. Dots underneath jump straight to a quote.

“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
“The animations are subtle in the right way. Our clients noticed straight away.”
Cai MorganStudio founder
“Finally, motion that works on phones as well as it does on my big screen.”
Dana OkaforFront-end lead

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-testimonial-rotator and an aria-label to a container whose children are the testimonials, each a <figure> with a <blockquote> and a <figcaption>. The avatar is its <img>, or any element marked data-avatar.

Options

Add any of these attributes to the same element.

data-intervalHow long each quote stays. Default 5000 ms.
data-controls="false"No dots.

The container keeps the height of the tallest quote, so nothing below it jumps. It pauses under the mouse, while it has focus and when it’s off screen; hidden quotes are hidden from screen readers too. From code, animacious.testimonial(element, 2) shows a quote.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Testimonial Rotator: shows one quote at a time. On each change the
    // quote slides out and fades, the next one slides in, and its avatar pops
    // into place with the name following. Dots underneath jump to a quote.
    // Usage:
    //   <div data-testimonial-rotator aria-label="What people say">
    //     <figure>
    //       <blockquote>“Lovely to work with.”</blockquote>
    //       <figcaption><img src="ana.jpg" alt=""> <b>Ana Ruiz</b> Designer</figcaption>
    //     </figure>
    //     <figure>…</figure>
    //   </div>
    // Each child is a testimonial. The avatar is its <img> or an element
    // marked data-avatar.
    // Optional: data-interval (ms each quote stays, default 5000),
    // data-controls="false" (no dots).
    // Script use: animacious.testimonial(element, index) shows a quote.
    var DEFAULTS = { interval: 5000 };

    var css =
      '[data-testimonial-rotator]{display:grid}' +
      '[data-testimonial-rotator]>:not(.tmr-dots){grid-area:1/1;margin:0}' +
      '[data-testimonial-rotator]>.tmr-off{visibility:hidden}' +
      '.tmr-dots{grid-area:2/1;display:flex;justify-content:center;gap:8px;margin-top:18px}' +
      '.tmr-dots>*{width:8px;height:8px;padding:0;border:0;border-radius:4px;color:inherit;background:currentColor;opacity:.25;cursor:pointer;transition:width .35s,opacity .35s}' +
      '.tmr-dots>[aria-current="true"],.tmr-dots>.tmr-on{width:24px;opacity:.8}' +
      '.tmr-dots>button:focus-visible{outline:3px solid #a855f7;outline-offset:3px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function parts(item) {
      var avatar = item.querySelector('[data-avatar]') || item.querySelector('img');
      var quote = item.querySelector('blockquote') || item.firstElementChild;
      var cap = item.querySelector('figcaption, cite');
      var rest = cap ? Array.from(cap.children).filter(function (c) { return c !== avatar && !c.contains(avatar); }) : [];
      if (cap && !rest.length && !cap.contains(avatar)) rest = [cap];
      return { avatar: avatar, quote: quote, rest: rest };
    }

    function setup(el) {
      var d = el.dataset;
      var items = Array.from(el.children);
      var n = items.length;
      if (!n) return;
      var interval = +d.interval || DEFAULTS.interval;
      var inThumb = !!el.closest('[aria-hidden="true"]');
      var current = 0, timer = null, paused = false, visible = false;

      if (!inThumb) {
        el.setAttribute('role', 'region');
        el.setAttribute('aria-roledescription', 'carousel');
      }
      items.forEach(function (it, i) {
        it.classList.toggle('tmr-off', i !== 0);
        if (!inThumb) it.setAttribute('aria-hidden', i === 0 ? 'false' : 'true');
      });

      var dots = [];
      if (d.controls !== 'false' && n > 1) {
        var bar = document.createElement('div');
        bar.className = 'tmr-dots';
        items.forEach(function (_, i) {
          var dot = document.createElement(inThumb ? 'span' : 'button');
          if (!inThumb) {
            dot.type = 'button';
            dot.setAttribute('aria-label', 'Show testimonial ' + (i + 1));
            dot.addEventListener('click', function () { show(i); restart(); });
          }
          bar.appendChild(dot);
          dots.push(dot);
        });
        el.appendChild(bar);
      }

      function mark() {
        dots.forEach(function (dot, i) {
          if (inThumb) dot.classList.toggle('tmr-on', i === current);
          else dot.setAttribute('aria-current', i === current ? 'true' : 'false');
        });
      }

      function show(i) {
        i = ((i % n) + n) % n;
        if (i === current) return;
        var out = items[current], inn = items[i], po = parts(out), pi = parts(inn);
        var dir = i > current ? 1 : -1;
        current = i;
        mark();
        if (!inThumb) {
          out.setAttribute('aria-hidden', 'true');
          inn.setAttribute('aria-hidden', 'false');
        }
        inn.classList.remove('tmr-off');
        if (reduced()) { out.classList.add('tmr-off'); return; }

        anime.utils.remove([out, inn, po.quote, po.avatar, pi.quote, pi.avatar].concat(po.rest, pi.rest).filter(Boolean));
        // Out: everything slides away the way we're going and fades.
        anime.animate(out, {
          x: -40 * dir, opacity: 0, duration: 380, ease: 'in(2)',
          onComplete: function () { out.classList.add('tmr-off'); anime.utils.set(out, { x: 0, opacity: 1 }); }
        });
        // In: the quote slides in, the avatar pops, then the name follows.
        anime.utils.set(inn, { x: 0, opacity: 1 });
        if (pi.quote) anime.animate(pi.quote, { x: [50 * dir, 0], opacity: [0, 1], duration: 650, delay: 220, ease: 'out(3)' });
        if (pi.avatar) anime.animate(pi.avatar, { scale: [0.4, 1], rotate: [-25 * dir, 0], opacity: [0, 1], duration: 900, delay: 380, ease: 'outElastic(1, .65)' });
        if (pi.rest.length) anime.animate(pi.rest, { y: [10, 0], opacity: [0, 1], duration: 500, delay: anime.stagger(60, { start: 480 }), ease: 'out(3)' });
      }
      el._tmr = function (i) { show(i); restart(); };

      function restart() {
        clearInterval(timer);
        timer = null;
        if (!visible || reduced() || n < 2) return;
        timer = setInterval(function () {
          if (!paused && !el.closest('[hidden]')) show(current + 1);
        }, interval);
      }

      if (!inThumb) {
        el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') paused = true; });
        el.addEventListener('pointerleave', function () { paused = false; });
        el.addEventListener('focusin', function () { paused = true; });
        el.addEventListener('focusout', function () { paused = false; });
      }

      mark();
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        restart();
      }).observe(el);
    }

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

    window.animacious = window.animacious || {};
    window.animacious.testimonial = function (el, i) { if (el && typeof el._tmr === 'function') el._tmr(i); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-testimonial-rotator aria-label="What people say">
  <figure>
    <blockquote>“Lovely to work with.”</blockquote>
    <figcaption><img src="ana.jpg" alt=""> <b>Ana Ruiz</b> <span>Designer</span></figcaption>
  </figure>
  <figure>…</figure>
  <figure>…</figure>
</div>