← Biblioteca componentes/midia/pilha-swipe/ficha.md CódigoTela cheia
midia/pilha-swipe

Pilha de cards com swipe

🟢 estavel complexidade alta prefixo swipe- pilha cards swipe arrasto drag carrossel imagem teclado compromissos duas-colunas loop-infinito

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

<link rel="stylesheet" href="componentes/midia/pilha-swipe/estilo.css">
<script src="componentes/midia/pilha-swipe/script.js"></script>

markup.html · 143 linhas · 7,4 KB

<!-- ==========================================================================
     PILHA DE CARDS COM SWIPE — cinco fotos empilhadas, a de cima se arrasta
     Prefixo: swipe-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. O empilhamento é CSS lendo --swipe-n; o script só troca números.

     DUAS COLUNAS, E A DA ESQUERDA É QUE ARGUMENTA. O texto da dobra fica
     inteiro fora da pilha: olho, título, apoio e a navegação. A pilha é a
     peça gráfica, e o que ela carrega — número, nome e uma linha — é rótulo,
     não parágrafo. Quem lê só a coluna da esquerda entendeu a dobra; quem
     percorre a pilha ganha os cinco nomes. Essa divisão é o que permite que
     a pilha seja um gesto sem virar conteúdo refém de gesto.

     A PILHA É UM ÍNDICE, NÃO UMA ORDEM DE DOM. Cada card carrega --swipe-n
     (0 = topo) e o CSS monta dele a posição inteira: deslocamento, escala,
     inclinação e z-index. O script nunca move um <li> de lugar — só reescreve
     os cinco números. Assim a ordem do documento continua sendo 01…05 para
     quem lê por leitor de tela, o foco nunca é arrancado de dentro de um nó
     que muda de pai, e nenhuma transição em curso é cancelada por reinserção.

     O PALCO É O ELEMENTO FOCÁVEL, e quem o representa é o <ul>: ele cobre o
     palco inteiro (position:absolute;inset:0), então o anel de foco desenha
     exatamente em volta do card de cima, e é ele que carrega o
     aria-roledescription e o rótulo do carrossel. As setas do teclado e
     Enter/Espaço são ouvidas ali.

     CINCO CARDS, QUATRO À VISTA. O quinto fica em opacity:0 (.is-back) de
     propósito: é a vaga de pouso. O card que voa para fora vira o de índice
     4 no mesmo instante em que sai — a pilha se acomoda durante o voo — e
     quando o voo acaba ele apenas reaparece parado naquele lugar invisível.
     Sem essa vaga, o card pousaria à vista, no meio da pilha.

     AS FOTOS SÃO RETRATO porque a caixa é 4/5. Todas as cinco ocupam o mesmo
     lugar na tela, então loading="lazy" nas quatro de baixo não adia nada —
     elas estão dentro da mesma interseção da de cima. O atributo fica porque
     é a regra da casa, e não porque economiza alguma coisa aqui: uma foto que
     chegasse depois apareceria em branco ao subir para o topo.
     ========================================================================== -->

<section class="sec-deep sec-pad swipe" data-swipe>
  <div class="wrap swipe-grid">

    <div class="swipe-copy">
      <span class="eyebrow" data-rv>Compromissos</span>
      <h2 class="sec-title ttl">Cinco coisas que <em>não</em> negociamos</h2>
      <p class="sec-lead" data-rv data-d="1">
        Antes de falar de carteira, de alocação ou de horizonte, falamos disto.
        São os cinco pontos que valem no primeiro diagnóstico e continuam valendo
        dez anos depois.
      </p>

      <!-- A navegação existe porque o arrasto não se anuncia: em desktop nem
           todo mundo tenta puxar um card, e no toque a descoberta depende de
           sorte. Os dois botões e o contador dizem, sem texto de instrução,
           que há cinco coisas ali e que dá para andar. -->
      <div class="swipe-nav" data-rv data-d="2">
        <button class="btn-disc swipe-btn" type="button" data-swipe-prev
                aria-label="Ver o compromisso anterior">
          <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
            <path d="M10 2L4 8L10 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </button>
        <button class="btn-disc swipe-btn" type="button" data-swipe-next
                aria-label="Ver o próximo compromisso">
          <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
            <path d="M6 2L12 8L6 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </button>
        <p class="swipe-count" data-swipe-count aria-live="polite">1 / 5</p>
      </div>
    </div>

    <div class="swipe-stage" data-rv data-d="1">
      <ul class="swipe-stack" data-swipe-stack tabindex="0"
          aria-roledescription="carrossel"
          aria-label="Compromissos da Liberta — arraste o card de cima ou use as setas do teclado">

        <li class="swipe-card" aria-roledescription="slide"
            style="--swipe-n:0;--swipe-rot:-2deg;--swipe-focus:50% 42%">
          <img class="swipe-img" src="../../../showroom/demo/ph-6.webp"
               alt="Globo terrestre antigo sobre mesa de biblioteca" decoding="async">
          <div class="swipe-cap">
            <span class="swipe-num">01</span>
            <h3 class="swipe-title">Independência</h3>
            <p class="swipe-line">Sem vínculo com banco ou corretora</p>
          </div>
        </li>

        <li class="swipe-card" aria-roledescription="slide" aria-hidden="true"
            style="--swipe-n:1;--swipe-rot:1.5deg;--swipe-focus:50% 46%">
          <img class="swipe-img" src="../../../showroom/demo/ph-8.webp"
               alt="Mesa de trabalho com xícara de café, caderno e óculos"
               loading="lazy" decoding="async">
          <div class="swipe-cap">
            <span class="swipe-num">02</span>
            <h3 class="swipe-title">Transparência</h3>
            <p class="swipe-line">Taxa combinada antes, nunca depois</p>
          </div>
        </li>

        <li class="swipe-card" aria-roledescription="slide" aria-hidden="true"
            style="--swipe-n:2;--swipe-rot:-1deg;--swipe-focus:50% 38%">
          <img class="swipe-img" src="../../../showroom/demo/ph-7.webp"
               alt="Reunião de quatro pessoas em sala de vidro"
               loading="lazy" decoding="async">
          <div class="swipe-cap">
            <span class="swipe-num">03</span>
            <h3 class="swipe-title">Método</h3>
            <p class="swipe-line">Política de investimentos escrita e assinada</p>
          </div>
        </li>

        <li class="swipe-card" aria-roledescription="slide" aria-hidden="true"
            style="--swipe-n:3;--swipe-rot:2deg;--swipe-focus:50% 34%">
          <img class="swipe-img" src="../../../showroom/demo/ph-4.webp"
               alt="Três pessoas conversando em escritório com vista para a cidade"
               loading="lazy" decoding="async">
          <div class="swipe-cap">
            <span class="swipe-num">04</span>
            <h3 class="swipe-title">Presença</h3>
            <p class="swipe-line">Quem atende é quem decide com você</p>
          </div>
        </li>

        <!-- O quinto nasce em .is-back: fora da pilha visível, opacity:0 e
             sem ponteiro. É a vaga de pouso descrita no cabeçalho. -->
        <li class="swipe-card is-back" aria-roledescription="slide" aria-hidden="true"
            style="--swipe-n:4;--swipe-rot:-1.5deg;--swipe-focus:50% 36%">
          <img class="swipe-img" src="../../../showroom/demo/ph-5.webp"
               alt="Homem mais velho e criança lendo juntos"
               loading="lazy" decoding="async">
          <div class="swipe-cap">
            <span class="swipe-num">05</span>
            <h3 class="swipe-title">Longo prazo</h3>
            <p class="swipe-line">O plano acompanha a vida, não o contrário</p>
          </div>
        </li>

      </ul>
    </div>

  </div>
