← Biblioteca componentes/midia/cartoes-3d/ficha.md CódigoTela cheia
midia/cartoes-3d

Cartões 3D em tambor

🟢 estavel complexidade alta prefixo crt- cartao cartao-de-credito 3d perspectiva tambor arrasto teclado virar institucional campanha produto acabamentos

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/midia/cartoes-3d/estilo.css">
<script src="componentes/midia/cartoes-3d/script.js"></script>

markup.html · 171 linhas · 10,7 KB

<!-- ==========================================================================
     CARTÕES 3D — o tambor de cartões que gira, para campanha institucional
     Prefixo: crt-
     Requer: core (tokens, base, motion.css, motion.js) + este estilo.css e
             este script.js

     Seis cartões num cilindro que gira em torno do eixo HORIZONTAL: o da
     frente de pé, o de cima e o de baixo inclinados e recuando, como as
     câmaras de um tambor. Ao lado, o nome e a descrição do acabamento em
     destaque. Para lançamento de cartão, níveis de relacionamento, uma linha
     de produtos com "cara de objeto".

     O TAMBOR GIRA SÓ QUANDO A PESSOA PEDE: arrasto (vertical no mouse,
     horizontal no dedo), as duas setas, os pontos, ↑ ↓ no teclado. Tocar no
     cartão da frente o vira e mostra o verso. A roda do mouse continua
     rolando a página, e nada se move no hover (CLAUDE.md §6).

     PARA A ROLAGEM GIRAR O TAMBOR, acrescente data-crt-rolagem na <section>:
     a seção fica alta, a grade gruda e a página gira do primeiro ao último
     cartão. Só em tela larga e sem movimento reduzido.

     OS ACABAMENTOS SAEM DOS TOKENS (--dark, --light, --light-2, --accent...):
     trocar o tema troca os cartões. Nenhuma cor escrita à mão.

     SEM JAVASCRIPT o tambor aparece parado, com o primeiro cartão de frente,
     e os controles não aparecem.
     ========================================================================== -->

