Copied!
Band

Testimonial card carousel

Quote cards with the active one centred at full size, faded neighbours peeking out each side, arrows, dots and a gentle autoplay.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

BandElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Testimonial card carousel" for my site using the Enrichr Library element at https://enrichr.dev/elements/testimonial-card-carousel.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/testimonial-card-carousel.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- heading: Kind words
- subheading: What people say after a build
- quotes: The hero finally moves the way the brief described it. | We swapped three plugins for one small script, and the page got faster. | People notice the motion before they read the copy, in a good way. | Set up in an afternoon, tweaked for a week, shipped on Friday. | It reads well with a screen reader and still feels lively. | Our landing page stopped looking like everyone else's.
- names: A designer, Leeds | A developer, Lisbon | A studio lead, Toronto | A founder, Melbourne | An accessibility tester, Dublin | A marketer, Austin
- autoplay: 5
- duration: 0.6
- accent: #126e45
- background: #fbf8f1
- card: #ffffff
- text: #0c1e18

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/testimonial-card-carousel.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-tcc">
  <div class="ef-tcc__head">
    <h2 class="ef-tcc__h">Kind words</h2>
    <p class="ef-tcc__sub">What people say after a build</p>
  </div>
  <div class="ef-tcc__gallery" role="region" aria-roledescription="carousel" aria-label="Testimonials, use the left and right arrow keys" tabindex="0">
    <ul class="ef-tcc__cards"></ul>
  </div>
  <div class="ef-tcc__actions">
    <button type="button" class="ef-tcc__arrow ef-tcc__prev" aria-label="Previous testimonial"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
    <div class="ef-tcc__dots"></div>
    <button type="button" class="ef-tcc__arrow ef-tcc__next" aria-label="Next testimonial"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
  </div>
  <p class="ef-tcc__live" aria-live="polite"></p>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-tcc { --ef-tcc-acc: #126e45; --ef-tcc-bg: #fbf8f1; --ef-tcc-card: #ffffff; --ef-tcc-text: #0c1e18; --ef-tcc-dim: color-mix(in srgb, var(--ef-tcc-text) 55%, var(--ef-tcc-card)); position: relative; width: 100%; box-sizing: border-box; min-height: 26rem; padding: 70px 20px; overflow: hidden; border-radius: 1rem; background: var(--ef-tcc-bg); color: var(--ef-tcc-text); font-family: 'Instrument Sans', system-ui, sans-serif; container-type: inline-size; }
.ef-tcc *, .ef-tcc *::before, .ef-tcc *::after { box-sizing: border-box; }
.ef-tcc .ef-tcc__head { position: relative; max-width: 1140px; margin: 0 auto 30px; padding-bottom: 15px; text-align: center; }
.ef-tcc .ef-tcc__head::after { content: ""; position: absolute; left: 50%; bottom: 0; width: 100px; height: 3px; transform: translateX(-50%); background: var(--ef-tcc-acc); }
.ef-tcc .ef-tcc__h { margin: 0; padding: 0; opacity: 1; color: var(--ef-tcc-acc); font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(34px, 7cqw, 56px); font-weight: 800; line-height: 1.15; letter-spacing: normal; text-transform: uppercase; }
.ef-tcc .ef-tcc__sub { margin: 0; padding: 0; opacity: 1; color: var(--ef-tcc-text); font-size: 18px; line-height: 28px; }
.ef-tcc .ef-tcc__gallery { position: relative; width: 100%; height: 380px; perspective: 900px; outline: none; border-radius: 26px; }
.ef-tcc .ef-tcc__gallery:focus-visible { box-shadow: 0 0 0 3px var(--ef-tcc-acc); }
.ef-tcc .ef-tcc__cards { position: absolute; top: 50%; left: 50%; width: 320px; height: 320px; margin: -160px 0 0 -160px; padding: 0; list-style: none; }
.ef-tcc .ef-tcc__card { position: absolute; top: 0; left: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; margin: 0; padding: 30px; list-style: none; text-align: center; opacity: 1; background: var(--ef-tcc-card); border: 2px solid var(--ef-tcc-acc); border-radius: 26px; box-shadow: 0 20px 40px color-mix(in srgb, var(--ef-tcc-acc) 15%, transparent); will-change: transform, opacity; }
.ef-tcc .ef-tcc__q { display: block; width: 30px; height: 30px; margin-bottom: 14px; color: var(--ef-tcc-acc); }
.ef-tcc .ef-tcc__stars { margin-bottom: 14px; color: var(--ef-tcc-acc); font-size: 16px; line-height: 1; letter-spacing: 3px; }
.ef-tcc .ef-tcc__quote { margin: 0 0 14px; padding: 0; opacity: 1; color: var(--ef-tcc-text); font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: 15px; font-weight: 600; line-height: 23px; }
.ef-tcc .ef-tcc__who { color: var(--ef-tcc-dim); font-size: 12px; letter-spacing: 0.5px; }
.ef-tcc .ef-tcc__actions { position: relative; display: flex; align-items: center; justify-content: center; gap: 22px; padding-top: 30px; }
.ef-tcc .ef-tcc__arrow { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 44px; height: 44px; padding: 0; border: 2px solid var(--ef-tcc-acc); border-radius: 50%; background: var(--ef-tcc-card); color: var(--ef-tcc-acc); cursor: pointer; transition: 0.2s; }
.ef-tcc .ef-tcc__arrow svg { width: 18px; height: 18px; }
.ef-tcc .ef-tcc__arrow:hover { background: var(--ef-tcc-acc); color: var(--ef-tcc-card); }
.ef-tcc .ef-tcc__arrow:focus-visible, .ef-tcc .ef-tcc__dot:focus-visible { outline: 3px solid var(--ef-tcc-acc); outline-offset: 3px; }
.ef-tcc .ef-tcc__dots { display: flex; gap: 8px; }
.ef-tcc .ef-tcc__dot { position: relative; width: 8px; height: 8px; padding: 0; border: none; border-radius: 26px; background: var(--ef-tcc-acc); opacity: 0.35; cursor: pointer; transition: 0.3s; }
.ef-tcc .ef-tcc__dot::before { content: ""; position: absolute; inset: -10px -4px; }
.ef-tcc .ef-tcc__dot.is-active { width: 24px; opacity: 1; }
.ef-tcc .ef-tcc__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@container (max-width: 560px) {
  .ef-tcc .ef-tcc__gallery { height: 320px; }
  .ef-tcc .ef-tcc__cards { width: 240px; height: 280px; margin: -140px 0 0 -120px; }
  .ef-tcc .ef-tcc__card { padding: 22px; border-radius: 20px; }
}

JavaScript

// Enrichr Library · https://enrichr.dev
// Testimonial card carousel: quote cards stacked in the middle, with the active card full size in front, one smaller faded
// card peeking out on each side and the rest hidden further out. Each card's place is its signed distance from the active
// card around the loop: 0 is centred at full size, -1 and 1 sit 95% of a card width out at 75% size and 45% opacity, and
// anything further waits 150% out at 60% size, invisible. Every change tweens each card from wherever it is now to its new
// place over 0.6 s on a cubic out curve (GSAP power2.out), so quick clicks never jump. It advances every 5 s, pauses
// while the pointer is over the cards or focus is inside, and the arrows, dots and arrow keys restart the timer.
// Reduced motion moves cards without the tween and turns autoplay off. Autoplay also waits off screen and in hidden tabs.
// mount(root, { heading, subheading, quotes (separated by |), names (separated by |), autoplay (seconds, 0 is off),
//               duration (s), accent, background, card, text })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-tcc");
  var head = root.querySelector(".ef-tcc__h");
  var sub = root.querySelector(".ef-tcc__sub");
  var gallery = root.querySelector(".ef-tcc__gallery");
  var list = root.querySelector(".ef-tcc__cards");
  var dotsWrap = root.querySelector(".ef-tcc__dots");
  var prevBtn = root.querySelector(".ef-tcc__prev");
  var nextBtn = root.querySelector(".ef-tcc__next");
  var live = root.querySelector(".ef-tcc__live");
  var QUOTE = '<path d="M119.472,66.59C53.489,66.59,0,120.094,0,186.1c0,65.983,53.489,119.487,119.472,119.487c0,0-0.578,44.392-36.642,108.284c-4.006,12.802,3.135,26.435,15.945,30.418c9.089,2.859,18.653,0.08,24.829-6.389c82.925-90.7,115.385-197.448,115.385-251.8C238.989,120.094,185.501,66.59,119.472,66.59z"/><path d="M392.482,66.59c-65.983,0-119.472,53.505-119.472,119.51c0,65.983,53.489,119.487,119.472,119.487c0,0-0.578,44.392-36.642,108.284c-4.006,12.802,3.136,26.435,15.945,30.418c9.089,2.859,18.653,0.08,24.828-6.389C479.539,347.2,512,240.452,512,186.1C512,120.094,458.511,66.59,392.482,66.59z"/>';
  var opts = {}, cards = [], dots = [], current = 0, sig = "", raf = 0, timer = 0, io = null;
  var visible = false, started = false, hovering = false, focused = false, destroyed = false, offs = [];

  function out3(p) { return 1 - Math.pow(1 - p, 3); } // power2.out
  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function split(s) { return String(s || "").split("|").map(function (x) { return x.trim(); }); }
  function on(el, ev, fn, opt) { el.addEventListener(ev, fn, opt); offs.push(function () { el.removeEventListener(ev, fn, opt); }); }

  function build() {
    list.textContent = ""; dotsWrap.textContent = ""; cards = []; dots = [];
    var qs = split(opts.quotes).filter(Boolean), ns = split(opts.names);
    if (!qs.length) qs = ["Add a quote"];
    qs.slice(0, 12).forEach(function (q, i) {
      var li = document.createElement("li"); li.className = "ef-tcc__card";
      li.setAttribute("role", "group"); li.setAttribute("aria-roledescription", "slide");
      li.setAttribute("aria-label", (i + 1) + " of " + Math.min(qs.length, 12));
      li.innerHTML = '<svg class="ef-tcc__q" viewBox="0 0 512 512" aria-hidden="true" focusable="false" fill="currentColor">' + QUOTE + '</svg>' +
        '<div class="ef-tcc__stars" role="img" aria-label="Five stars">★★★★★</div><p class="ef-tcc__quote"></p><div class="ef-tcc__who"></div>';
      li.querySelector(".ef-tcc__quote").textContent = "“" + q.replace(/^["“]|["”]$/g, "") + "”";
      li.querySelector(".ef-tcc__who").textContent = ns[i] ? "— " + ns[i] : "";
      list.appendChild(li);
      // GSAP starts every card from its CSS state: centred, full size, opaque
      cards.push({ el: li, cur: { x: 0, s: 1, o: 1, z: 0 }, from: null, to: null, t0: 0 });
      var d = document.createElement("button"); d.type = "button"; d.className = "ef-tcc__dot";
      d.setAttribute("aria-label", "Show testimonial " + (i + 1));
      d.addEventListener("click", function () { goTo(i, true); restartAutoplay(); });
      dotsWrap.appendChild(d); dots.push(d);
    });
    current = Math.min(current, cards.length - 1);
  }
  function getDiff(idx) {
    var total = cards.length, diff = idx - current;
    if (diff > total / 2) diff -= total;
    if (diff < -total / 2) diff += total;
    return diff;
  }
  function apply(c) {
    c.el.style.transform = "translate(" + c.cur.x.toFixed(3) + "%,0) scale(" + c.cur.s.toFixed(4) + ")";
    c.el.style.opacity = c.cur.o.toFixed(4);
    c.el.style.zIndex = String(Math.round(c.cur.z));
  }
  function render(instant) {
    var now = performance.now();
    cards.forEach(function (c, idx) {
      var diff = getDiff(idx), v;
      if (diff === 0) v = { x: 0, s: 1, o: 1, z: 5 };
      else if (diff === -1) v = { x: -95, s: 0.75, o: 0.45, z: 3 };
      else if (diff === 1) v = { x: 95, s: 0.75, o: 0.45, z: 3 };
      else if (diff < -1) v = { x: -150, s: 0.6, o: 0, z: 1 };
      else v = { x: 150, s: 0.6, o: 0, z: 1 };
      if (diff === 0) c.el.removeAttribute("aria-hidden"); else c.el.setAttribute("aria-hidden", "true");
      if (instant || reduce) { c.cur = v; c.from = null; apply(c); }
      else { c.from = { x: c.cur.x, s: c.cur.s, o: c.cur.o, z: c.cur.z }; c.to = v; c.t0 = now; }
    });
    dots.forEach(function (d, i) { d.classList.toggle("is-active", i === current); if (i === current) d.setAttribute("aria-current", "true"); else d.removeAttribute("aria-current"); });
    if (!raf && !reduce && !instant) raf = requestAnimationFrame(tick);
  }
  function tick(now) {
    raf = 0;
    var dur = Math.max(0.05, opts.duration) * 1000, busy = false;
    cards.forEach(function (c) {
      if (!c.from) return;
      var p = Math.min(1, Math.max(0, (now - c.t0) / dur)), e = out3(p);
      for (var k in c.to) c.cur[k] = c.from[k] + (c.to[k] - c.from[k]) * e;
      apply(c);
      if (p >= 1) c.from = null; else busy = true;
    });
    if (busy) raf = requestAnimationFrame(tick);
  }
  function goTo(i, announce) {
    var n = cards.length;
    current = ((i % n) + n) % n;
    render(false);
    if (announce) live.textContent = "Testimonial " + (current + 1) + " of " + n;
  }
  function next(a) { goTo(current + 1, a); }
  function prev(a) { goTo(current - 1, a); }

  // autoplay: a plain timeout chain that only runs while it makes sense to move things for the reader
  function canPlay() { return started && !reduce && opts.autoplay > 0 && visible && !hovering && !focused && !document.hidden && cards.length > 1; }
  function stopAutoplay() { clearTimeout(timer); timer = 0; }
  function startAutoplay() { stopAutoplay(); if (canPlay()) timer = setTimeout(function () { timer = 0; next(false); startAutoplay(); }, opts.autoplay * 1000); }
  function restartAutoplay() { startAutoplay(); }

  on(nextBtn, "click", function () { next(true); restartAutoplay(); });
  on(prevBtn, "click", function () { prev(true); restartAutoplay(); });
  on(gallery, "mouseenter", function () { hovering = true; stopAutoplay(); });
  on(gallery, "mouseleave", function () { hovering = false; startAutoplay(); });
  on(box, "focusin", function () { focused = true; stopAutoplay(); });
  on(box, "focusout", function (e) { if (!box.contains(e.relatedTarget)) { focused = false; startAutoplay(); } });
  on(gallery, "keydown", function (e) {
    if (e.key === "ArrowRight") { e.preventDefault(); next(true); }
    else if (e.key === "ArrowLeft") { e.preventDefault(); prev(true); }
  });
  on(document, "visibilitychange", function () { if (document.hidden) stopAutoplay(); else startAutoplay(); });

  function begin() { if (started || destroyed) return; started = true; render(false); startAutoplay(); }

  function update(partial) {
    opts = Object.assign({
      heading: "Kind words", subheading: "What people say after a build",
      quotes: "The hero finally moves the way the brief described it. | We swapped three plugins for one small script, and the page got faster. | People notice the motion before they read the copy, in a good way. | Set up in an afternoon, tweaked for a week, shipped on Friday. | It reads well with a screen reader and still feels lively. | Our landing page stopped looking like everyone else's.",
      names: "A designer, Leeds | A developer, Lisbon | A studio lead, Toronto | A founder, Melbourne | An accessibility tester, Dublin | A marketer, Austin",
      autoplay: 5, duration: 0.6, accent: "#126e45", background: "#fbf8f1", card: "#ffffff", text: "#0c1e18"
    }, opts, partial || {});
    opts.autoplay = Math.max(0, num(opts.autoplay, 5)); opts.duration = Math.max(0.05, num(opts.duration, 0.6));
    head.textContent = opts.heading; sub.textContent = opts.subheading;
    box.style.setProperty("--ef-tcc-acc", opts.accent);
    box.style.setProperty("--ef-tcc-bg", opts.background);
    box.style.setProperty("--ef-tcc-card", opts.card);
    box.style.setProperty("--ef-tcc-text", opts.text);
    var nsig = opts.quotes + "\u0001" + opts.names;
    if (nsig !== sig) { sig = nsig; build(); if (started) render(true); }
    if (started) startAutoplay();
  }

  update(o);
  if (!("IntersectionObserver" in window)) { visible = true; begin(); }
  else {
    io = new IntersectionObserver(function (en) { visible = en[0].isIntersecting; if (visible) begin(); if (visible) startAutoplay(); else stopAutoplay(); }, { threshold: 0.2 });
    io.observe(box);
  }
  return {
    update: update,
    destroy: function () { destroyed = true; stopAutoplay(); cancelAnimationFrame(raf); raf = 0; if (io) io.disconnect(); offs.forEach(function (f) { f(); }); offs = []; }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"heading":"Kind words","subheading":"What people say after a build","quotes":"The hero finally moves the way the brief described it. | We swapped three plugins for one small script, and the page got faster. | People notice the motion before they read the copy, in a good way. | Set up in an afternoon, tweaked for a week, shipped on Friday. | It reads well with a screen reader and still feels lively. | Our landing page stopped looking like everyone else's.","names":"A designer, Leeds | A developer, Lisbon | A studio lead, Toronto | A founder, Melbourne | An accessibility tester, Dublin | A marketer, Austin","autoplay":5,"duration":0.6,"accent":"#126e45","background":"#fbf8f1","card":"#ffffff","text":"#0c1e18"});