</section>

estilo.css · 284 linhas · 13,6 KB

/* ==========================================================================
   PILHA DE CARDS COM SWIPE — prefixo swipe-
   Duas colunas: o argumento da dobra à esquerda, e à direita uma pilha de
   cinco fotos em que a de cima se arrasta, voa para fora e volta pelo fundo.

   Tokens usados:
     --display --sans     tipografia do número/título do card e do contador
     --ease               curva única do projeto — acomodação da pilha e voo
     --radius-media       canto do card
     --radius-pill        disco dos botões
     --accent             número do card e realce dos botões
     --dark-rgb           véu da legenda e sombra da pilha
     --light              texto sobre a foto (ilha escura, ver abaixo)
     --fg-2 --fg-muted    botões e contador, herdados do escopo de tom
     --rule --rule-2      cor de carregamento do card e borda dos botões

   O CARD É UMA ILHA ESCURA. O que está sobre a foto lê --light, e não --fg:
   assim a mesma pilha roda numa seção .sec-paper sem virar texto escuro
   sobre véu escuro. O que está fora da foto — botões, contador — continua
   lendo o escopo de tom da seção.

   A POSIÇÃO INTEIRA DE UM CARD SAI DE UM NÚMERO SÓ. Cada card carrega
   --swipe-n (0 = topo) e daí vêm deslocamento, escala, inclinação e z-index.
   O script não conhece a fórmula: ele reescreve os cinco números e o CSS
   remonta a pilha. Mesmo canal do midia/galeria-pasta (--i/--a) e do
   listas/baralho-sticky (--deck-i), pela mesma razão: mudar o desenho da
   pilha não pode exigir mexer no JavaScript.
   ========================================================================== */

.swipe{
  --swipe-max:420px;      /* largura do palco; 320px abaixo de 760px */
  --swipe-step:12px;      /* faixa que cada card deixa aparecer abaixo do de cima */
  --swipe-shrink:.045;    /* quanto cada nível encolhe (4,5% por degrau) */
  --swipe-depth:4;        /* cards à vista; os demais ficam em .is-back */
  /* A cauda da pilha sai por baixo da caixa do palco: (depth - 1) degraus.
     Reservada em margem para não invadir o que vem embaixo no mobile. */
  --swipe-tail:calc((var(--swipe-depth) - 1) * var(--swipe-step));

  /* Durações de INTERAÇÃO, locais por natureza (--dur do core é .9s, certo
     para entrada de seção e lento para um gesto responder). O script lê
     --swipe-fly-dur para dimensionar o próprio fallback de timeout: se você
     mudar a duração do voo, mude aqui e o script acompanha. Em segundos. */
  --swipe-fly-dur:.5s;    /* voo do card para fora */
  --swipe-back-dur:.55s;  /* acomodação da pilha e volta do card ao lugar */

  /* Fração da largura do palco a partir da qual soltar o card manda ele
     embora. .3 = 30%; abaixo de .18 o card escapa em qualquer tremida. */
  --swipe-threshold:.3;

  /* Troca sozinha, em SEGUNDOS. 0 = desligada, que é o padrão: a dobra não
     se mexe sem alguém pedir. Ver a variação "avanço automático" na ficha. */
  --swipe-auto:0;

  /* O card voa 140% da própria largura: passa da borda da coluna. Quem corta
     é este recorte, e é o mesmo recurso do midia/carrossel-perfis. Nada aqui
     é sticky, então overflow:hidden não custa nada. */
  overflow:hidden;
}

/* ---------------------------------------------------------------- grade */
/* Duas colunas de mesma largura. minmax(0,1fr) e não 1fr: sem o mínimo zero,
   uma linha longa do apoio empurra a coluna e o palco encolhe fora de hora. */
.swipe-grid{
  display:grid;
  grid-template-columns:minmax(0,1fr) minmax(0,1fr);
  gap:clamp(32px,5vw,80px);
  align-items:center;
}

