Pular para o conteúdo
Biblioteca CAR-01

Carrossel coverflow 3D

Card central inteiro e vizinhos girados em perspectiva, com setas, pontos, passagem automática e swipe. React puro, sem dependências.

usa o CAR-01
abrir sozinho ↗
Código
CAR-01 · alias coverflow
Função
Carrossel e depoimento
Fonte
21st.dev · Coverflow carousel
Recebido
16/09/2026
Status
adotado
Licença
não verificada

O código

coverflow.css + coverflow.js + markup.html 356 linhas · 3 arquivo(s) numa caixa só
/* ===== coverflow.css =============================================================== */

/* CAR-01 · Carrossel coverflow 3D — reescrito do original do 21st.dev sem React.
   Card central inteiro; ±1 e ±2 girados em perspectiva, menores e escurecidos; o resto some no
   centro. Mobile-first: o card encolhe para 76 vw mantendo a proporção 330 × 500, e a partir de
   768 px vale o tamanho da referência.
   O conteúdo do card é livre — o original é um depoimento, mas serve a qualquer cartão. */
.c-coverflow {
  --c-car-fundo-card: #141414;
  --c-car-linha: rgba(235, 235, 235, .14);
  --c-car-tinta: #ebebeb;
  --c-car-destaque: #e8ad32;
  --c-car-raio: 18px;
  --c-car-perspectiva: 1400px;
  --c-car-largura: min(330px, 76vw);
  --c-car-altura: calc(var(--c-car-largura) * 500 / 330);
  --c-car-gutter: 16px;
  --c-car-passo: calc(var(--c-car-largura) * .864);
  --c-car-passo-2: calc(var(--c-car-largura) * 1.545);
  --c-car-curva: cubic-bezier(.25, 1, .5, 1);

  position: relative;
  padding: 16px 0 8px;
  overflow: hidden;
  -webkit-user-select: none;
  user-select: none;
}
@media (min-width: 768px) { .c-coverflow { --c-car-largura: 330px; } }

.c-coverflow__palco {
  position: relative;
  height: calc(var(--c-car-altura) + 48px);
  perspective: var(--c-car-perspectiva);
  touch-action: pan-y;               /* o dedo na vertical rola a página; na horizontal, passa o card */
}
.c-coverflow__card {
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--c-car-largura);
  height: var(--c-car-altura);
  margin: calc(var(--c-car-altura) / -2) 0 0 calc(var(--c-car-largura) / -2);
  border: 1px solid var(--c-car-linha);
  border-radius: var(--c-car-raio);
  overflow: hidden;
  background: var(--c-car-fundo-card);
  transform: translate3d(0, 0, 0) scale(.4) rotateY(0deg);
  transform-origin: center center;
  opacity: 0;
  z-index: 0;
  filter: brightness(.4) blur(2px);
  pointer-events: none;
  transition: transform .8s var(--c-car-curva), opacity .8s var(--c-car-curva), filter .8s var(--c-car-curva);
  -webkit-tap-highlight-color: transparent;
}
.c-coverflow__card.is-central { transform: translate3d(0, 0, 0) scale(1) rotateY(0deg); opacity: 1; z-index: 30; filter: none; pointer-events: auto; }
.c-coverflow__card.is-dir-1 { transform: translate3d(var(--c-car-passo), 0, 0) scale(.84) rotateY(-24deg); opacity: .65; z-index: 20; filter: brightness(.75); pointer-events: auto; cursor: pointer; }
.c-coverflow__card.is-dir-2 { transform: translate3d(var(--c-car-passo-2), 0, 0) scale(.68) rotateY(-38deg); opacity: .38; z-index: 10; filter: brightness(.55) blur(1px); pointer-events: auto; cursor: pointer; }
.c-coverflow__card.is-esq-1 { transform: translate3d(calc(-1 * var(--c-car-passo)), 0, 0) scale(.84) rotateY(24deg); opacity: .65; z-index: 20; filter: brightness(.75); pointer-events: auto; cursor: pointer; }
.c-coverflow__card.is-esq-2 { transform: translate3d(calc(-1 * var(--c-car-passo-2)), 0, 0) scale(.68) rotateY(38deg); opacity: .38; z-index: 10; filter: brightness(.55) blur(1px); pointer-events: auto; cursor: pointer; }