<section class="sec-dark sec-pad crt" data-crt>
  <div class="wrap crt-grade">

    <div class="crt-texto">
      <span class="eyebrow" data-rv>Cartão Private</span>
      <h2 class="sec-title ttl">Seis acabamentos, <em>um cartão só</em></h2>
      <p class="sec-lead" data-rv data-d="1">Metal escovado, numeração no verso e o seu nome gravado. Você escolhe o acabamento ao abrir o relacionamento.</p>
      <a class="btn btn-primary crt-cta" href="#conhecer" data-rv data-d="2">
        Quero conhecer
        <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 7H12M8 3L12 7L8 11"/></svg>
      </a>

      <div class="crt-rotulo" data-rv data-d="3">
        <p class="crt-conta" data-crt-conta aria-hidden="true">01 — 06</p>
        <p class="crt-nome" data-crt-nome>Grafite</p>
        <p class="crt-desc" data-crt-desc>Metal escovado, acabamento fosco</p>
      </div>
      <p class="crt-dica" data-crt-dica hidden>Arraste, use as setas ou toque no cartão para ver o verso.</p>
      <p class="visually-hidden" data-crt-vivo aria-live="polite"></p>
    </div>

    <div class="crt-palco-slot" data-rv data-d="1">
      <div class="crt-palco" data-crt-palco tabindex="0" role="region" aria-roledescription="carrossel" aria-label="Acabamentos do cartão">
        <div class="crt-tambor">

          <div class="crt-cartao crt-acab--grafite" style="--i:0" data-nome="Grafite" data-desc="Metal escovado, acabamento fosco" role="group" aria-roledescription="slide" aria-label="1 de 6: Grafite">
            <div class="crt-giro">
              <div class="crt-face crt-frente">
                <span class="crt-marca">Meridiano <b>Private</b></span>
                <span class="crt-chip" aria-hidden="true"></span>
                <svg class="crt-onda" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M8.5 7.5a6 6 0 0 1 0 9M12 5a9.5 9.5 0 0 1 0 14M15.5 2.5a13 13 0 0 1 0 19"/></svg>
                <span class="crt-numero">•••• 6769</span>
                <span class="crt-titular">Ricardo Couto</span>
              </div>
              <div class="crt-face crt-verso" aria-hidden="true">
                <span class="crt-tarja"></span>
                <span class="crt-assina">•••</span>
                <span class="crt-legal">Cartão de demonstração. Emissão sujeita a análise de crédito e às condições do contrato.</span>
              </div>
            </div>
          </div>

          <div class="crt-cartao crt-acab--marfim" style="--i:1" data-nome="Marfim" data-desc="Cerâmica clara, toque acetinado" role="group" aria-roledescription="slide" aria-label="2 de 6: Marfim">
            <div class="crt-giro">
              <div class="crt-face crt-frente">
                <span class="crt-marca">Meridiano <b>Private</b></span>
                <span class="crt-chip" aria-hidden="true"></span>
                <svg class="crt-onda" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M8.5 7.5a6 6 0 0 1 0 9M12 5a9.5 9.5 0 0 1 0 14M15.5 2.5a13 13 0 0 1 0 19"/></svg>
                <span class="crt-numero">•••• 6769</span>
                <span class="crt-titular">Ricardo Couto</span>
              </div>
              <div class="crt-face crt-verso" aria-hidden="true">
                <span class="crt-tarja"></span>
                <span class="crt-assina">•••</span>
                <span class="crt-legal">Cartão de demonstração. Emissão sujeita a análise de crédito e às condições do contrato.</span>
              </div>
            </div>
          </div>

          <div class="crt-cartao crt-acab--casa" style="--i:2" data-nome="Assinatura" data-desc="Na cor da casa, anodizado" role="group" aria-roledescription="slide" aria-label="3 de 6: Assinatura">
            <div class="crt-giro">
              <div class="crt-face crt-frente">
                <span class="crt-marca">Meridiano <b>Private</b></span>
                <span class="crt-chip" aria-hidden="true"></span>
                <svg class="crt-onda" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M8.5 7.5a6 6 0 0 1 0 9M12 5a9.5 9.5 0 0 1 0 14M15.5 2.5a13 13 0 0 1 0 19"/></svg>
                <span class="crt-numero">•••• 6769</span>
                <span class="crt-titular">Ricardo Couto</span>
              </div>
              <div class="crt-face crt-verso" aria-hidden="true">
                <span class="crt-tarja"></span>
                <span class="crt-assina">•••</span>
                <span class="crt-legal">Cartão de demonstração. Emissão sujeita a análise de crédito e às condições do contrato.</span>
              </div>
            </div>
          </div>

          <div class="crt-cartao crt-acab--areia" style="--i:3" data-nome="Areia" data-desc="A cor da casa lavada em papel, fosca" role="group" aria-roledescription="slide" aria-label="4 de 6: Areia">
            <div class="crt-giro">
              <div class="crt-face crt-frente">
                <span class="crt-marca">Meridiano <b>Private</b></span>
                <span class="crt-chip" aria-hidden="true"></span>
                <svg class="crt-onda" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M8.5 7.5a6 6 0 0 1 0 9M12 5a9.5 9.5 0 0 1 0 14M15.5 2.5a13 13 0 0 1 0 19"/></svg>
                <span class="crt-numero">•••• 6769</span>
                <span class="crt-titular">Ricardo Couto</span>
              </div>
              <div class="crt-face crt-verso" aria-hidden="true">
                <span class="crt-tarja"></span>
                <span class="crt-assina">•••</span>
                <span class="crt-legal">Cartão de demonstração. Emissão sujeita a análise de crédito e às condições do contrato.</span>
              </div>
            </div>
          </div>

          <div class="crt-cartao crt-acab--noite" style="--i:4" data-nome="Noite" data-desc="Preto profundo, filete na cor da casa" role="group" aria-roledescription="slide" aria-label="5 de 6: Noite">
            <div class="crt-giro">
              <div class="crt-face crt-frente">
                <span class="crt-marca">Meridiano <b>Private</b></span>
                <span class="crt-chip" aria-hidden="true"></span>
                <svg class="crt-onda" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M8.5 7.5a6 6 0 0 1 0 9M12 5a9.5 9.5 0 0 1 0 14M15.5 2.5a13 13 0 0 1 0 19"/></svg>
                <span class="crt-numero">•••• 6769</span>
                <span class="crt-titular">Ricardo Couto</span>
              </div>
              <div class="crt-face crt-verso" aria-hidden="true">
                <span class="crt-tarja"></span>
                <span class="crt-assina">•••</span>
                <span class="crt-legal">Cartão de demonstração. Emissão sujeita a análise de crédito e às condições do contrato.</span>
              </div>
            </div>
          </div>

          <div class="crt-cartao crt-acab--prata" style="--i:5" data-nome="Prata" data-desc="Titânio polido, espelhado" role="group" aria-roledescription="slide" aria-label="6 de 6: Prata">
            <div class="crt-giro">
              <div class="crt-face crt-frente">
                <span class="crt-marca">Meridiano <b>Private</b></span>
                <span class="crt-chip" aria-hidden="true"></span>
                <svg class="crt-onda" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M8.5 7.5a6 6 0 0 1 0 9M12 5a9.5 9.5 0 0 1 0 14M15.5 2.5a13 13 0 0 1 0 19"/></svg>
                <span class="crt-numero">•••• 6769</span>
                <span class="crt-titular">Ricardo Couto</span>
              </div>
              <div class="crt-face crt-verso" aria-hidden="true">
                <span class="crt-tarja"></span>
                <span class="crt-assina">•••</span>
                <span class="crt-legal">Cartão de demonstração. Emissão sujeita a análise de crédito e às condições do contrato.</span>
              </div>
            </div>
          </div>

        </div>
      </div>

      <div class="crt-controles" data-crt-controles hidden>
        <button class="btn-disc crt-seta" type="button" data-crt-anterior aria-label="Acabamento anterior">
          <svg width="16" height="16" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 12V2M3 6l4-4 4 4"/></svg>
        </button>
        <div class="crt-pontos" data-crt-pontos></div>
        <button class="btn-disc crt-seta" type="button" data-crt-proximo aria-label="Próximo acabamento">
          <svg width="16" height="16" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 2v10M3 8l4 4 4-4"/></svg>
        </button>
      </div>
    </div>

  </div>
</section>

estilo.css · 285 linhas · 13,3 KB

/* ==========================================================================
   CARTÕES 3D — prefixo crt-
   Seis cartões num tambor que gira em torno do eixo horizontal. O da frente
   de pé; os vizinhos inclinados, recuando e escurecendo, como as câmaras de
   um tambor de revólver visto de lado. Tocar no da frente o vira.

   Tokens usados:
     --display --sans         tipografia
     --ease                   curva do giro e do virar
     --dark --dark-2          acabamentos escuros, sombra e véu de fundo
     --light --light-2        acabamentos claros e tinta dos escuros
     --accent --accent-2      acabamento "da casa", filete e chip
     --fg --fg-2 --fg-muted   rótulo e contador
     --rule --rule-2          pontos
     --accent-ink             ponto ativo e contador sobre o escopo
     --radius-pill            pontos
     --nav-h                  topo do modo por rolagem

   TRÊS DECISÕES QUE EXPLICAM O ARQUIVO:

   1. A POSE É UMA CONTA SOBRE UM NÚMERO. Cada cartão recebe do script
      --crt-d, a distância (com fração) até a frente: 0 é o da frente, 1 o
      de baixo, -1 o de cima. O CSS faz o resto: gira --crt-passo por casa em
      torno do eixo do tambor. Sem script, --crt-d cai no --i do markup e o
      tambor aparece parado no primeiro cartão.

   2. NENHUM GRUPO NO CAMINHO DO 3D. opacity, filter e overflow num elemento
      preserve-3d o achatam (grouping property) — e o cartão precisa de 3D
      por dentro para virar. Por isso o escurecer dos vizinhos mora nas
      FACES, que são folhas, e o recorte mora na seção, fora da perspectiva.

   3. CADA CARTÃO TEM A SUA PERSPECTIVA, e o tambor é plano. Num contexto
      3D só, dois cartões vizinhos se ATRAVESSAM no meio do giro — as bordas
      de um cortam a face do outro por uns vinte quadros. Com perspective()
      dentro do transform de cada um (todos partem do mesmo centro, então a
      projeção é a mesma) e o tambor achatado, quem decide quem cobre quem é
      o z-index, que o script escreve pela distância até a frente. A troca
      acontece no meio do caminho, num quadro só.

   4. O CARTÃO É UMA ILHA, NÃO UM ESCOPO. O acabamento escolhe fundo e tinta
      com --dark/--light direto, como texto sobre foto. Ele não lê --fg,
      porque um cartão marfim numa dobra escura continua marfim.
   ========================================================================== */

