web design
COMPONENT Q / TESTIMONIAL CAROUSEL · OVERLAPPING PORTRAITS + KEYS

GUIDE / TESTIMONIAL CAROUSEL

Overview

An overlapping-portrait testimonial carousel. A ghost portrait peeks the next quote behind the main one, and the main portrait re-rises on every change. Arrows plus position dots are real buttons, ArrowLeft and ArrowRight move while focus is within the group, and the quote is an aria-live region so changes announce.

When to use

Social proof, three to six quotes. Portraits are decorative placeholders; never advance automatically.

Spec

Tokens
--gold-soft quotes, --gold dot, --line arrows
Motion
portrait re-rises 1.15s on change; dots .4s
A11y
real buttons, aria-live, aria-current, keys L/R
Layout
5/7 grid, aspect 4/5 portrait

Anti-patterns

No auto-advance. No keyboard-inaccessible swipe-only controls. Do not drop the ghost portrait, it telegraphs the next quote.

<style>
:root{
  --bg:#0b0a08;--surface:#14110b;--ivory:#efe8d6;--muted:#9c9078;--faint:#5a5446;
  --line:#2a2519;--line-faint:rgba(239,232,214,.08);--gold:#c9a24b;--gold-soft:#e7cd8c;
  --font-display:'Instrument Serif',ui-serif,Georgia,'Times New Roman',serif;
  --font-title:'Libre Caslon Condensed',ui-serif,Georgia,'Times New Roman',serif;
  --font-body:'Inter',system-ui,-apple-system,sans-serif;
  --font-mono:'JetBrains Mono',ui-monospace,monospace;
  --ease:cubic-bezier(.22,1,.36,1);--dur-fast:200ms;--dur-base:400ms;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--ivory);font-family:var(--font-body);min-height:100vh;padding:64px 48px}
@keyframes hf-rise{from{opacity:0;transform:translateY(22px)}to{opacity:1;transform:translateY(0)}}
.animate-rise{opacity:0;animation:hf-rise 1.15s var(--ease) forwards}
.tc{display:grid;grid-template-columns:1fr;gap:40px;max-width:1040px;margin:0 auto}
@media(min-width:768px){.tc{grid-template-columns:5fr 7fr;gap:56px;align-items:center}}
.tc-port{display:flex;justify-content:center}
.tc-frame{position:relative;aspect-ratio:4/5;width:100%;max-width:20rem}
.tc-ghost{position:absolute;inset:0;border-radius:1rem;border:1px solid var(--line);background-size:cover;background-position:center;opacity:.4;filter:grayscale(1);transform:translate(1.25rem,-1.25rem)}
.tc-main{position:relative;height:100%;width:100%;border-radius:1rem;border:1px solid var(--line);object-fit:cover;opacity:.9;filter:contrast(1.25) grayscale(1)}
.tc-quote{font-family:var(--font-title);font-size:1.5rem;line-height:1.35;color:var(--ivory);font-weight:400}
@media(min-width:768px){.tc-quote{font-size:2.25rem;line-height:1.25}}
.tc-quote .q{color:var(--gold-soft)}
.tc-foot{display:flex;align-items:center;gap:16px;margin-top:28px}
.tc-bar{height:1px;width:2rem;background:rgba(201,162,75,.6)}
.tc-cite{font-style:normal}
.tc-name{display:block;font-size:.875rem;font-weight:500;letter-spacing:.01em;color:var(--ivory)}
.tc-role{display:block;margin-top:4px;font-family:var(--font-mono);font-size:.75rem;text-transform:uppercase;letter-spacing:.2em;color:var(--faint)}
.tc-ctrl{display:flex;align-items:center;gap:20px;margin-top:40px}
.tc-arrow{display:flex;align-items:center;justify-content:center;height:2.75rem;width:2.75rem;border-radius:9999px;border:1px solid var(--line);color:var(--muted);background:none;cursor:pointer;transition:border-color var(--dur-base) var(--ease),color var(--dur-base) var(--ease)}
.tc-arrow:hover{border-color:rgba(201,162,75,.6);color:var(--gold-soft)}
.tc-arrow:focus-visible{outline:2px solid var(--gold);outline-offset:3px}
.tc-dots{display:flex;align-items:center;gap:8px;margin-left:8px}
.tc-dot{height:.375rem;width:.375rem;border-radius:9999px;border:0;background:var(--line);cursor:pointer;padding:0;transition:width var(--dur-base) var(--ease),background var(--dur-base) var(--ease)}
.tc-dot:hover{background:var(--muted)}
.tc-dot[aria-current="true"]{width:1.5rem;background:var(--gold)}
.tc-dot:focus-visible{outline:2px solid var(--gold);outline-offset:3px}
@media(prefers-reduced-motion:reduce){.animate-rise{opacity:1;animation:none}}
</style>