.swipe-copy{min-width:0}

/* ---------------------------------------------------------------- navegação */
.swipe-nav{
  display:flex;align-items:center;gap:12px;
  margin-top:clamp(28px,3.6vw,42px);
}

/* Disco de ícone. Não é .btn do core: .btn é pílula de TEXTO com padding
   fixo, e espremer 42px nela seria brigar com a primitiva em vez de usá-la.
   Esta é a TERCEIRA aparição do mesmo disco na biblioteca (já existe em
   midia/galeria-scroll-horizontal e hero/duas-colunas-produto), ou seja, o
   gatilho do §6 do contrato — a proposta de promover ao core está registrada
   na ficha, em Gotchas. Enquanto ela não acontece, a regra fica aqui, com o
/* O disco de ícone é a primitiva .btn-disc do core (core/base.css): forma,
   cor, hover e estado desabilitado vêm de lá, e o markup traz as duas
   classes. Esta pasta escrevia as mesmas seis linhas — eram cinco cópias
   idênticas na biblioteca, e o §6 manda promover na terceira. */

/* Tabular para o contador não dançar ao passar de 1 para 4 — a fonte de
   display tem larguras diferentes por algarismo, e o bloco inteiro treme. */
.swipe-count{
  margin-left:6px;
  font-family:var(--display);font-weight:700;font-size:12px;
  letter-spacing:.14em;color:var(--fg-muted);
  font-variant-numeric:tabular-nums;
}

/* ---------------------------------------------------------------- palco */
/* A caixa do palco é o card de cima, e só ele: 4/5, que é o recorte de
   retrato das fotos do showroom. A cauda da pilha (os degraus de baixo)
   fica FORA dessa caixa, e é por isso que existe a margem de baixo — sem
   ela, no mobile, o quarto degrau cai por cima do olho da coluna de texto. */
.swipe-stage{
  position:relative;
  width:100%;
  max-width:var(--swipe-max);
  aspect-ratio:4/5;
  margin-inline:auto;
  margin-block-end:var(--swipe-tail);
}
/* Sem perspective aqui, de propósito: nenhum card gira em rotateX/rotateY,
   e perspective sobre transformações 2D não muda um pixel — medido, a caixa
   do card sai idêntica com e sem. No midia/galeria-pasta ela é obrigatória
   porque lá a aba tomba de verdade; aqui seria enfeite de folha de estilo. */

/* O <ul> cobre o palco inteiro e é ele que recebe o foco: assim o anel do
   core (3px para fora) desenha em volta do card de cima, e não em volta de
   uma caixa maior que ninguém vê. Nenhum overflow aqui — o card precisa
   sair da caixa para voar; quem corta é a seção. */
.swipe-stack{
  position:absolute;inset:0;
  cursor:grab;
  /* O gesto horizontal é do card; o vertical continua rolando a página.
     Sem isto, arrastar de lado no celular trava a rolagem — e arrastar de
     cima para baixo deixaria de rolar a página. */
  touch-action:pan-y;
  /* Arrastar sobre a foto e a legenda selecionaria as duas. */
  user-select:none;-webkit-user-select:none;
  -webkit-tap-highlight-color:transparent;
}
.swipe-stack:active{cursor:grabbing}

/* ---------------------------------------------------------------- card */
/* transform-origin na BASE, e isso não é gosto. Com a origem no centro, o
   encolhimento come exatamente o deslocamento: num card de 525px de altura,
   scale(.955) sobe a borda de baixo em 11,8px contra os 12px que o
   translateY desceu — sobra 0,2px de faixa e a pilha lê como um card só.
   Ancorada na base, a faixa que aparece é exatamente --swipe-step por
   degrau, em qualquer tamanho de tela, e a conta não depende da altura. */
.swipe-card{
  position:absolute;inset:0;
  overflow:hidden;
  border-radius:var(--radius-media);
  background:var(--rule);
  /* Fio de 1px: é ele que separa um card do outro na faixa de 12px que
     sobra. Sem o fio, quatro fotos escuras empilhadas leem como uma mancha
     só — a pilha existe na tela mas não se conta. Fica em --light a 14% e
     não em --rule-2 porque o fio está sobre FOTO, não sobre a seção: em
     .sec-paper, --rule-2 é quase preto e o fio some contra a imagem. */
  border:1px solid color-mix(in srgb,var(--light) 14%,transparent);
  box-shadow:0 22px 54px rgba(var(--dark-rgb),.5);
  transform-origin:50% 100%;
  z-index:calc(10 - var(--swipe-n,0));
  /* min(--swipe-n,1) zera a inclinação do card de cima. Não é capricho: o
     arrasto escreve rotate() a partir de zero, e um card em repouso a -2°
     daria um salto visível de 2° no pointerdown. Do segundo degrau em
     diante a inclinação entra inteira, e é ela que faz a pilha ler como
     cartas jogadas na mesa e não como uma sanfona. */
  transform:translateY(calc(var(--swipe-n,0) * var(--swipe-step)))
            scale(calc(1 - var(--swipe-n,0) * var(--swipe-shrink)))
            rotate(calc(var(--swipe-rot,0deg) * min(var(--swipe-n,0),1)));
  transition:transform var(--swipe-back-dur) var(--ease),
             opacity var(--swipe-back-dur) var(--ease);
  /* Repetidos aqui, e não só no .swipe-stack: touch-action NÃO se herda —
     o navegador percorre os ancestrais do alvo do toque, então o valor da
     pilha já valeria, mas quem lê o card isolado não tem como saber disso.
     user-select desce por herança e está aqui pelo mesmo motivo de leitura. */
  touch-action:pan-y;
  user-select:none;-webkit-user-select:none;
}