.crt{
  --crt-w:clamp(260px,30vw,420px);          /* largura do cartão */
  --crt-h:calc(var(--crt-w) / 1.586);       /* proporção ISO 7810 */
  --crt-dy:calc(var(--crt-h) * .58);       /* o vizinho: quanto sobe ou desce */
  --crt-dy2:calc(var(--crt-h) * .3);        /* dali em diante, por casa */
  --crt-dz:calc(var(--crt-h) * .55);        /* quanto recua por casa */
  --crt-a:50deg;                            /* o vizinho: quanto deita */
  --crt-a2:20deg;                           /* dali em diante, por casa */
  --crt-persp:1300px;                       /* distância do olho */
  --crt-inclina:-7deg;                      /* o tambor todo, no plano */
  --crt-guinada:-16deg;                     /* o tambor todo, de lado */
  --crt-dur:.8s;                            /* o virar */
  /* O tambor sangra para cima e para baixo; quem corta é a seção. clip e
     não hidden: hidden faria da seção um contêiner de rolagem, e o modo por
     rolagem precisa do sticky preso à janela. */
  overflow:clip;
}

.crt-grade{
  display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);
  gap:clamp(24px,4vw,64px);align-items:center;
}

/* ---------------------------------------------------------------- texto */
.crt-texto{display:grid;justify-items:start;gap:18px}
.crt-texto .sec-lead{max-inline-size:40ch}
.crt-cta{margin-top:6px}
.crt-cta svg{flex:none}

.crt-rotulo{
  margin-top:clamp(20px,4vh,40px);padding-top:18px;
  border-top:1px solid var(--rule);min-inline-size:min(100%,320px);
}
.crt-conta{
  font-size:11px;letter-spacing:.16em;color:var(--accent-ink,var(--accent));
  font-variant-numeric:tabular-nums;
}
.crt-nome{
  margin-top:8px;
  font-family:var(--display);font-weight:700;text-transform:uppercase;
  font-size:clamp(20px,1.8vw,26px);letter-spacing:.04em;color:var(--fg);
}
.crt-desc{margin-top:4px;font-size:14px;color:var(--fg-2)}
.crt-dica{font-size:12.5px;line-height:1.5;color:var(--fg-muted);max-inline-size:36ch}
.crt-dica[hidden]{display:none}

/* ---------------------------------------------------------------- palco */
/* O data-rv mora no slot, e não no palco: o filtro do reveal é grouping
   property e achataria a perspectiva se estivesse no mesmo nó. */
.crt-palco-slot{
  position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;
  align-items:center;gap:clamp(12px,2vw,28px);
}
.crt-palco{
  position:relative;height:calc(var(--crt-h) * 2.1);
  touch-action:pan-y;                       /* a página ainda rola no dedo */
  cursor:grab;user-select:none;-webkit-user-select:none;
  border-radius:24px;outline:none;
}
.crt-palco:focus-visible{box-shadow:0 0 0 2px var(--accent)}
.crt-palco.is-arrastando{cursor:grabbing}

.crt-tambor{position:absolute;inset:0}

.crt-cartao{
  --crt-d:var(--i,0);
  --crt-p:var(--i,0);
  /* A curva por trechos: a primeira casa anda --crt-dy e deita --crt-a; as
     seguintes, só --crt-dy2 e --crt-a2. É o que junta os de trás num maço
     em vez de espalhá-los pela tela. */
  --crt-d1:clamp(-1,var(--crt-d),1);
  --crt-dr:calc(var(--crt-d) - var(--crt-d1));
  position:absolute;left:calc(50% - var(--crt-w) / 2);top:calc(50% - var(--crt-h) / 2);
  width:var(--crt-w);height:var(--crt-h);
  z-index:calc(10 - var(--i,0));           /* o script reescreve pela distância */
  transform-style:preserve-3d;
  transform:
    perspective(var(--crt-persp))
    rotateZ(var(--crt-inclina))
    rotateY(var(--crt-guinada))
    translateY(calc(var(--crt-d1) * var(--crt-dy) + var(--crt-dr) * var(--crt-dy2)))
    translateZ(calc(var(--crt-p) * var(--crt-dz) * -1))
    rotateX(calc((var(--crt-d1) * var(--crt-a) + var(--crt-dr) * var(--crt-a2)) * -1));
}
/* Sem script o tambor não dobra a volta: do quarto em diante os cartões
   ficariam de costas, atrás do da frente. Com script, quem some é o que
   passou de duas casas e meia (.is-longe). */
.crt:not(.is-vivo) .crt-cartao:nth-child(n+4),
.crt-cartao.is-longe{visibility:hidden}

.crt-giro{
  position:absolute;inset:0;transform-style:preserve-3d;
  transition:transform var(--crt-dur) var(--ease);
}
.crt-cartao.is-virado .crt-giro{transform:rotateY(180deg)}