<div class="tc" role="group" aria-roledescription="carousel" aria-label="Member testimonials" id="tc">
  <div class="tc-port">
    <div class="tc-frame">
      <div class="tc-ghost" id="ghost"></div>
      <img class="tc-main animate-rise" id="portrait" src="" alt="" aria-hidden="true">
    </div>
  </div>
  <div>
    <blockquote class="tc-quote" aria-live="polite"><p><span class="q">&ldquo;</span><span id="quote"></span><span class="q">&rdquo;</span></p></blockquote>
    <footer class="tc-foot">
      <span class="tc-bar" aria-hidden="true"></span>
      <cite class="tc-cite">
        <span class="tc-name" id="name"></span>
        <span class="tc-role" id="role"></span>
      </cite>
    </footer>
    <div class="tc-ctrl">
      <button class="tc-arrow" id="prev" aria-label="Previous testimonial" type="button">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M15 5l-7 7 7 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
      </button>
      <button class="tc-arrow" id="next" aria-label="Next testimonial" type="button">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
      </button>
      <div class="tc-dots" id="dots"></div>
    </div>
  </div>
</div>

<script>
var DATA = [
  {quote:"I stopped guessing. The backtests are honest, and for the first time my edge survived contact with the market.", name:"A. Vance", role:"Systematic trader", seed:"vance-hf"},
  {quote:"These are the same tools my old desk guarded like state secrets now they sit on my laptop.", name:"M. Okafor", role:"Ex-prop quant", seed:"okafor-hf"},
  {quote:"Research to live deployment in an afternoon. The loop just compounds.", name:"S. Linde", role:"Independent PM", seed:"linde-hf"},
  {quote:"The Quant-Agent reads the whole library so I do not have to. It is like pairing with a senior researcher.", name:"R. Haas", role:"Developer, turned trader", seed:"haas-hf"}
];
var n = DATA.length, i = 0;
var portrait = document.getElementById("portrait");
var ghost = document.getElementById("ghost");
var quoteEl = document.getElementById("quote");
var nameEl = document.getElementById("name");
var roleEl = document.getElementById("role");
var dotsEl = document.getElementById("dots");

function url(seed){ return "https://picsum.photos/seed/" + seed + "/640/800"; }

DATA.forEach(function(t, idx){
  var b = document.createElement("button");
  b.type = "button";
  b.className = "tc-dot";
  b.setAttribute("aria-label", "Show testimonial " + (idx + 1));
  b.addEventListener("click", function(){ render(idx); });
  dotsEl.appendChild(b);
});

function render(idx){
  i = idx;
  var t = DATA[i], next = DATA[(i + 1) % n];
  portrait.src = url(t.seed);
  // re-trigger the rise animation on change
  portrait.classList.remove("animate-rise");
  void portrait.offsetWidth;
  portrait.classList.add("animate-rise");
  ghost.style.backgroundImage = "url(" + url(next.seed) + ")";
  quoteEl.textContent = t.quote;
  nameEl.textContent = t.name;
  roleEl.textContent = t.role;
  var dots = dotsEl.children;
  for(var k = 0; k < dots.length; k++){
    dots[k].setAttribute("aria-current", k === i ? "true" : "false");
  }
}
function go(d){ render((i + d + n) % n); }

document.getElementById("prev").addEventListener("click", function(){ go(-1); });
document.getElementById("next").addEventListener("click", function(){ go(1); });
document.getElementById("tc").addEventListener("keydown", function(e){
  if(e.key === "ArrowLeft"){ e.preventDefault(); go(-1); }
  else if(e.key === "ArrowRight"){ e.preventDefault(); go(1); }
});

render(0);
</script>