/* Fora da pilha visível. É a vaga de pouso: o card que voou vira este e
   reaparece parado, invisível, sem cruzar a pilha de volta. pointer-events
   fecha o alvo — opacity:0 continua clicável, e um card invisível comendo o
   clique da faixa de baixo é o tipo de defeito que ninguém encontra. */
.swipe-card.is-back{opacity:0;pointer-events:none}

/* Durante o arrasto quem escreve transform é o script, inline. A transição
   sai da frente para o card não ficar 0,55s atrás do dedo. */
.swipe-card.is-dragging{transition:none;z-index:30;will-change:transform}

/* O voo. 140% da própria largura tira o card da coluna; a opacidade cai em
   70% do tempo para ele não atravessar o texto da esquerda ainda opaco
   quando o gesto foi para esse lado. --swipe-fly (1 ou -1) é o único valor
   que o script escreve aqui: a direção. */
.swipe-card.is-out{
  z-index:20;opacity:0;
  transform:translateX(calc(var(--swipe-fly,1) * 140%))
            rotate(calc(var(--swipe-fly,1) * 22deg));
  transition:transform var(--swipe-fly-dur) var(--ease),
             opacity calc(var(--swipe-fly-dur) * .7) var(--ease);
}

/* Um quadro sem transição, para o card ser teleportado sem desenhar o
   caminho: é como ele volta da posição de voo para a vaga do fundo, e como
   o "anterior" o joga para fora antes de trazê-lo de volta. Vem DEPOIS de
   .is-out de propósito — as duas têm a mesma especificidade, e é a ordem
   que decide quem manda no transition. */
.swipe-card.is-instant{transition:none}

.swipe-img{
  position:absolute;inset:0;width:100%;height:100%;
  object-fit:cover;
  /* O corte é por card, no style do <li>: retrato de estúdio tem o assunto
     no terço de cima e foto de ambiente tem no meio. Cravar um valor único
     decapitaria uma das duas famílias. */
  object-position:var(--swipe-focus,50% 38%);
  pointer-events:none;
}

/* ---------------------------------------------------------------- legenda */
/* Dentro da foto, ancorada embaixo, sobre gradiente mais desfoque mascarado
   (contrato §6). Véu em caixa reta abre borda dura e lê chapado; a máscara
   é o que dissolve a quina de cima do véu. */
.swipe-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  display:flex;flex-direction:column;align-items:flex-start;gap:7px;
  padding:84px 24px 22px;
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.94) 0%,
    rgba(var(--dark-rgb),.58) 46%,
    transparent 100%);
  backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
  mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
  -webkit-mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
}

.swipe-num{
  font-family:var(--display);font-weight:700;
  font-size:clamp(28px,3.2vw,42px);line-height:1;letter-spacing:-.02em;
  color:var(--accent);
}
.swipe-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(19px,1.7vw,24px);line-height:1.15;letter-spacing:-.02em;
  color:var(--light);
}
/* Sem max-width: quem define a medida é a largura do card, e ela já é
   estreita. Um teto em ch aqui quebrava em duas linhas frases que cabiam
   numa — medido a 420px de palco, onde a caixa da legenda tem 372px e as
   cinco linhas do padrão editorial cabem inteiras. O limite real é
   editorial e está na ficha: até ~46 caracteres. */
.swipe-line{
  font-family:var(--sans);
  font-size:clamp(13px,1.15vw,15px);line-height:1.45;
  color:color-mix(in srgb,var(--light) 76%,transparent);
}

/* ---------------------------------------------------------------- movimento reduzido */
/* Sem voo e sem acomodação: a troca é instantânea. O gesto continua inteiro
   — arrastar é coisa pedida pela pessoa, não movimento imposto a ela — só
   que o card volta seco ao lugar em vez de deslizar. O script lê a mesma
   preferência e pula a sequência do voo, senão ficaria esperando um
   transitionend que nunca chega. */
@media(prefers-reduced-motion:reduce){
  .swipe-card{transition:none}
  .swipe-card.is-out{transition:none}
  .swipe-btn{transition:none}
}

/* ---------------------------------------------------------------- toque */
/* Sem ponteiro não existe hover nem cursor de arrasto. Os botões continuam
   sendo a alternativa visível para quem não descobre o gesto. */
@media(hover:none){
  .swipe-stack{cursor:default}
  .swipe-stack:active{cursor:default}
}

/* ---------------------------------------------------------------- mobile */
/* Uma coluna, e a PILHA VEM PRIMEIRO: ela é o que faz parar de rolar. O
   texto vem embaixo, onde é lido por quem já parou. A ordem do DOM continua
   texto → pilha, que é a ordem de leitura correta para teclado e leitor de
   tela; quem troca é só a pintura. */
@media(max-width:760px){
  .swipe-grid{grid-template-columns:1fr;gap:clamp(28px,5vh,44px)}
  .swipe-stage{order:1;--swipe-max:320px}
  .swipe-copy{order:2}
}

@media(max-width:420px){
  .swipe{--swipe-step:10px}
  .swipe-cap{padding:70px 18px 18px;gap:6px}
  .swipe-nav{gap:10px}
}

script.js · 356 linhas · 15,8 KB