/* ---------------------------------------------------------------- faces */
.crt-face{
  --crt-fundo:var(--dark-2);
  --crt-tinta:var(--light);
  position:absolute;inset:0;overflow:hidden;
  border-radius:calc(var(--crt-w) * .045);
  backface-visibility:hidden;-webkit-backface-visibility:hidden;
  background:var(--crt-fundo);color:var(--crt-tinta);
  font-family:var(--sans);font-size:calc(var(--crt-w) * .036);
  box-shadow:
    0 calc(var(--crt-w) * .05) calc(var(--crt-w) * .1) color-mix(in srgb,var(--dark) 55%,transparent),
    inset 0 0 0 1px color-mix(in srgb,var(--light) 14%,transparent);
}
/* O brilho: uma faixa diagonal fixa na face, que gira junto com o cartão.
   É o giro que a faz passar pela luz, e não o cursor (CLAUDE.md §6). */
.crt-face::before{
  content:"";position:absolute;inset:0;pointer-events:none;
  background:
    linear-gradient(115deg,transparent 30%,color-mix(in srgb,var(--light) 16%,transparent) 46%,transparent 60%),
    repeating-linear-gradient(90deg,color-mix(in srgb,var(--light) 3%,transparent) 0 1px,transparent 1px 3px);
}
/* O véu dos vizinhos: escurece conforme recuam. Véu opaco por cima, e não
   opacity na face — face translúcida deixaria ver o cartão de trás através
   do da frente no meio do giro. E na face, não no cartão (decisão 2). */
.crt-face::after{
  content:"";position:absolute;inset:0;pointer-events:none;
  background:var(--dark);opacity:calc(var(--crt-p) * .3);
}
.crt-verso{transform:rotateY(180deg)}

/* Frente: marca no alto, chip e aproximação à esquerda, número e titular
   embaixo. Tudo em proporção da largura, para o cartão escalar inteiro. */
.crt-frente{
  display:grid;grid-template-columns:auto auto 1fr;grid-template-rows:auto 1fr auto auto;
  column-gap:calc(var(--crt-w) * .03);
  padding:calc(var(--crt-w) * .065) calc(var(--crt-w) * .07);
}
.crt-marca{
  grid-column:1 / -1;
  font-family:var(--display);font-weight:600;letter-spacing:.02em;
  font-size:calc(var(--crt-w) * .048);
}
.crt-marca b{font-weight:400;opacity:.7}
.crt-chip{
  grid-row:2;align-self:center;
  width:calc(var(--crt-w) * .12);height:calc(var(--crt-w) * .09);
  border-radius:calc(var(--crt-w) * .016);
  background:
    linear-gradient(90deg,transparent 32%,color-mix(in srgb,var(--dark) 22%,transparent) 32% 34%,transparent 34% 66%,color-mix(in srgb,var(--dark) 22%,transparent) 66% 68%,transparent 68%),
    linear-gradient(0deg,transparent 46%,color-mix(in srgb,var(--dark) 22%,transparent) 46% 54%,transparent 54%),
    linear-gradient(135deg,color-mix(in srgb,var(--light-2) 70%,var(--accent)),color-mix(in srgb,var(--light-2) 40%,var(--accent-2)));
}
.crt-onda{grid-row:2;align-self:center;width:calc(var(--crt-w) * .06);height:auto;opacity:.75}
.crt-numero{
  grid-column:1 / -1;
  font-size:calc(var(--crt-w) * .05);letter-spacing:.14em;
  font-variant-numeric:tabular-nums;
}
.crt-titular{
  grid-column:1 / -1;margin-top:calc(var(--crt-w) * .02);
  font-size:calc(var(--crt-w) * .03);letter-spacing:.16em;text-transform:uppercase;opacity:.75;
}

.crt-verso{display:grid;grid-template-rows:auto auto 1fr;align-content:start;padding-block:calc(var(--crt-w) * .07)}
.crt-tarja{height:calc(var(--crt-w) * .13);background:color-mix(in srgb,var(--dark) 86%,transparent)}
.crt-assina{
  margin:calc(var(--crt-w) * .05) calc(var(--crt-w) * .07) 0;
  padding:calc(var(--crt-w) * .012) calc(var(--crt-w) * .025);
  justify-self:start;min-inline-size:55%;
  font-size:calc(var(--crt-w) * .03);letter-spacing:.2em;text-align:end;
  background:color-mix(in srgb,var(--crt-tinta) 12%,transparent);
}
.crt-legal{
  align-self:end;margin-inline:calc(var(--crt-w) * .07);
  font-size:calc(var(--crt-w) * .024);line-height:1.5;opacity:.6;max-inline-size:36ch;
}

/* ---------------------------------------------------------------- acabamentos */
.crt-acab--grafite .crt-face{
  --crt-fundo:linear-gradient(140deg,color-mix(in srgb,var(--dark-2) 78%,var(--light)),var(--dark) 70%);
}
.crt-acab--marfim .crt-face{
  --crt-fundo:linear-gradient(140deg,var(--light),color-mix(in srgb,var(--light-2) 88%,var(--dark)));
  --crt-tinta:var(--dark);
}
.crt-acab--casa .crt-face{
  --crt-fundo:linear-gradient(140deg,var(--accent),var(--accent-2));
  --crt-tinta:var(--dark);
}
.crt-acab--areia .crt-face{
  --crt-fundo:linear-gradient(140deg,color-mix(in srgb,var(--light-2) 78%,var(--accent)),color-mix(in srgb,var(--light-2) 58%,var(--accent-2)));
  --crt-tinta:var(--dark);
}
.crt-acab--noite .crt-face{
  --crt-fundo:var(--dark);
  box-shadow:
    0 calc(var(--crt-w) * .05) calc(var(--crt-w) * .1) color-mix(in srgb,var(--dark) 55%,transparent),
    inset 0 0 0 1px var(--accent);
}
.crt-acab--noite .crt-marca b{color:var(--accent);opacity:1}
.crt-acab--prata .crt-face{
  --crt-fundo:linear-gradient(120deg,color-mix(in srgb,var(--light-2) 70%,var(--dark)),var(--light) 45%,color-mix(in srgb,var(--light-2) 76%,var(--dark)));
  --crt-tinta:var(--dark);
}