How it works

All the cards sit on top of each other in the middle. Each card's place is its signed distance from the active card around the loop, so the deck never runs out: 0 is centred at full size, -1 and 1 sit 95% of a card width to each side at 75% size and 45% opacity, and anything further waits 150% out at 60% size, fully faded. Every change starts a tween on each card from wherever it is right now to its new place, over 0.6 seconds on a cubic ease-out (GSAP's power2.out), so fast clicking never jumps. Autoplay steps forward every 5 seconds; the arrows and dots restart the timer.

Accessibility and performance

Autoplay pauses while the pointer is over the cards, while focus is anywhere inside the element, while it is off screen and in a hidden tab, and it is off entirely with prefers-reduced-motion, where cards also move without the tween. The card area is a focusable carousel region that the Left and Right arrow keys move. Arrows and dots are real buttons with labels, the current dot is marked, side cards are hidden from assistive technology and changes made by the reader are announced politely. The star row reads as five stars.

Only transform, opacity and z-index change. The animation loop runs only while a tween is in progress and stops when the cards settle; autoplay is a single timeout.

SectionBand
TechniquesCarousel
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byTestimonials by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay. Image drag with tiltA row of generated art cards you drag with the pointer, tilting and skewing with speed, gliding on release and easing back from the ends. WebGL image transitionThree generated slides that change with a WebGL shader transition: dissolve, wipe-warp or pixel.

FAQ

Are the quotes real?

No. They are neutral sample lines that do not name real people or companies. Replace them with your own, separated by a vertical bar, and put the matching names in the same order.

How many quotes can it hold?

Up to twelve. With three or more you get a card on each side; with five or more, the cards waiting off to the sides slide in smoothly as you move.

Does it need GSAP?

No. The tween is a small requestAnimationFrame loop with the same cubic ease-out curve GSAP calls power2.out.

Can I stop the autoplay?

Yes. Set Autoplay to 0. It also never runs for people who prefer reduced motion.