/* ==========================================================================
   PILHA DE CARDS COM SWIPE — arrasto, voo, teclado e contador.

   O empilhamento é CSS e não passa por aqui. Este script escreve UM número
   por card (--swipe-n, 0 = topo) e três classes de estado; a fórmula da
   posição, a inclinação e o z-index continuam morando no estilo.css. Mudar
   o desenho da pilha não exige tocar neste arquivo — é o mesmo canal do
   midia/galeria-pasta.

   O QUE ELE FAZ:
     1. arrasta o card de cima com pointer events, escrevendo transform
        inline num rAF só (o CSS da pilha continua valendo para os demais);
     2. passado o limiar de distância OU de velocidade, manda o card voar e
        REORDENA OS ÍNDICES NA HORA — a pilha se acomoda durante o voo, e o
        card só reaparece parado no fundo quando o voo termina;
     3. avança também por clique nos botões, pelas setas do teclado,
        por Enter/Espaço e por um toque na faixa do card de baixo;
     4. mantém contador, aria-live e aria-hidden;
     5. avança sozinho se --swipe-auto for maior que zero — desligado por
        padrão, com pausa no ponteiro, no foco e fora da tela.

   NADA DE MOVER DOM. Reordenar os <li> dentro do <ul> seria a outra forma
   de fazer a mesma coisa, e é pior por três motivos medidos: a ordem de
   leitura do leitor de tela passaria a girar junto (o mesmo bloco seria
   anunciado 03, 04, 05, 01… conforme o uso), reinserir um nó cancela a
   transição em curso nele, e um nó que muda de pai carregando o foco leva o
   foco para o <body> em parte dos navegadores. Com índice, a ordem do
   documento é sempre 01…05, o foco fica onde estava (no <ul>, que nunca se
   move) e o z-index sai do número, não da posição no DOM.

   MOVIMENTO REDUZIDO: sem voo. A troca é instantânea e o arrasto continua
   funcionando, com retorno seco — gesto pedido pela pessoa não é movimento
   imposto a ela. O script precisa saber disso porque sem transição não há
   transitionend, e a espera do pouso nunca terminaria.
   ========================================================================== */