/* ---------------------------------------------------------------- controles */
.crt-controles{display:grid;justify-items:center;gap:14px}
.crt-controles[hidden]{display:none}
.crt-pontos{display:grid;justify-items:center;gap:4px}
/* O alvo é o botão inteiro (24px); o ponto que se vê é o ::before. */
.crt-ponto{
  display:grid;place-items:center;width:24px;height:24px;padding:0;
  background:none;border:0;cursor:pointer;
}
.crt-ponto::before{
  content:"";width:6px;height:6px;border-radius:var(--radius-pill);
  background:var(--rule-2);
  transition:width .4s var(--ease),height .4s var(--ease),background-color .4s var(--ease);
}
.crt-ponto[aria-current="true"]::before{height:18px;background:var(--accent-ink,var(--accent))}
.crt-ponto:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-pill)}

/* ---------------------------------------------------------------- modo por rolagem
   Variação data-crt-rolagem: a seção fica alta e a grade gruda; a rolagem da
   página gira o tambor. Só em tela larga e sem "reduzir movimento" — quem o
   script não liga (.is-pinada) é a versão de sempre, com controles. */
.crt.is-pinada{height:calc(100svh + (var(--crt-n,6) - 1) * var(--crt-trecho,42svh))}
.crt.is-pinada .crt-grade{position:sticky;top:var(--nav-h);min-height:calc(100svh - var(--nav-h))}
.crt.is-pinada .crt-palco{cursor:default}

@media(max-width:860px){
  .crt{--crt-w:min(78vw,360px);--crt-guinada:-8deg;--crt-inclina:-5deg}
  /* No celular o rótulo desce para BAIXO do tambor: em cima, ele ficaria
     uma tela acima do cartão que ele nomeia. display:contents solta os
     filhos do bloco de texto na grade, e a ordem os rearruma. */
  .crt-grade{grid-template-columns:1fr;gap:18px;justify-items:start}
  .crt-texto{display:contents}
  .crt-palco-slot{order:1;justify-self:stretch;grid-template-columns:1fr;margin-top:8px}
  .crt-rotulo,.crt-dica{order:2}
  .crt-rotulo{margin-top:0}
  .crt-palco{height:calc(var(--crt-h) * 2.2)}
  .crt-controles{grid-auto-flow:column;align-items:center}
  .crt-pontos{grid-auto-flow:column}
  .crt-ponto[aria-current="true"]::before{height:6px;width:18px}
}

script.js · 361 linhas · 14,2 KB

/* ==========================================================================
   CARTÕES 3D — crt-
   O 3D é CSS. Este script escreve, quadro a quadro, só três coisas em cada
   cartão: --crt-d (a distância até a frente, com fração), --crt-p (o
   quanto ele recuou, de 0 a 2,5) e o z-index que sai dele. E cuida da
   interação:

     arrasto     vertical no mouse; horizontal no dedo — o vertical do dedo
                 é da página (touch-action:pan-y), e roubá-lo prenderia quem
                 só está rolando
     setas       ↑ ↓ ← → com o foco no palco, Home e End
     botões      as duas setas e um ponto por cartão
     virar       clique ou toque no cartão da frente, Enter ou Espaço
     vizinho     clique num cartão de cima ou de baixo o traz para a frente

   O tambor é um CICLO: depois do último vem o primeiro, e o caminho é
   sempre o curto. A posição é um número contínuo (pos); cada cartão lê a
   diferença até ela dobrada para [-n/2, n/2). O salto da dobra acontece
   atrás, onde o cartão já está invisível (.is-longe).

   NADA ANDA SOZINHO. Não há giro automático — e por isso não há .btn-play:
   o tambor só se move quando a pessoa pede.

   MODO POR ROLAGEM (data-crt-rolagem): a seção fica alta, a grade gruda e a
   rolagem da página vira a posição, do primeiro ao último sem ciclo. As
   setas e os pontos rolam a página até o cartão. Só em tela larga e sem
   movimento reduzido; fora disso, o modo de sempre.

   MOVIMENTO REDUZIDO: o giro que a pessoa pede continua — é gesto dela, não
   movimento imposto —, mas o encaixe resolve em poucos quadros e o arrasto
   não arremessa ao soltar.
   ========================================================================== */