/* conteúdo do card: foto cobrindo, véu para o texto ter contraste, e o texto só no central */
.c-coverflow__foto { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.c-coverflow__veu {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg, rgba(0, 0, 0, .40) 0%, rgba(0, 0, 0, .10) 25%, rgba(0, 0, 0, .68) 60%, rgba(0, 0, 0, .96) 100%);
}
.c-coverflow__conteudo {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  height: 100%;
  padding: 20px 18px 22px;
  text-align: center;
  color: var(--c-car-tinta);
  opacity: 0;
  transform: translateY(16px);
  transition: opacity .5s ease, transform .5s ease;
}
.c-coverflow__card.is-central .c-coverflow__conteudo { opacity: 1; transform: none; }
.c-coverflow__nome { font-size: 1.65rem; font-weight: 700; line-height: 1.1; text-transform: uppercase; letter-spacing: .02em; text-shadow: 0 3px 12px rgba(0, 0, 0, .95); }
.c-coverflow__papel { font-size: .9rem; font-weight: 600; line-height: 1.2; text-transform: uppercase; letter-spacing: .06em; text-shadow: 0 3px 10px rgba(0, 0, 0, .9); }
.c-coverflow__traco { width: 34px; height: 2px; margin: 5px auto 4px; border-radius: 2px; background-color: var(--c-car-destaque); }
.c-coverflow__texto { max-width: 280px; margin: 0 auto 6px; font-size: .88rem; line-height: 1.35; text-shadow: 0 2px 8px rgba(0, 0, 0, .9); }

/* setas redondas nas laterais e os pontos de paginação (o JS cria os pontos) */
.c-coverflow__seta {
  position: absolute;
  z-index: 40;
  top: calc(16px + (var(--c-car-altura) + 48px) / 2);
  transform: translateY(-50%);
  width: 46px;
  height: 46px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 1px solid var(--c-car-linha);
  border-radius: 50%;
  background-color: rgba(235, 235, 235, .08);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: var(--c-car-tinta);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background-color .2s ease;
}
.c-coverflow__seta:hover { background-color: rgba(235, 235, 235, .16); }
.c-coverflow__seta--anterior { left: var(--c-car-gutter); }
.c-coverflow__seta--proxima { right: var(--c-car-gutter); }
.c-coverflow__seta svg { width: 20px; height: 20px; }
.c-coverflow__pontos { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 8px; }
.c-coverflow__ponto {
  width: 8px; height: 8px; padding: 0; border: 0; border-radius: 999px;
  background-color: rgba(235, 235, 235, .25);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: width .3s ease, background-color .3s ease;
}
.c-coverflow__ponto[aria-selected="true"] { width: 28px; background-color: var(--c-car-destaque); }

/* o rótulo de leitor de tela, fora da tela mas na árvore */
.c-coverflow .c-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

@media (prefers-reduced-motion: reduce) {
  .c-coverflow__card, .c-coverflow__conteudo { transition: none; }
}


/* ===== coverflow.js ================================================================ */

/* CAR-01 · Coverflow 3D — reescrito do original do 21st.dev sem React.
   Cinco estados por classe (central, ±1, ±2; o resto some no centro), setas, pontos de paginação,
   passagem automática a cada 5 s, setas do teclado, swipe por pointer events e wheel horizontal.

   A passagem para quando: a seção sai da tela, a aba sai de foco, o mouse está em cima, o dedo
   está no card, o foco de TECLADO está num card (padrão APG — senão o foco, que segue o centro,
   pularia de card a cada 5 s), "reduzir movimento" está ligado, ou passou menos de 8 s de um toque.

   Três coisas que o original não tem e que foram pagas em produção:
     - **tabindex itinerante:** só o card central é focável, e o foco acompanha o centro;
     - **fim de arraste não é toque:** o clique depois de um swipe é engolido, senão um arraste
       disparava a ação do card;
     - **o lateral só vem ao centro:** clicar num vizinho não executa a ação dele.

   Nada de `aria-haspopup` nos cards: `role="group"` não o admite (ARIA 1.2 o permite em button,
   link, menuitem, combobox, gridcell e afins) e o axe o acusa como erro. Quem anuncia a ação do
   card central é o `aria-describedby`.

   API pública, em cada elemento `.c-coverflow` (use para integrar com modal, vídeo, o que for):
     el.__cCoverflow.irPara(i) · .pausar(ms) · .pausado(true|false) · .atual() · .total() */