(function () {
  var raizes = document.querySelectorAll('[data-swipe]');
  if (!raizes.length) return;                        // guard

  var LIMIAR_CLIQUE = 6;      /* px: abaixo disso o gesto foi um clique */
  var LIMIAR_FLICK  = 0.55;   /* px/ms: velocidade que dispensa distância */
  var MIN_FLICK     = 14;     /* px mínimos para um lance rápido contar */

  /* A flag do core é a fonte. O plano B existe para o componente colado
     sozinho, sem motion.js — e repete a regra do ?motion=on. */
  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;
  }

  /* Aceita "0.5s" e "500ms" — o CSS pode escrever qualquer um dos dois. */
  function emMs(valor, padrao) {
    valor = (valor || '').replace(/\s/g, '');
    if (!valor) return padrao;
    var n = parseFloat(valor);
    if (isNaN(n)) return padrao;
    return valor.indexOf('ms') > -1 ? n : n * 1000;
  }

  function iniciar(raiz) {
    var pilha = raiz.querySelector('[data-swipe-stack]');
    if (!pilha) return;

    var cards = [].slice.call(pilha.querySelectorAll('.swipe-card'));
    if (cards.length < 2) return;                    /* pilha de um não é pilha */

    var contador = raiz.querySelector('[data-swipe-count]');
    var btnPrev  = raiz.querySelector('[data-swipe-prev]');
    var btnNext  = raiz.querySelector('[data-swipe-next]');

    var reduz  = reduzido();
    var estilo = getComputedStyle(raiz);

    /* Os quatro parafusos públicos moram no CSS, junto do resto do desenho.
       Lidos uma vez: são decisão de projeto, não estado. */
    var profundidade = parseFloat(estilo.getPropertyValue('--swipe-depth')) || cards.length;
    var fracao = parseFloat(estilo.getPropertyValue('--swipe-threshold'));
    if (!(fracao > 0)) fracao = 0.3;
    var durVoo   = emMs(estilo.getPropertyValue('--swipe-fly-dur'), 500);
    var durVolta = emMs(estilo.getPropertyValue('--swipe-back-dur'), 550);
    var auto = (parseFloat(estilo.getPropertyValue('--swipe-auto')) || 0) * 1000;

    /* ordem[k] = índice, no DOM, do card que está no degrau k da pilha.
       É o estado inteiro do componente: uma permutação de cinco números. */
    var ordem = [];
    for (var i = 0; i < cards.length; i++) ordem.push(i);

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

    function pintar() {
      for (var k = 0; k < ordem.length; k++) {
        var c = cards[ordem[k]];
        c.style.setProperty('--swipe-n', String(k));
        /* Só o card de cima é lido. Os de baixo estão sobrepostos e sem
           nenhum elemento focável dentro, então aria-hidden aqui não tranca
           foco em lugar nenhum — a lista continua na ordem 01…05 no DOM. */
        if (k === 0) c.removeAttribute('aria-hidden');
        else c.setAttribute('aria-hidden', 'true');
        if (k >= profundidade) c.classList.add('is-back');
        else c.classList.remove('is-back');
      }
      /* O número anunciado é o do CARD, não o do degrau: quem ouve "2 / 5"
         está no compromisso 02, e é isso que o card mostra escrito. */
      if (contador) contador.textContent = (ordem[0] + 1) + ' / ' + cards.length;
    }

    function girar(passo) {
      if (passo > 0) ordem.push(ordem.shift());
      else ordem.unshift(ordem.pop());
    }

    /* ------------------------------------------------------------ voo */

    var emVoo = null;   /* { card, fim } — no máximo um por vez */

    function encerrarVoo() { if (emVoo) emVoo.fim(); }

    /* Espera o fim da transição de transform, com rede por tempo.
       transitionend não chega em dois casos reais: aba oculta (o navegador
       congela a transição e ela só termina ao voltar) e transição cortada
       por outra classe. Sem a rede, um card ficaria fora da tela para sempre
       e a pilha perderia um item. O teto acompanha a maior das durações
       declaradas no CSS, com folga de 220ms. */
    function aoFim(card, fn) {
      var feito = false, relogio = 0;
      function acabou(e) {
        if (e && (e.target !== card ||
                 (e.propertyName && e.propertyName !== 'transform'))) return;
        if (feito) return;
        feito = true;
        card.removeEventListener('transitionend', acabou);
        clearTimeout(relogio);
        fn();
      }
      card.addEventListener('transitionend', acabou);
      relogio = setTimeout(acabou, Math.max(durVoo, durVolta) + 220);
      return acabou;
    }

    /* Pouso: o card sai da posição de voo e assume, SEM desenhar o caminho,
       o lugar que já é dele no fundo da pilha. Com --swipe-depth:4 e cinco
       cards, esse lugar está em opacity:0 — por isso o teleporte não
       aparece. Com profundidade igual ao número de cards ele apareceria, e
       aí a vaga de pouso teria de ser criada de outro jeito. */
    function pousar(card) {
      emVoo = null;
      card.classList.add('is-instant');
      card.classList.remove('is-out');
      card.style.removeProperty('--swipe-fly');
      /* Leitura de layout de propósito: força o navegador a aplicar o estado
         acima ainda sem transição. Sem este flush, tirar .is-instant no
         mesmo quadro faria o card desenhar o caminho de volta. */
      void card.offsetWidth;
      card.classList.remove('is-instant');
    }

    function avancar(direcao) {
      encerrarVoo();                    /* o voo anterior pousa agora */
      var card = cards[ordem[0]];
      if (reduz) { girar(1); pintar(); return; }
      card.style.setProperty('--swipe-fly', direcao < 0 ? '-1' : '1');
      card.classList.add('is-out');
      girar(1);
      pintar();                         /* a pilha acomoda DURANTE o voo */
      emVoo = { card: card, fim: aoFim(card, function () { pousar(card); }) };
      agendarAuto();
    }

    /* O anterior é o avanço ao contrário: o card do fundo é jogado para fora
       pela direita sem transição, promovido a topo, e só então solto — a
       transição de volta desenha o caminho de entrada. */
    function voltar() {
      encerrarVoo();
      var card = cards[ordem[ordem.length - 1]];
      if (reduz) { girar(-1); pintar(); return; }
      card.classList.add('is-instant');
      card.style.setProperty('--swipe-fly', '1');
      card.classList.add('is-out');
      void card.offsetWidth;
      girar(-1);
      pintar();
      card.classList.remove('is-instant');
      emVoo = { card: card, fim: aoFim(card, function () {
        emVoo = null;
        card.classList.remove('is-out');
        card.classList.remove('is-instant');
        card.style.removeProperty('--swipe-fly');
      }) };
      requestAnimationFrame(function () {
        card.classList.remove('is-out');
        card.style.removeProperty('--swipe-fly');
      });
      agendarAuto();
    }

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

    var arrastando = false, ponteiro = -1, alvo = null;
    var x0 = 0, y0 = 0, dx = 0, dy = 0, andou = 0;
    var tUltimo = 0, vel = 0, largura = 1, pedido = 0;

    /* Um rAF só, e ele existe apenas enquanto o dedo está no card: escreve o
       transform do quadro e se apaga. Fora do gesto não há nada rodando. */
    function desenhar() {
      pedido = 0;
      if (!alvo) return;
      alvo.style.transform = 'translate(' + dx + 'px,' + (dy * 0.3) + 'px) ' +
                             'rotate(' + (dx * 0.06) + 'deg)';
    }

    /* O navegador tem um arrasto próprio para imagem: puxar a foto descola
       dela um fantasma translúcido e o gesto vira drag-and-drop de arquivo.
       setPointerCapture não impede — são dois mecanismos separados, e o
       nativo só morre cancelando o evento. */
    pilha.addEventListener('dragstart', function (e) { e.preventDefault(); });

    pilha.addEventListener('pointerdown', function (e) {
      if (typeof e.button === 'number' && e.button !== 0) return;
      if (arrastando) return;
      encerrarVoo();
      alvo = cards[ordem[0]];
      arrastando = true;
      ponteiro = e.pointerId;
      x0 = e.clientX; y0 = e.clientY;
      dx = 0; dy = 0; andou = 0; vel = 0;
      tUltimo = e.timeStamp || Date.now();
      /* Uma leitura de layout por GESTO, aqui e não a cada quadro: o palco
         não muda de largura enquanto o dedo está nele. */
      largura = pilha.getBoundingClientRect().width || 1;
      alvo.classList.add('is-dragging');
      /* Captura para o arrasto sobreviver ao ponteiro sair do card — que é
         justamente o que acontece quando ele passa do limiar. */
      try { pilha.setPointerCapture(ponteiro); } catch (err) {}
      agendarAuto();
    });

    pilha.addEventListener('pointermove', function (e) {
      if (!arrastando || e.pointerId !== ponteiro) return;
      var agora = e.timeStamp || Date.now();
      var novo = e.clientX - x0;
      var dt = agora - tUltimo;
      /* Velocidade do último trecho, em px/ms. É o que separa um lance
         curto e rápido — que a mão espera que descarte — de um empurrão
         lento até a metade da tela, que a mão espera que volte. */
      if (dt > 0) vel = (novo - dx) / dt;
      tUltimo = agora;
      dx = novo;
      dy = e.clientY - y0;
      andou = Math.max(andou, Math.abs(dx) + Math.abs(dy));
      if (!pedido) pedido = requestAnimationFrame(desenhar);
    });

    function soltar(e, cancelado) {
      if (!arrastando) return;
      if (e && e.pointerId !== undefined && e.pointerId !== ponteiro) return;
      arrastando = false;
      if (pedido) { cancelAnimationFrame(pedido); pedido = 0; }
      try { pilha.releasePointerCapture(ponteiro); } catch (err) {}

      var card = alvo;
      alvo = null;
      if (!card) { agendarAuto(); return; }

      /* Tira o transform inline e devolve a transição ANTES de decidir: se
         o card fica, ele volta deslizando; se vai, o voo parte exatamente de
         onde a mão soltou, porque as duas mudanças caem no mesmo recálculo
         de estilo. */
      card.style.removeProperty('transform');
      card.classList.remove('is-dragging');

      var passou = !cancelado &&
                   (Math.abs(dx) > largura * fracao ||
                    (Math.abs(vel) > LIMIAR_FLICK && Math.abs(dx) > MIN_FLICK));
      if (passou) avancar(dx < 0 ? -1 : 1);
      else agendarAuto();
    }

    pilha.addEventListener('pointerup', function (e) { soltar(e, false); });
    /* pointercancel chega quando o navegador toma o gesto para si — é o que
       acontece ao rolar a página na vertical com o dedo sobre o card, por
       causa do touch-action:pan-y. Ali o card TEM de voltar ao lugar: quem
       rolou a página não pediu para descartar nada. */
    pilha.addEventListener('pointercancel', function (e) { soltar(e, true); });

    /* ------------------------------------------------------------ clique */
    /* Um toque na faixa que sobra do card de baixo avança. É o alvo que a mão
       procura antes de descobrir o arrasto, e é o único jeito de navegar com
       um toque só sem sair do palco. O limiar de 6px separa clique de
       arrasto: sem ele, todo gesto terminaria disparando um avanço extra. */
    pilha.addEventListener('click', function (e) {
      if (andou > LIMIAR_CLIQUE) return;
      var no = e.target;
      while (no && no !== pilha) {
        if (no.classList && no.classList.contains('swipe-card')) {
          if (no !== cards[ordem[0]]) avancar(1);
          return;
        }
        no = no.parentNode;
      }
    });

    /* ------------------------------------------------------------ teclado */
    /* Só o eixo da pilha. Cima e baixo continuam rolando a PÁGINA — quem
       aperta seta para baixo quer descer, não trocar de card. */
    pilha.addEventListener('keydown', function (e) {
      var k = e.key;
      if (k === 'ArrowRight') { e.preventDefault(); avancar(1); }
      else if (k === 'ArrowLeft') { e.preventDefault(); voltar(); }
      else if (k === 'Enter' || k === ' ' || k === 'Spacebar') {
        e.preventDefault(); avancar(1);
      }
    });

    if (btnNext) btnNext.addEventListener('click', function () { avancar(1); });
    if (btnPrev) btnPrev.addEventListener('click', function () { voltar(); });

    /* ------------------------------------------------------------ avanço automático */
    /* Desligado por padrão (--swipe-auto:0). Ligado, é um setTimeout que se
       reagenda: nenhum laço rodando, nada preso a quadro. Pausa sob o
       ponteiro, com o foco dentro, durante o arrasto e fora da tela — nas
       quatro situações a pessoa está lendo ou operando, e trocar o card
       debaixo dela é o defeito clássico de carrossel automático. */
    var sobPonteiro = false, sobFoco = false, visivel = true, relogioAuto = 0;

    function pausado() { return sobPonteiro || sobFoco || arrastando; }

    function agendarAuto() {
      if (relogioAuto) { clearTimeout(relogioAuto); relogioAuto = 0; }
      if (!auto || reduz || !visivel || pausado()) return;
      relogioAuto = setTimeout(function () {
        relogioAuto = 0;
        avancar(1);
      }, auto);
    }

    if (auto) {
      pilha.addEventListener('mouseenter', function () { sobPonteiro = true; agendarAuto(); });
      pilha.addEventListener('mouseleave', function () { sobPonteiro = false; agendarAuto(); });
      raiz.addEventListener('focusin',  function () { sobFoco = true; agendarAuto(); });
      raiz.addEventListener('focusout', function () { sobFoco = false; agendarAuto(); });

      var io = new IntersectionObserver(function (entradas) {
        for (var e = 0; e < entradas.length; e++) visivel = entradas[e].isIntersecting;
        agendarAuto();
      }, { threshold: 0 });
      io.observe(raiz);
    }

    pintar();
    agendarAuto();
  }

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

Duas colunas. À esquerda o argumento da dobra — olho, título, apoio — e, embaixo dele, dois botões redondos com um contador 1 / 5. À direita, cinco fotos em retrato empilhadas uma sobre a outra, cada uma 12px mais abaixo, 4,5% menor e levemente inclinada, como cartas largadas na mesa. O card de cima acompanha o dedo e gira com o gesto, girando a partir da própria base; passado 30% da largura do palco, ele voa para fora, some, e reaparece no fundo da pilha. Não há começo nem fim: a quinta carta é seguida da primeira. Cada card carrega, dentro da própria imagem, um número grande na cor de marca, uma palavra e uma linha.

Resolve a lista curta que não merece uma dobra inteira e não pode virar bullet. Cinco compromissos, cinco princípios, cinco perguntas frequentes de venda — conteúdo que numa grade de cinco caixas fica com cara de tabela de plano, e numa lista com marcador não é lido por ninguém. A pilha compacta os cinco no espaço de um, dá ao visitante um motivo físico para tocar na página, e mantém a coluna de texto livre para argumentar. O preço está declarado nos gotchas: quem não interage lê um card só.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho 1 palavra, substantivo. O rótulo da categoria, não uma frase.
Título (h2.sec-title.ttl) 4 a 7 palavras, com uma em <em>. Diz o que a pilha enumera. Duas linhas na coluna.
Apoio (.sec-lead) 2 a 3 linhas. Enquadra o conjunto e carrega o argumento sozinho — é o que quem não tocar na pilha vai ler.
Número (.swipe-num) Dois dígitos, 01…05, na ordem do DOM. Não é decoração: é o que o contador 2 / 5 está dizendo.
Título do card (.swipe-title) Uma palavra, até 14 caracteres. "Independência", "Método", "Presença". Duas palavras já quebram em duas linhas no palco de 320px.
Linha (.swipe-line) Uma frase, até 46 caracteres, sem ponto final. Medido: a 44 caracteres ela ainda cabe em uma linha visual tanto no palco de 420px quanto no de 320px. Acima disso quebra em duas e a mancha da legenda muda de card para card.
Quantidade Sempre uma a mais que --swipe-depth — cinco cards com profundidade 4. Mínimo prático: 4 cards com --swipe-depth:3.

O que mantém a pilha coesa é o paralelismo gramatical: se um card diz "Sem vínculo com banco ou corretora" (uma ausência), todos dizem o que a empresa não faz ou todos dizem o que ela faz. Como os cinco passam pelo mesmo lugar da tela, com a mesma tipografia e a mesma altura de véu, qualquer variação de forma aparece como defeito e não como variação.

A ordem é editorial. Sem movimento reduzido, sem JavaScript e para quem não toca em nada, o card visível é sempre o 01. Ele tem de ser o compromisso mais forte, não o mais óbvio.

Especificação dos assets


Como usar

<link rel="stylesheet" href="componentes/midia/pilha-swipe/estilo.css">
<script src="componentes/midia/pilha-swipe/script.js"></script>

Cada card declara três coisas no style e nada mais:

<li class="swipe-card" aria-roledescription="slide"
    style="--swipe-n:0;--swipe-rot:-2deg;--swipe-focus:50% 42%">

--swipe-n é o degrau inicial (0, 1, 2…, em sequência); depois disso quem escreve é o script. O último card do markup nasce com class="swipe-card is-back", que é a vaga de pouso.

Ganchos

Gancho Papel
[data-swipe] A raiz. O script parte daqui e lê nela todas as variáveis de ajuste.
[data-swipe-stack] O <ul>. Cobre o palco inteiro, é o elemento focável, recebe o arrasto e as setas, e carrega aria-roledescription="carrossel".
[data-swipe-prev] / [data-swipe-next] Os dois botões da coluna de texto.
[data-swipe-count] O contador, com aria-live="polite".
--swipe-n Degrau do card na pilha, 0 = topo. Escrito pelo script, lido pelo CSS para posição, escala, inclinação e z-index. É o contrato inteiro entre os dois.
--swipe-rot Inclinação do card, no style do <li>. Só vale do segundo degrau em diante.
--swipe-focus Corte da foto, por card, no style do <li>. Padrão 50% 38%.
--swipe-fly Direção do voo (1 ou -1). Escrito pelo script só enquanto o card está fora.
--swipe-max Largura do palco. 420px; 320px abaixo de 760px.
--swipe-step Faixa que cada card deixa aparecer abaixo do de cima. 12px; 10px abaixo de 420px.
--swipe-shrink Encolhimento por degrau. .045.
--swipe-depth Cards à vista. 4. O markup tem de ter um card a mais que isto.
--swipe-tail Reserva de espaço para a cauda da pilha. Calculada de depth e step — não se mexe.
--swipe-threshold Fração da largura do palco que manda o card embora. .3.
--swipe-fly-dur / --swipe-back-dur Voo e acomodação, em segundos. O script lê as duas para dimensionar o próprio fallback de pouso.
--swipe-auto Troca sozinha, em segundos. 0 = desligada (padrão).
.is-dragging No card sob o dedo: sem transição, z-index alto, will-change.
.is-out No card em voo: 140% para o lado, 22° e opacidade zero.
.is-instant Um quadro sem transição, para teleportar sem desenhar o caminho.
.is-back Card fora da pilha visível: opacity:0 e pointer-events:none.

Variações


Gotchas


Histórico

Projeto Onde Data Diferenças
autoral construído do zero na biblioteca 2026-09-03 —

Origem. Padrão de interação recorrente em produto premium: a pilha de cards que se descarta com um gesto lateral, conhecida dos aplicativos de relacionamento e de recrutamento, e reaproveitada em vitrines do Awwwards e em componentes de card stack arrastável do ecossistema Framer/Framer Motion. Não há referência única, e nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O que o padrão de origem quase sempre traz e não atravessa para cá: React, biblioteca de animação com física de mola, e uma pilha que acaba — a versão de aplicativo esvazia a fila porque descartar é a ação; numa dobra de site, esvaziar deixaria um buraco, então aqui a fila dá voltas.

Qual irmão ele substitui, e em que caso (§11 do contrato). Antes de construir, a busca no catálogo deu dois candidatos e nenhum dos dois serve para o caso desta dobra. listas/baralho-sticky empilha cards, mas quem empilha é a rolagem: a sequência é obrigatória e custa uma tela inteira de altura — serve para método em ordem, não para cinco itens sem ordem numa dobra comum. conteudo/pilares-texto-na-foto tem a mesma legenda dentro da foto e resolve três itens lado a lado, parados; com cinco a grade quebra em duas fileiras desiguais e a dobra vira tabela. A pilha-swipe entra exatamente no vão entre os dois: quatro a seis itens curtos, sem ordem, no espaço de um card só e com um gesto para a mão. Fora desse vão, use um dos irmãos.

Decisões de tradução para o contrato.

Medições desta entrega (Chrome headless, preview.html): desktop 1440×900 — seção de 813px, palco de 420×525, console limpo, overflowX: ok, cinco fotos pintadas em 900×1200; arrasto de −260px leva o contador a 2 / 5 e ArrowLeft devolve a 1 / 5, sem sobrar nenhuma classe de estado; mobile 390×844 — overflowX: ok, pilha antes do texto, palco de 320×400, 29px de folga entre a cauda da pilha e o topo da coluna de texto, arrasto de −160px avança; movimento reduzido — transition em 0s, troca completa em menos de 60ms e nenhum card em voo. Conformidade: 0 alertas.