(function () {
  var raizes = document.querySelectorAll('[data-crt]');
  if (!raizes.length) return;                        // guard

  function reduzido() {
    if (window.LIB && typeof window.LIB.reduce === 'boolean') return window.LIB.reduce;
    return matchMedia('(prefers-reduced-motion: reduce)').matches &&
           location.search.indexOf('motion=on') < 0;
  }

  function dois(v) { return (v < 10 ? '0' : '') + v; }

  function iniciar(raiz) {
    var palco = raiz.querySelector('[data-crt-palco]');
    if (!palco) return;
    var cartoes = [].slice.call(palco.querySelectorAll('.crt-cartao'));
    var n = cartoes.length;
    if (n < 2) return;

    var reduz = reduzido();
    var elConta = raiz.querySelector('[data-crt-conta]');
    var elNome = raiz.querySelector('[data-crt-nome]');
    var elDesc = raiz.querySelector('[data-crt-desc]');
    var elVivo = raiz.querySelector('[data-crt-vivo]');
    var elDica = raiz.querySelector('[data-crt-dica]');
    var elCtl = raiz.querySelector('[data-crt-controles]');
    var elPontos = raiz.querySelector('[data-crt-pontos]');
    var btAntes = raiz.querySelector('[data-crt-anterior]');
    var btDepois = raiz.querySelector('[data-crt-proximo]');

    var pos = 0;              /* posição contínua do tambor, em cartões */
    var alvo = null;          /* destino do encaixe */
    var vel = 0;              /* arremesso do arrasto, em cartões por quadro */
    var frente = -1;          /* índice na frente, já publicado */
    var laco = 0;
    var dAntes = [], pAntes = [], longeAntes = [];
    var pontos = [];

    var pinada = false;
    var dicaGesto = elDica ? elDica.textContent : '';
    var dicaRolagem = 'Role a página, use as setas ou toque no cartão para ver o verso.';
    var estreito = matchMedia('(max-width: 860px)');

    function indice(k) { return ((k % n) + n) % n; }

    /* A casa de onde o próximo comando parte: o destino em curso, se houver.
       Sem isto, dois cliques rápidos pedem o mesmo destino e andam um só. */
    function base() { return Math.round(alvo !== null ? alvo : pos); }

    /* ------------------------------------------------------------ rótulo */

    function publicar(idx) {
      var c = cartoes[idx];
      if (elConta) elConta.textContent = dois(idx + 1) + ' — ' + dois(n);
      if (elNome) elNome.textContent = c.getAttribute('data-nome') || '';
      if (elDesc) elDesc.textContent = c.getAttribute('data-desc') || '';
      for (var j = 0; j < n; j++) {
        if (j === idx) cartoes[j].removeAttribute('aria-hidden');
        else cartoes[j].setAttribute('aria-hidden', 'true');
        if (pontos[j]) {
          if (j === idx) pontos[j].setAttribute('aria-current', 'true');
          else pontos[j].removeAttribute('aria-current');
        }
      }
    }

    /* A região viva fala quando o tambor PARA, e não a cada cartão que passa
       pela frente no meio de um arrasto. */
    function anunciar() {
      if (!elVivo) return;
      var c = cartoes[indice(Math.round(pos))];
      elVivo.textContent = (c.getAttribute('data-nome') || '') + '. ' +
        (c.getAttribute('data-desc') || '') + '. ' +
        (indice(Math.round(pos)) + 1) + ' de ' + n + '.';
    }

    /* ------------------------------------------------------------ pintura */

    function pintar() {
      for (var j = 0; j < n; j++) {
        var d = j - pos;
        if (!pinada) {                     /* no ciclo, o caminho curto */
          d = ((d % n) + n) % n;
          if (d >= n / 2) d -= n;
        }
        d = Math.round(d * 1000) / 1000;
        var p = d < 0 ? -d : d;
        var longe = p > 2.5;
        if (p > 2.5) p = 2.5;
        if (dAntes[j] !== d) { cartoes[j].style.setProperty('--crt-d', d); dAntes[j] = d; }
        if (pAntes[j] !== p) {
          cartoes[j].style.setProperty('--crt-p', p);
          /* Quem está mais perto da frente fica por cima (estilo.css, 3). */
          cartoes[j].style.zIndex = 100 - Math.round(p * 20);
          pAntes[j] = p;
        }
        if (longeAntes[j] !== longe) {
          cartoes[j].classList.toggle('is-longe', longe);
          longeAntes[j] = longe;
        }
      }
      var idx = indice(Math.round(pos));
      if (idx !== frente) {
        /* O cartão que sai da frente desvira: ninguém quer reencontrar o
           verso ao dar a volta. */
        if (frente > -1) cartoes[frente].classList.remove('is-virado');
        frente = idx;
        publicar(idx);
      }
    }

    function quadro() {
      laco = 0;
      if (arrastando) { pintar(); return; }
      if (vel) {
        pos += vel;
        vel *= .9;
        if ((vel < 0 ? -vel : vel) < .01) { vel = 0; alvo = Math.round(pos); }
      } else if (alvo !== null) {
        var d = alvo - pos;
        if ((d < 0 ? -d : d) < .001) {
          pos = alvo; alvo = null;
          /* Normaliza o ciclo em repouso: o número fica pequeno e o índice
             não muda, porque n cartões fecham exatamente uma volta. */
          if (!pinada) pos = indice(pos);
          anunciar();
        } else {
          pos += d * (reduz ? .5 : .14);
        }
      }
      pintar();
      if (vel || alvo !== null) ligar();
    }

    function ligar() { if (!laco) laco = requestAnimationFrame(quadro); }

    /* ------------------------------------------------------------ destinos */

    function irPara(i) {
      if (pinada) { rolarAte(i); return; }
      var k = base();
      var d = i - indice(k);
      while (d > n / 2) d -= n;
      while (d < -n / 2) d += n;
      vel = 0;
      alvo = k + d;
      ligar();
    }

    function passo(dir) {
      if (pinada) { rolarAte(Math.max(0, Math.min(n - 1, base() + dir))); return; }
      vel = 0;
      alvo = base() + dir;
      ligar();
    }

    function virar() {
      var c = cartoes[indice(Math.round(pos))];
      c.classList.toggle('is-virado');
    }

    /* ------------------------------------------------------------ arrasto */

    var arrastando = false, ponteiro = -1, xAnt = 0, yAnt = 0, ultimo = 0, andou = 0, eixo = '';

    palco.addEventListener('dragstart', function (e) { e.preventDefault(); });

    palco.addEventListener('pointerdown', function (e) {
      if (pinada) return;
      if (typeof e.button === 'number' && e.button !== 0) return;
      arrastando = true;
      ponteiro = e.pointerId;
      xAnt = e.clientX; yAnt = e.clientY;
      ultimo = 0; andou = 0; eixo = '';
      vel = 0; alvo = null;
      palco.classList.add('is-arrastando');
      try { palco.setPointerCapture(ponteiro); } catch (err) {}
    });

    palco.addEventListener('pointermove', function (e) {
      if (!arrastando || e.pointerId !== ponteiro) return;
      var dx = e.clientX - xAnt, dy = e.clientY - yAnt;
      xAnt = e.clientX; yAnt = e.clientY;
      andou += (dx < 0 ? -dx : dx) + (dy < 0 ? -dy : dy);
      /* O eixo se decide nos primeiros 6px e não muda mais: um arrasto
         diagonal não soma os dois. No dedo só existe o horizontal. */
      if (!eixo && andou > 6) {
        eixo = (e.pointerType === 'mouse' && (dy < 0 ? -dy : dy) > (dx < 0 ? -dx : dx)) ? 'y' : 'x';
      }
      if (!eixo) return;
      var alto = palco.offsetHeight || 400, largo = palco.offsetWidth || 400;
      /* Puxar para baixo traz o de cima; puxar para a esquerda traz o próximo. */
      var delta = eixo === 'y' ? -dy / (alto * .4) : -dx / (largo * .6);
      pos += delta;
      ultimo = delta;
      ligar();
    });

    function soltar(e) {
      if (!arrastando) return;
      if (e && e.pointerId !== undefined && e.pointerId !== ponteiro) return;
      arrastando = false;
      palco.classList.remove('is-arrastando');
      try { palco.releasePointerCapture(ponteiro); } catch (err) {}
      /* Soma da progressão a .9: o arremesso anda 10 vezes o último passo.
         Teto de .05 por quadro, meio cartão de escorrego: com .08, um
         arrasto de 220px andava dois cartões em vez de um. */
      vel = reduz ? 0 : Math.max(-.05, Math.min(.05, ultimo));
      if (!vel) alvo = Math.round(pos);
      ligar();
    }
    palco.addEventListener('pointerup', soltar);
    palco.addEventListener('pointercancel', soltar);

    /* Clique: no da frente, vira; num vizinho, traz para a frente. 6px de
       tolerância separam o clique do arrasto. */
    palco.addEventListener('click', function (e) {
      if (andou > 6) return;
      var no = e.target;
      while (no && no !== palco) {
        var i = cartoes.indexOf(no);
        if (i > -1) {
          if (i === indice(Math.round(pos)) && alvo === null) virar();
          else irPara(i);
          return;
        }
        no = no.parentNode;
      }
    });

    /* ------------------------------------------------------------ teclado */
    /* O eixo do tambor é o vertical, então ↑ ↓ giram — mas só com o foco no
       palco, que é um gesto deliberado. Fora dele as setas rolam a página. */
    palco.addEventListener('keydown', function (e) {
      var k = e.key;
      if (k === 'ArrowDown' || k === 'ArrowRight') { e.preventDefault(); passo(1); }
      else if (k === 'ArrowUp' || k === 'ArrowLeft') { e.preventDefault(); passo(-1); }
      else if (k === 'Home') { e.preventDefault(); irPara(0); }
      else if (k === 'End') { e.preventDefault(); irPara(n - 1); }
      else if (k === 'Enter' || k === ' ') { e.preventDefault(); virar(); }
    });

    /* ------------------------------------------------------------ controles */

    if (elPontos) {
      for (var j = 0; j < n; j++) {
        var b = document.createElement('button');
        b.type = 'button';
        b.className = 'crt-ponto';
        b.setAttribute('aria-label', cartoes[j].getAttribute('data-nome') || String(j + 1));
        (function (i) { b.addEventListener('click', function () { irPara(i); }); })(j);
        elPontos.appendChild(b);
        pontos.push(b);
      }
    }
    if (btAntes) btAntes.addEventListener('click', function () { passo(-1); });
    if (btDepois) btDepois.addEventListener('click', function () { passo(1); });

    /* ------------------------------------------------------------ rolagem */

    var desliga = null;
    var grade = raiz.querySelector('.crt-grade');
    var pt = 0, curso = 0, gruda = 0;

    /* O curso é o trecho em que a grade está GRUDADA, e não a altura da
       seção: o respiro de cima e o de baixo (.sec-pad) passam antes e depois
       dele. Medido pela seção inteira, o primeiro cartão já saía girado e o
       último chegava com a grade saindo da tela. Lido uma vez e a cada
       resize — getComputedStyle a cada quadro de rolagem é desperdício. */
    function medirCurso() {
      var cs = getComputedStyle(raiz);
      pt = parseFloat(cs.paddingTop) || 0;
      gruda = parseFloat(getComputedStyle(grade).top) || 0;
      curso = raiz.clientHeight - pt - (parseFloat(cs.paddingBottom) || 0) - grade.offsetHeight;
    }

    function rolarAte(i) {
      var y = raiz.getBoundingClientRect().top + pageYOffset + pt - gruda + curso * (i / (n - 1));
      window.scrollTo({ top: Math.round(y) + 1, behavior: reduz ? 'auto' : 'smooth' });
    }

    function lerRolagem() {
      if (curso <= 0) return;
      var k = (gruda - pt - raiz.getBoundingClientRect().top) / curso;
      if (k < 0) k = 0; else if (k > 1) k = 1;
      alvo = k * (n - 1);
      ligar();
    }

    function modo() {
      var quer = raiz.hasAttribute('data-crt-rolagem') && !reduz && !estreito.matches;
      if (quer === pinada) return;
      pinada = quer;
      raiz.classList.toggle('is-pinada', pinada);
      raiz.style.setProperty('--crt-n', n);
      if (elDica) elDica.textContent = pinada ? dicaRolagem : dicaGesto;
      if (desliga) { desliga(); desliga = null; }
      if (pinada) {
        medirCurso();
        pos = Math.max(0, Math.min(n - 1, pos));
        if (window.LIB && LIB.onScroll) desliga = LIB.onScroll(lerRolagem);
        else {
          addEventListener('scroll', lerRolagem, { passive: true });
          desliga = function () { removeEventListener('scroll', lerRolagem); };
          lerRolagem();
        }
      } else {
        alvo = Math.round(pos);
        ligar();
      }
    }
    if (estreito.addEventListener) estreito.addEventListener('change', modo);
    var pedido = 0;
    addEventListener('resize', function () {
      if (!pinada || pedido) return;
      pedido = requestAnimationFrame(function () { pedido = 0; medirCurso(); lerRolagem(); });
    }, { passive: true });

    /* ------------------------------------------------------------ partida */

    raiz.classList.add('is-vivo');
    if (elCtl) elCtl.hidden = false;
    if (elDica) elDica.hidden = false;
    modo();
    pintar();
  }

  for (var r = 0; r < raizes.length; r++) iniciar(raizes[r]);
})();