(function () {
  'use strict';

  var INTERVALO = 5000;
  var PAUSA_TOQUE = 8000;
  var SWIPE = 45;
  var CLASSES = ['is-central', 'is-dir-1', 'is-dir-2', 'is-esq-1', 'is-esq-2'];
  var contador = 0;

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

  function preparar(cf) {
    var palco = cf.querySelector('.c-coverflow__palco');
    var cards = Array.prototype.slice.call(cf.querySelectorAll('.c-coverflow__card'));
    var anterior = cf.querySelector('.c-coverflow__seta--anterior');
    var proxima = cf.querySelector('.c-coverflow__seta--proxima');
    var pontos = cf.querySelector('.c-coverflow__pontos');
    var n = cards.length;
    if (!palco || !n) { return; }
    var atual = 0, visivel = false, pausadoAte = 0, travado = false, mouseEmCima = false, swipou = false, dedo = false;
    var timer = null, x0 = null, botoes = [];
    var acao = cf.getAttribute('data-acao') || '';      // texto anunciado para o card central; vazio = sem ação

    cards.forEach(function (c, i) {
      c.setAttribute('role', 'group');
      c.setAttribute('aria-roledescription', 'slide');
      c.setAttribute('aria-label', (i + 1) + ' de ' + n);
    });

    /* a descrição da ação do card central é um texto fora da tela e FORA da árvore (aria-hidden):
       não vira frase solta no modo de leitura, e o aria-describedby lê o texto que referencia mesmo
       oculto (accname 1.2) */
    var dica = null;
    if (acao) {
      dica = document.createElement('span');
      dica.id = 'c-coverflow-dica-' + (++contador);
      dica.className = 'c-sr';
      dica.setAttribute('aria-hidden', 'true');
      dica.textContent = acao;
      cf.appendChild(dica);
    }

    if (pontos) {
      cards.forEach(function (_, i) {
        var b = document.createElement('button');
        b.type = 'button';
        b.className = 'c-coverflow__ponto';
        b.setAttribute('role', 'tab');
        b.setAttribute('aria-label', 'Item ' + (i + 1) + ' de ' + n);
        b.addEventListener('click', function () { pausarPorToque(); irPara(i); });
        pontos.appendChild(b);
        botoes.push(b);
      });
    }

    function aplicar() {
      var focado = cards.indexOf(document.activeElement);
      cards.forEach(function (c, i) {
        var d = (i - atual + n) % n;
        var k = d === 0 ? 'is-central' : d === 1 ? 'is-dir-1' : d === 2 ? 'is-dir-2'
              : d === n - 1 ? 'is-esq-1' : d === n - 2 ? 'is-esq-2' : null;
        CLASSES.forEach(function (cl) { if (cl !== k) { c.classList.remove(cl); } });
        if (k) { c.classList.add(k); }
        c.setAttribute('aria-hidden', d === 0 ? 'false' : 'true');
        if (d === 0) {
          c.tabIndex = 0;
          if (dica) { c.setAttribute('aria-describedby', dica.id); }
        } else {
          c.tabIndex = -1;
          c.removeAttribute('aria-describedby');
        }
      });
      // o foco segue o centro: o card com foco não sai para a lateral escondido
      if (focado >= 0 && focado !== atual) { cards[atual].focus({ preventScroll: true }); }
      botoes.forEach(function (b, i) { b.setAttribute('aria-selected', i === atual ? 'true' : 'false'); });
      cf.setAttribute('data-atual', String(atual));
    }
    function irPara(i) { atual = ((i % n) + n) % n; aplicar(); }
    function pausarPorToque(ms) { pausadoAte = Date.now() + (ms || PAUSA_TOQUE); agendar(); }
    function focoDeTeclado() {
      if (cards.indexOf(document.activeElement) < 0) { return false; }
      try { return document.activeElement.matches(':focus-visible'); } catch (e) { return true; }
    }
    function podeAndar() {
      return visivel && !travado && !mouseEmCima && !dedo && !document.hidden
        && !reduzirMovimento() && Date.now() >= pausadoAte && !focoDeTeclado();
    }
    function agendar() {
      clearTimeout(timer);
      var espera = Math.max(INTERVALO, pausadoAte - Date.now());
      timer = setTimeout(function () { if (podeAndar()) { irPara(atual + 1); } agendar(); }, espera);
    }

    if (anterior) { anterior.addEventListener('click', function () { pausarPorToque(); irPara(atual - 1); }); }
    if (proxima) { proxima.addEventListener('click', function () { pausarPorToque(); irPara(atual + 1); }); }
    cards.forEach(function (c, i) {
      c.addEventListener('click', function (e) {
        if (swipou) { swipou = false; e.preventDefault(); e.stopPropagation(); return; }  // fim de arraste não é toque
        if (i !== atual) { e.stopPropagation(); pausarPorToque(); irPara(i); }            // o lateral só vem ao centro
      });
    });
    cf.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { mouseEmCima = true; } });
    cf.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') { mouseEmCima = false; } });
    /* o dedo no card pausa enquanto estiver lá e 8 s depois de soltar; sem a captura implícita do
       toque, um botão solto fora do palco deixaria o dedo preso */
    palco.addEventListener('pointerdown', function (e) {
      if (e.button === 0) { x0 = e.clientX; swipou = false; dedo = e.pointerType !== 'mouse'; pausarPorToque(); }
    });
    palco.addEventListener('pointerup', function (e) {
      dedo = false;
      if (x0 === null) { return; }
      var d = e.clientX - x0;
      x0 = null;
      pausarPorToque();
      if (Math.abs(d) > SWIPE) { swipou = true; irPara(d < 0 ? atual + 1 : atual - 1); }
    });
    palco.addEventListener('pointercancel', function () { x0 = null; dedo = false; pausarPorToque(); });
    /* no PC, deslizar na horizontal no trackpad passa os cards — só o wheel horizontal, com trava de
       600 ms entre passos; o vertical segue rolando a página */
    var rolagem = 0, travaAte = 0;
    cf.addEventListener('wheel', function (e) {
      if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) { return; }
      e.preventDefault();
      if (Date.now() < travaAte) { return; }
      rolagem += e.deltaX;
      if (Math.abs(rolagem) >= 40) {
        travaAte = Date.now() + 600;
        pausarPorToque();
        irPara(rolagem > 0 ? atual + 1 : atual - 1);
        rolagem = 0;
      }
    }, { passive: false });
    /* o teclado só responde com a seção na tela — é o que tira o conflito do ouvinte global do
       original, que roubava a seta de outro componente da mesma página */
    document.addEventListener('keydown', function (e) {
      if (!visivel) { return; }
      if (e.key === 'ArrowRight') { pausarPorToque(); irPara(atual + 1); }
      else if (e.key === 'ArrowLeft') { pausarPorToque(); irPara(atual - 1); }
    });
    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function (entradas) { visivel = entradas[0].isIntersecting; }, { threshold: 0.4 }).observe(cf);
    } else {
      visivel = true;
    }

    cf.__cCoverflow = {
      irPara: irPara,
      pausar: function (ms) { pausarPorToque(ms); },
      pausado: function (sim) { travado = !!sim; agendar(); },
      atual: function () { return atual; },
      total: function () { return n; }
    };

    aplicar();
    agendar();
  }

  function iniciar() { Array.prototype.forEach.call(document.querySelectorAll('.c-coverflow'), preparar); }

  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();