Seis cartões num tambor que gira em torno do eixo horizontal. O da frente fica de pé, levemente inclinado, com o nome, o chip e a numeração; os vizinhos deitam, recuam e escurecem acima e abaixo dele, e os de trás viram uma fresta — o maço inteiro lido de lado. Ao lado, o contador (02 — 06), o nome do acabamento e uma linha de material. Arrastar, apertar as setas, clicar num ponto ou num cartão vizinho gira o tambor; tocar no cartão da frente o vira e mostra o verso.

O problema que ela resolve: cartão é objeto, e foto de cartão numa grade o transforma em miniatura de catálogo. Numa campanha institucional — o lançamento de um cartão, os níveis de um relacionamento, uma linha com acabamentos — a pessoa precisa pegar o produto. O tambor dá a ela o gesto: escolher girando, e virar para ver o outro lado.

Os cartões são desenhados em CSS, com os tokens do tema. Nenhuma imagem, nenhuma marca de bandeira, e trocar o tema troca os acabamentos.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho o nome do produto, 1 a 3 palavras — "Cartão Private"
Título 3 a 7 palavras, com o <em> na promessa
Apoio 80 a 180 caracteres: o que o cartão é e como se escolhe o acabamento
Nome do acabamento (data-nome) 1 a 2 palavras, material ou cor — "Grafite", "Marfim"
Descrição (data-desc) 3 a 6 palavras, o material e o toque — "Metal escovado, acabamento fosco"
Marca no cartão a mesma em todos: o que muda é o acabamento

O que mantém a peça: o nome é do acabamento, não do plano. Se cada cartão for um nível com preço, a pessoa quer comparar, e o tambor esconde cinco de seis. Aí o nível vai numa tabela e o tambor mostra só a família.

O cartão é fictício de propósito. Número mascarado (•••• 6769), titular de exemplo e, no verso, a frase "Cartão de demonstração". Não ponha número completo, bandeira real nem logotipo de emissor — o primeiro confunde, os outros dois têm dono.

Especificação dos assets

Nenhum. Cartão, chip, brilho e textura escovada são CSS.


Como usar

<link rel="stylesheet" href="componentes/midia/cartoes-3d/estilo.css">
<script src="componentes/midia/cartoes-3d/script.js"></script>

Cada cartão declara a casa, o nome e a descrição, e veste um acabamento:

<div class="crt-cartao crt-acab--marfim" style="--i:1" data-nome="Marfim" data-desc="Cerâmica clara, toque acetinado" role="group" aria-roledescription="slide" aria-label="2 de 6: Marfim">

Acabamentos

Classe De onde vem
.crt-acab--grafite --dark-2 clareado para --dark
.crt-acab--marfim --light para --light-2
.crt-acab--casa --accent para --accent-2 — o cartão na cor da marca
.crt-acab--areia --light-2 tingido de --accent
.crt-acab--noite --dark com filete e "Private" em --accent
.crt-acab--prata --light-2 escurecido, com faixa espelhada

Acabamento novo: uma regra .crt-acab--nome .crt-face{--crt-fundo:…;--crt-tinta:…}. Só tokens.

Ganchos

Gancho Papel
[data-crt] a raiz
[data-crt-palco] o palco, focável — arrasto, clique, teclado
.crt-cartao + --i, data-nome, data-desc cada cartão
[data-crt-conta] [data-crt-nome] [data-crt-desc] rótulo, reescrito pelo script
[data-crt-vivo] região viva: fala quando o tambor para
[data-crt-controles] [data-crt-pontos] [data-crt-anterior] [data-crt-proximo] controles, escondidos sem script
[data-crt-rolagem] variação: a rolagem gira o tambor
.is-vivo .is-pinada .is-virado .is-longe escritos pelo script

Parafusos (na seção)

Propriedade Padrão O que faz
--crt-w clamp(260px,30vw,420px) largura do cartão; a altura sai da proporção ISO 7810
--crt-dy / --crt-dy2 .58 / .3 da altura quanto o vizinho e os de trás sobem ou descem
--crt-dz .55 da altura quanto cada casa recua
--crt-a / --crt-a2 50deg / 20deg quanto o vizinho e os de trás deitam
--crt-inclina / --crt-guinada -7deg / -16deg o tambor todo, no plano e de lado
--crt-persp 1300px distância do olho
--crt-dur .8s o virar
--crt-trecho 42svh rolagem por cartão, na variação por rolagem

Variações


Gotchas


Movimento reduzido / toque

Nada anda sozinho — não há giro automático, e por isso não há .btn-play. Com reduce, o giro que a pessoa pede continua (é gesto dela), mas o encaixe resolve em poucos quadros e o arrasto não arremessa ao soltar; a variação por rolagem não liga e a seção fica no modo de sempre. O virar mantém a transição curta do --crt-dur, porque é resposta a um toque.

Nada reage ao cursor (CLAUDE.md §6). O brilho é uma faixa fixa na face, que passa pela luz porque o cartão gira — não porque o mouse passou.

No celular o rótulo desce para baixo do tambor, junto dos controles: em cima ele ficaria uma tela acima do cartão que nomeia.


Histórico

Origem Quando O quê
referência: revolver-template.modulify.website 2026-09-29 Clone de referência, reescrito do zero

O que veio da referência: o tambor de cartões no eixo horizontal, o da frente inclinado, os vizinhos achatados em perspectiva, o contador com nome e descrição do acabamento, a dica de gesto. O que mudou: cartões em CSS com os tokens (sem WebGL, sem imagem, sem marca); a roda do mouse não é sequestrada — a rolagem só gira o tambor na variação, com a grade grudada; arrasto horizontal no dedo; o verso ao tocar; pontos e setas como botões, região viva e teclado; nada segue o cursor. O Cardwall, do mesmo catálogo, ficou de fora por mover os cartões com o cursor. Registro consolidado em projetos/referencias-externas.md.