/* ===== markup.html ================================================================= */

<!-- CAR-01 · Coverflow. Os pontos de paginação são criados pelo JS dentro de .c-coverflow__pontos.
     data-acao é opcional: o texto anunciado para o card central quando ele tem ação (abrir um modal,
     por exemplo). Sem ele, o card não recebe aria-describedby. O conteúdo do card é livre. -->
<div class="c-coverflow" data-acao="Ampliar o depoimento">
  <div class="c-coverflow__palco">
    <figure class="c-coverflow__card">
      <img class="c-coverflow__foto" src="foto-1.jpg" alt="">
      <span class="c-coverflow__veu" aria-hidden="true"></span>
      <figcaption class="c-coverflow__conteudo">
        <span class="c-coverflow__nome">Marina Alves</span>
        <span class="c-coverflow__papel">Fotógrafa</span>
        <span class="c-coverflow__traco" aria-hidden="true"></span>
        <p class="c-coverflow__texto">Montei o ensaio em meia hora. A luz já estava pronta.</p>
      </figcaption>
    </figure>
    <figure class="c-coverflow__card">
      <img class="c-coverflow__foto" src="foto-2.jpg" alt="">
      <span class="c-coverflow__veu" aria-hidden="true"></span>
      <figcaption class="c-coverflow__conteudo">
        <span class="c-coverflow__nome">Rodrigo Dias</span>
        <span class="c-coverflow__papel">Diretor</span>
        <span class="c-coverflow__traco" aria-hidden="true"></span>
        <p class="c-coverflow__texto">Gravei três episódios num dia só.</p>
      </figcaption>
    </figure>
    <!-- cinco ou mais cards deixam os dois vizinhos de cada lado visíveis -->

    <button type="button" class="c-coverflow__seta c-coverflow__seta--anterior" aria-label="Anterior">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
    </button>
    <button type="button" class="c-coverflow__seta c-coverflow__seta--proxima" aria-label="Próximo">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>
    </button>
  </div>
  <div class="c-coverflow__pontos" role="tablist" aria-label="Itens"></div>
</div>

Já rodou em

  • trento-pedro · home v3 — seção 4, os depoimentos (leva 1 de correções, 16/09/2026)

Exige

  • react (useState, useEffect, useCallback, useRef)

Como se configura

  • --c-car-fundo-card
  • --c-car-linha
  • --c-car-tinta
  • --c-car-raio
  • --c-car-perspectiva
  • --c-car-altura

A ficha

O que o 21st.dev entregou

Recebido em 16/09/2026 (noite), para substituir o carrossel de depoimentos da seção 4: "os cards de depoimentos devem passar automaticamente e com o design apresentado no código", com mais cards hipotéticos para dar volume.

Dependências: só React (useState, useEffect, useCallback, useRef); ícones SVG inline. Fotos de demonstração num CDN do 21st.dev (pratos de um restaurante indiano — o demo é de cardápio).

Como funciona:

  • Palco de 520 px com perspective: 1400px; cada card é 330 × 500 px, cantos de 18 px, borda branca a 12 %.
  • Posições por deslocamento em relação ao atual: centro (escala 1, opacidade 1), ±1 (translateX(±285px) scale(.84) rotateY(∓24°), opacidade .65, brilho .75), ±2 (±510px, .68, ∓38°, opacidade .38, brilho .55 + desfoque); os demais somem no centro (scale(.4), opacidade 0). Transição all 800ms cubic-bezier(.25, 1, .5, 1).
  • Card: foto cobrindo, vinheta escura (0,4 → 0,1 → 0,68 → 0,96), conteúdo só no central (tag no canto superior direito; embaixo: título 1,65 rem/900 em caixa-alta, segunda linha, traço dourado de 34 × 2 px, descrição em itálico e um CTA pill dourado "View Menu").
  • Setas redondas de 46 px em vidro escuro nas laterais; pontos de paginação (o ativo alonga para 28 px em dourado).
  • Passagem automática a cada 5 s, pausada com o mouse em cima; setas do teclado (ouvinte global na janela); swipe por touchstart/touchend (45 px).
  • Fundo: a foto do card atual desfocada e escurecida atrás de tudo (brightness(.22) blur(32px)), com vinheta radial.

O que serve à Trento, e o que muda na reescrita (rodada de correções 1):

  • Entra inteiro o mecanismo: palco em perspectiva, os cinco estados, as transições, setas, pontos, passagem automática, swipe e teclado — reescritos em coverflow.js + depoimento.css, sem React.
  • O card vira depoimento: tag = o tipo de trabalho (#Retrato, #Moda…), título = o nome, segunda linha = o papel, descrição = o depoimento em itálico. O CTA por card sai (um depoimento não vende) e o dourado #c5a880 vira --tr-destaque-claro (nenhum hex novo).
  • A foto desfocada de fundo sai: feriria a regra das duas cores de fundo (§4 da especificação mobile). Os cards continuam sobre o degradê claro → escuro da seção.
  • 7 cards: os 3 de sempre e 4 hipotéticos com placeholder gerado localmente (degradê escuro com a inicial e o carimbo "FOTO HIPOTÉTICA"), todos data-placeholder="hipotetico".
  • Passagem automática também pausa fora da tela, com a foto ampliada aberta, com "reduzir movimento" e por 8 s depois de um toque, como os outros carrosséis da página (§10.3); o teclado só responde com a seção na tela.
  • No celular o card encolhe para caber em 390 px (76 vw), mantendo a proporção 330 × 500.

O que a adaptação da OCTA mudou

  • A passagem automática retoma 8 s depois de um toque, em vez de depender de hover
  • O teclado só responde com a seção na tela, o que tira o conflito do ouvinte global

Lacunas do original

  • A passagem automática pausa com o mouse em cima — no celular não há como pausar
  • O ouvinte de setas do teclado é global na janela: rouba a seta de outro componente da mesma página
  • As fotos de demonstração vêm de um CDN do 21st.dev (o demo é de cardápio de restaurante)