← Biblioteca componentes/listas/etapas-em-abas/ficha.md CódigoTela cheia
listas/etapas-em-abas

Etapas em abas com reprodução

🟢 estavel complexidade alta prefixo eta- etapas processo como-funciona abas tabs rodizio carrossel apg acessibilidade wcag-2-2-2 pausa progresso

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

<link rel="stylesheet" href="componentes/listas/etapas-em-abas/estilo.css">
<script src="componentes/listas/etapas-em-abas/script.js"></script>

markup.html · 228 linhas · 13,8 KB

<!-- ==========================================================================
     ETAPAS EM ABAS COM REPRODUÇÃO — o "como funciona" que anda no tempo
     Prefixo: eta-
     Requer: core (tokens.css, base.css, motion.css) + estilo.css e script.js
     core/motion.js é RECOMENDADO e quase obrigatório: é dele que vêm o
     LIB.reduce e o LIB.pausavel, que é quem mostra e comanda o botão de
     pausa. Sem motion.js o script cai no matchMedia para o movimento
     reduzido e o botão de pausa fica [hidden] como nasceu — a dobra
     continua inteira, com as setas e as abas funcionando.
     Usa as primitivas .wrap, .sec-pad, .eyebrow, h2.sec-title, .sec-lead,
     .ttl, .btn-disc, .btn-play, .nota e .visually-hidden do core. Sem lib.

     A DOBRA. Duas colunas. À esquerda, a lista de etapas: número, título e
     a descrição, que só fica aberta na etapa ativa; embaixo de cada etapa,
     um filete que ENCHE no tempo da etapa e diz quanto falta. À direita, o
     painel que troca junto — a foto (ou a peça de interface) daquela etapa.
     Acima, três discos de reprodução: anterior, pausar, próxima.

     ANDA NO TEMPO, E NÃO NO SCROLL. É a diferença que decide entre esta
     peça e as duas irmãs. O conteudo/rolagem-sincronizada troca o painel
     conforme a pessoa ROLA — quem não rola não vê nada acontecer. O
     listas/linha-do-tempo-etapas não troca nada: mostra as cinco etapas de
     uma vez, empilhadas. Aqui a dobra se apresenta sozinha, como um filme
     curto, e quem quiser dirigir dirige. A ficha tem a tabela de quando
     cada uma ganha.

     PADRÃO DE ABAS DO W3C APG, e não "lista com JavaScript". A coluna da
     esquerda é um role="tablist" de verdade, cada etapa é um role="tab"
     com aria-selected, e cada painel da direita é o role="tabpanel"
     amarrado à aba de mesmo índice. Setas (as quatro), Home e End andam
     pela lista, com roving tabindex: uma parada de Tab para a lista
     inteira. aria-orientation="vertical" porque a lista é vertical em toda
     largura de tela — por isso ↑/↓ funcionam além de ←/→.

     A DESCRIÇÃO MORA DENTRO DO BOTÃO, e isso é consequência do padrão, não
     descuido. Um role="tablist" só pode conter role="tab": não há onde pôr
     um parágrafo irmão da aba sem quebrar a árvore que o leitor de tela
     recebe. Então a descrição é conteúdo da própria aba — o que também
     acerta o alvo de clique, porque a linha inteira, texto incluído, é o
     que a pessoa tenta clicar. A descrição fechada é visibility:hidden
     (depois do fecho, com atraso), e por isso sai da árvore de
     acessibilidade: o nome da aba inativa é só "Política de investimentos",
     e o da aba ativa traz a descrição junto. Ver o estilo.css.

     O NÚMERO É DECORAÇÃO AQUI, e é o único componente de etapas da casa em
     que ele pode ser. O MANUAL §6 registra que `list-style:none` custa a
     semântica de lista no Safari, e que onde a ORDEM é a informação é
     obrigatório devolvê-la — por role="list" ou por número audível. Esta
     peça não é <ol>: é um tablist, e o próprio role entrega posição e total
     ("guia 2 de 4") sem que ninguém escreva nada. O número em aria-hidden
     evita o "zero dois" antes de cada título.

     SEM ID NO MARKUP. aria-controls e aria-labelledby precisam de id, e id
     em markup de biblioteca colide na primeira página que repetir a dobra.
     Quem cria os ids — únicos por instância — e amarra os dois atributos é
     o script.

     SEM JAVASCRIPT a dobra fica INTEIRA e legível: as quatro descrições
     abertas (o fecho é do .is-live, que só o script escreve), o primeiro
     painel visível e nenhum controle morto na tela — os três discos e o
     filete de progresso só existem sob .is-live. Nada de conteúdo que só o
     script revela.

     COMPLIANCE. As etapas descrevem PROCESSO, não resultado: nenhuma fala
     de rentabilidade, retorno ou desempenho, e isso é regra e não estilo
     (ver a seção Compliance da ficha). Os painéis são fotos de exemplo, e
     a .nota do fim da dobra declara isso onde a pessoa lê, não no rodapé
     da página.
     ========================================================================== -->

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

    <header class="eta-head">

      <div class="eta-head-txt">
        <span class="eyebrow" data-rv>Como funciona</span>
        <h2 class="sec-title ttl">Quatro etapas, e a sua <em>por escrito</em></h2>
        <p class="sec-lead" data-rv data-d="2">O mesmo caminho para toda família que atendemos. Ele se apresenta sozinho aqui do lado — pare quando quiser ler com calma.</p>
      </div>

      <!-- OS TRÊS DISCOS. Só aparecem sob .is-live: sem script eles não
           comandariam nada. O do meio é a primitiva .btn-play do core, que
           nasce [hidden] e é o LIB.pausavel quem mostra — com movimento
           reduzido não há troca automática para pausar, o script não chama
           o LIB.pausavel e o botão fica escondido como nasceu (WCAG 2.2.2
           e o precedente do hero/slideshow-cinematico).
           A copy dos dois rótulos mora AQUI, em data-rot-*, e não no
           script: redator nenhum vai procurar texto dentro de um .js. -->
      <div class="eta-ctl" data-rv data-d="3">

        <button class="btn-disc eta-disco" type="button" data-eta-ant
                aria-label="Etapa anterior">
          <svg class="eta-seta" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
            <path d="M14.5 5.5 8 12l6.5 6.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </button>

        <button class="btn-disc btn-play eta-toggle" type="button" data-eta-toggle hidden
                data-rot-pausar="Pausar as etapas" data-rot-tocar="Retomar as etapas"
                aria-label="Pausar as etapas">
          <svg class="btn-play-pausa" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
            <path d="M9 5v14M15 5v14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
          </svg>
          <svg class="btn-play-tocar" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
            <path d="M8 5.5v13l11-6.5z" fill="currentColor"/>
          </svg>
        </button>

        <button class="btn-disc eta-disco" type="button" data-eta-prox
                aria-label="Próxima etapa">
          <svg class="eta-seta" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
            <path d="M9.5 5.5 16 12l-6.5 6.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
        </button>

      </div>
    </header>

    <div class="eta-grid" data-eta-grade>

      <!-- data-rv NO INVÓLUCRO, nunca na lista nem na aba. O filete de
           progresso se move por transform (scaleX), e o core termina todo
           reveal em [data-rv].in{transform:none} — seletor (0,2,0), que
           vence qualquer classe do componente. No caminho ele não incomoda:
           a regra só apaga o transform do PRÓPRIO nó que leva o data-rv.
           Contrato §5, precedente efeitos/inclinacao-3d. -->
      <div class="eta-col" data-rv>
        <div class="eta-lista" role="tablist" aria-orientation="vertical"
             aria-label="Etapas da assessoria" data-eta-lista>

          <!-- ---------------------------------------------------------- 01 -->
          <button class="eta-aba" type="button" role="tab" aria-selected="true"
                  tabindex="0" data-eta-aba>
            <span class="eta-num" aria-hidden="true">01</span>
            <span class="eta-rot" data-eta-rot>Diagnóstico</span>
            <span class="eta-desc"><span class="eta-desc-in">Levantamos o que já existe: patrimônio consolidado nas instituições em que ele está hoje, estrutura da família, compromissos de liquidez e o que foi contratado antes de nós. Nada é recomendado aqui — a etapa serve para enxergar o conjunto de uma vez.</span></span>
            <span class="eta-trilho" aria-hidden="true"><span class="eta-fill"></span></span>
          </button>

          <!-- ---------------------------------------------------------- 02 -->
          <button class="eta-aba" type="button" role="tab" aria-selected="false"
                  tabindex="-1" data-eta-aba>
            <span class="eta-num" aria-hidden="true">02</span>
            <span class="eta-rot" data-eta-rot>Política de investimentos</span>
            <span class="eta-desc"><span class="eta-desc-in">Um documento escrito fixa as faixas de alocação por classe, os limites de risco, a liquidez que precisa ficar disponível e o horizonte de cada objetivo. É ele que orienta as decisões seguintes — as nossas e as suas —, e ele é datado e assinado.</span></span>
            <span class="eta-trilho" aria-hidden="true"><span class="eta-fill"></span></span>
          </button>

          <!-- ---------------------------------------------------------- 03 -->
          <button class="eta-aba" type="button" role="tab" aria-selected="false"
                  tabindex="-1" data-eta-aba>
            <span class="eta-num" aria-hidden="true">03</span>
            <span class="eta-rot" data-eta-rot>Implementação</span>
            <span class="eta-desc"><span class="eta-desc-in">A carteira é montada dentro das faixas aprovadas, na custódia que já é sua. Cada movimentação passa por você antes de acontecer, e a remuneração do assessor é a taxa combinada em contrato — não a operação executada.</span></span>
            <span class="eta-trilho" aria-hidden="true"><span class="eta-fill"></span></span>
          </button>

          <!-- ---------------------------------------------------------- 04 -->
          <button class="eta-aba" type="button" role="tab" aria-selected="false"
                  tabindex="-1" data-eta-aba>
            <span class="eta-num" aria-hidden="true">04</span>
            <span class="eta-rot" data-eta-rot>Comitê e revisão</span>
            <span class="eta-desc"><span class="eta-desc-in">A política volta à mesa a cada semestre: o que mudou na família, na empresa e no cenário, e o que disso pede ajuste de faixa. A revisão sai registrada — decisão datada, com o motivo escrito ao lado —, e a ata fica com você.</span></span>
            <span class="eta-trilho" aria-hidden="true"><span class="eta-fill"></span></span>
          </button>

        </div>
      </div>

      <!-- O PALCO. Os quatro painéis ocupam a mesma caixa, empilhados, e
           trocam em fusão com desfoque — nada entra seco (§5). Sem script,
           só o primeiro tem .is-on e os outros ficam invisíveis: são a
           ilustração da etapa, não conteúdo novo.
           A legenda vai DENTRO da imagem, ancorada embaixo (§6) — embaixo
           da foto ela viraria uma segunda peça. O véu dela é gradiente mais
           backdrop-filter com máscara, que é a receita da casa.
           tabindex="0" no painel ativo é recomendação do APG para painel
           sem nada focável dentro: sem ele, quem anda de Tab nunca alcança
           a legenda. Quem move o atributo entre os painéis é o script. -->
      <div class="eta-palco" data-rv data-d="1">

        <figure class="eta-painel is-on" role="tabpanel" tabindex="0" data-eta-painel>
          <img class="eta-img" src="../../../showroom/demo/ph-wide-2.webp"
               alt="Homem de terno observando a cidade pela janela de um escritório"
               loading="lazy" decoding="async">
          <figcaption class="eta-legenda">O patrimônio consolidado em uma página, nas instituições em que ele já está</figcaption>
        </figure>

        <figure class="eta-painel" role="tabpanel" aria-hidden="true" data-eta-painel>
          <img class="eta-img" src="../../../showroom/demo/ph-wide-3.webp"
               alt="Veleiro sozinho em mar aberto, sob céu carregado"
               loading="lazy" decoding="async">
          <figcaption class="eta-legenda">As faixas de alocação saem escritas, com data e assinatura</figcaption>
        </figure>

        <figure class="eta-painel" role="tabpanel" aria-hidden="true" data-eta-painel>
          <img class="eta-img" src="../../../showroom/demo/ph-wide-1.webp"
               alt="Casal caminhando por uma estrada de terra entre vinhedos, ao entardecer"
               loading="lazy" decoding="async">
          <figcaption class="eta-legenda">Cada movimentação passa por você antes de acontecer</figcaption>
        </figure>

        <figure class="eta-painel" role="tabpanel" aria-hidden="true" data-eta-painel>
          <img class="eta-img" src="../../../showroom/demo/ph-wide-4.webp"
               alt="Sala de reunião vazia com vista para a cidade"
               loading="lazy" decoding="async">
          <figcaption class="eta-legenda">A ata do comitê fica com a família, e não só conosco</figcaption>
        </figure>

      </div>
    </div>

    <!-- A REGIÃO VIVA. Só a troca AUTOMÁTICA é anunciada aqui: a troca
         pedida por alguém já é narrada pela própria aba, que recebe o foco
         e o aria-selected, e anunciar as duas faria o leitor de tela dizer
         cada etapa duas vezes. Fica em .visually-hidden — na árvore de
         acessibilidade, fora da tela e sem ocupar espaço. -->
    <p class="visually-hidden" aria-live="polite" data-eta-vivo></p>

    <!-- A ressalva mora ONDE A PESSOA VÊ A PEÇA, não no rodapé da página.
         Duas classes no mesmo elemento: .nota é a tipografia da primitiva
         do core, .eta-nota é a margem e o alinhamento, que continuam
         locais de propósito. -->
    <p class="nota eta-nota" data-rv>Os painéis ao lado são fotos ilustrativas e não representam um caso real de cliente. Os prazos e a periodicidade do comitê são acordados em contrato e variam com a estrutura do patrimônio.</p>

  </div>
</section>

estilo.css · 380 linhas · 15,1 KB

/* ==========================================================================
   ETAPAS EM ABAS COM REPRODUÇÃO — prefixo eta-
   Lista de etapas à esquerda (role="tablist"), painel de mídia à direita,
   e três discos de reprodução acima. A etapa ativa abre a descrição e o
   filete embaixo dela enche no tempo da etapa.

   Tokens usados:
     --bg --fg --fg-2 --fg-muted   tinta e superfície, do escopo da seção
     --rule                        trilho do filete e fio da ressalva
     --dark --light                o véu e a tinta da legenda, que está sobre
                                   FOTO e não sobre o fundo do escopo
     --accent                      o filete que enche (é GRÁFICO, não texto)
     --accent-ink                  o número da etapa ativa (é TEXTO sobre o
                                   fundo do escopo — MANUAL §3; o var() traz
                                   --accent de fallback para quem colar a
                                   dobra fora de um escopo de tom)
     --display --sans              tipografia
     --ease                        curva do projeto
     --radius-media                canto do painel
     --nota-max                    medida da ressalva (parafuso da primitiva)
   A coluna vem do .wrap do core: --max e --pad não são lidos aqui.

   UM RELÓGIO SÓ. --eta-dur é o tempo de cada etapa, e ele é lido em dois
   lugares que não podem discordar: a animação do filete (aqui) e o
   setTimeout do script, que a pega por getComputedStyle na raiz. Nunca há
   dois números para manter em sincronia.

   O FILETE É CSS, NÃO JAVASCRIPT ESCREVENDO LARGURA POR QUADRO. A regra
   que anima casa com a aba de aria-selected="true", e só com ela — então
   trocar de etapa tira a regra de uma aba e a põe na outra, e uma animação
   que passa a casar COMEÇA DO ZERO. O reinício a cada troca é de graça, e
   não custa uma linha de script. Pausar é animation-play-state, escrito
   numa regra mais específica que a shorthand (ver abaixo).
   ========================================================================== */

.eta{
  /* Relógio da dobra: quanto cada etapa fica em cena. Seis segundos é o
     tempo de ler uma descrição de ~45 palavras sem correr. Abaixo de 4s a
     troca vira pisca-pisca; acima de 9s a dobra parece travada e o filete
     parece parado. O script lê ESTE valor. */
  --eta-dur:6s;

  /* Fusão entre um painel e o seguinte. */
  --eta-swap:.7s;

  /* Abertura e fecho da descrição. Mais curta que a fusão do painel de
     propósito: o texto precisa ter assentado antes de a foto terminar de
     trocar, senão as duas coisas se movem ao mesmo tempo e o olho não sabe
     para onde ir. */
  --eta-open:.44s;

  /* Desfoque da fusão do painel. Nada entra seco (contrato §5). */
  --eta-blur:14px;

  /* Corpo do título de cada etapa. */
  --eta-rot:clamp(17px,1.5vw,21px);

  /* Proporção do palco. 16/11 é a caixa que mais se aproxima da altura da
     coluna de quatro etapas sem cortar demais uma foto 16/9. */
  --eta-palco:16 / 11;
}

/* ===== CABEÇALHO ===== */

.eta-head{
  display:grid;
  grid-template-columns:minmax(0,1fr) auto;
  align-items:end;
  column-gap:clamp(24px,4vw,64px);
}
.eta-head-txt{min-width:0}

/* OS TRÊS DISCOS SÓ EXISTEM COM SCRIPT. Sem JavaScript eles não comandam
   nada, e botão que não faz nada é pior que botão que não existe — mesma
   decisão da barra de abas do hero/slideshow-cinematico. Quem escreve
   .is-live é o script, na primeira linha da inicialização.
   O [hidden] do botão de pausa não precisa de regra aqui: quem dá display
   a ele é o .btn-disc do core, e o .btn-play[hidden]{display:none} da
   mesma folha vem depois e vence (§6.5, já resolvido na primitiva). */
.eta-ctl{display:none}
.eta.is-live .eta-ctl{
  display:flex;align-items:center;
  gap:10px;
  padding-bottom:6px;
}
.eta-seta{width:17px;height:17px}

/* ===== GRADE ===== */

.eta-grid{
  display:grid;
  grid-template-columns:minmax(0,1fr) minmax(0,1.04fr);
  column-gap:clamp(28px,4.4vw,72px);
  align-items:start;
  margin-top:clamp(34px,4.6vh,56px);
}
.eta-col{min-width:0}

.eta-lista{
  display:flex;flex-direction:column;
  min-width:0;
}

/* ===== A ABA =====
   Botão de verdade, sem cara de botão. O reset é local porque o .btn do
   core é outra coisa (pílula de rótulo, com fundo e padding fixos), e o
   .btn-disc é o disco de ícone — nenhum dos dois cabe numa linha de lista.

   TUDO É <span>, e não é preguiça: <button> só aceita conteúdo de
   frase, então um <p> ou um <div> aqui dentro seria markup inválido. O
   display vem do CSS, que não liga para isso. */
.eta-aba{
  display:grid;
  grid-template-columns:auto minmax(0,1fr);
  column-gap:clamp(12px,1.4vw,18px);
  width:100%;
  padding:clamp(16px,2.2vh,22px) 0 0;
  border:0;background:none;
  text-align:start;
  color:var(--fg-2);
  transition:color .35s var(--ease);
}
.eta-aba:hover{color:var(--fg)}
.eta-aba[aria-selected="true"]{color:var(--fg)}

.eta-num{
  grid-column:1;grid-row:1;
  font-family:var(--display);font-weight:700;
  font-size:12px;letter-spacing:.1em;line-height:1.7;
  color:var(--fg-muted);
  transition:color .35s var(--ease);
}
/* O número da etapa ativa é TEXTO sobre o fundo do escopo, e por isso lê
   --accent-ink e não --accent: em seção clara o accent puro dá 1.36:1 com
   o sage da Liberta, contra um mínimo de 3:1 (MANUAL §3). O filete logo
   abaixo continua em --accent puro — ele é traço, não glifo. */
.eta-aba[aria-selected="true"] .eta-num{color:var(--accent-ink,var(--accent))}

.eta-rot{
  grid-column:2;grid-row:1;
  font-family:var(--display);font-weight:700;
  font-size:var(--eta-rot);letter-spacing:-.015em;line-height:1.24;
  color:currentColor;
}

/* ===== A DESCRIÇÃO =====
   Fechar e abrir por grid-template-rows de 0fr a 1fr: é a única técnica
   que anima ATÉ A ALTURA DO CONTEÚDO sem um número mágico. max-height
   obrigaria a chutar um teto — e um teto curto corta a descrição longa em
   silêncio, enquanto um teto folgado faz o fecho parecer atrasado, porque
   a transição gasta metade do tempo percorrendo altura que não existe.

   Onde a interpolação entre fr não existe (antes de Chrome 107, Firefox
   120 e Safari 17.2) a troca acontece SEM transição — abre e fecha na
   hora. Nada quebra, e é o degrau certo para uma peça de conteúdo.

   ABERTA É O ESTADO PADRÃO, e isso é o que faz a dobra funcionar sem
   JavaScript: as quatro descrições ficam visíveis, e quem as fecha é o
   .is-live que só o script escreve.

   POR QUE minmax(0,…) E NÃO 0fr/1fr SECOS, e isto foi medido duas vezes:
   `grid-template-rows:0fr` é, por especificação, `minmax(auto,0fr)`, e o
   mínimo automático de um item de grade é o min-content dele — a linha se
   recusa a descer abaixo da altura do texto e a descrição "fechada" fica
   do mesmo tamanho da aberta. Medido: as quatro em 103.938px, fechadas e
   abertas. Zerar o mínimo do ITEM (min-height:0) baixou para 10px, que é
   o padding do filho — o padding continua contando no min-content. Só
   escrever o mínimo na PISTA, com minmax(0,…), fecha de verdade. Medido
   depois: aberta 103.938px, fechada 0px. As duas pontas têm o mesmo
   mínimo, o que também é o que permite a transição interpolar.

   O overflow:hidden mora no invólucro da grade, não no texto: é ele que
   corta o padding do filho enquanto a linha encolhe. */
.eta-desc{
  grid-column:2;grid-row:2;
  display:grid;grid-template-rows:minmax(0,1fr);
  overflow:hidden;
}
.eta-desc-in{
  display:block;
  /* Cinto além do suspensório: o minmax(0,…) da pista já basta, e este
     min-height é o que segura o caso de alguém trocar a pista por `0fr`
     seco sem saber por que ela estava escrita assim. */
  min-height:0;
  padding-top:10px;
  font-size:14.5px;line-height:1.62;letter-spacing:normal;
  color:var(--fg-2);
  max-width:56ch;
}
.eta.is-live .eta-desc{
  grid-template-rows:minmax(0,0fr);
  visibility:hidden;
  transition:grid-template-rows var(--eta-open) var(--ease),
             visibility 0s linear var(--eta-open);
}
/* A visibilidade não é enfeite: ela TIRA a descrição fechada da árvore de
   acessibilidade. Como a descrição mora dentro do <button role="tab">
   (um tablist só pode conter tabs, ver o markup), sem isto o nome da aba
   inativa carregaria as quarenta e cinco palavras dela. O atraso faz o
   texto desaparecer só depois do fecho — sem ele a descrição sumiria de
   uma vez e a altura animaria sozinha, vazia. */
.eta.is-live .eta-aba[aria-selected="true"] .eta-desc{
  grid-template-rows:minmax(0,1fr);
  visibility:visible;
  transition:grid-template-rows var(--eta-open) var(--ease),visibility 0s;
}
/* Quem pediu menos movimento troca de etapa sem ver a altura animar. */
@media(prefers-reduced-motion:reduce){
  .eta.is-live .eta-desc,
  .eta.is-live .eta-aba[aria-selected="true"] .eta-desc{transition:none}
}

/* ===== O FILETE =====
   O trilho de cada aba é também o fio que separa uma etapa da seguinte —
   por isso não há border-bottom na aba: seria uma linha em cima da outra. */
.eta-trilho{
  grid-column:1 / -1;grid-row:3;
  margin-top:clamp(14px,1.8vh,18px);
  height:2px;
  background:var(--rule);
  overflow:hidden;
}
.eta-fill{
  display:block;height:100%;
  background:var(--accent);
  transform:scaleX(0);transform-origin:left center;
}
/* Enche no tempo da etapa. Mesma fonte que o script: --eta-dur.
   A regra casa SÓ com a aba selecionada — é a troca de aba que reinicia a
   animação, porque uma animação que passa a casar começa do zero. */
.eta.is-live .eta-aba[aria-selected="true"] .eta-fill{
  animation:etaFill var(--eta-dur) linear forwards;
}
@keyframes etaFill{
  from{transform:scaleX(0)}
  to{transform:scaleX(1)}
}
/* A PAUSA PRECISA VENCER A SHORTHAND. `animation:etaFill …` é shorthand e
   zera todas as longhands dela, inclusive animation-play-state. A regra
   acima vale (0,5,0); esta soma .is-paused e vale (0,6,0). Escrita com
   menos, o botão de pausa "não funcionaria" — sem erro nenhum no console.
   É a mesma armadilha registrada na ficha do hero/slideshow-cinematico. */
.eta.is-live.is-paused .eta-aba[aria-selected="true"] .eta-fill{
  animation-play-state:paused;
}
/* Sem relógio não há o que contar: o filete SOME. Deixá-lo cheio, como faz
   o slideshow, mentiria menos ali (onde ele é a régua da barra de abas) do
   que aqui, onde ele é a única peça que se move — um filete cheio parado
   embaixo da etapa ativa leria como "esta etapa terminou". */
@media(prefers-reduced-motion:reduce){
  .eta.is-live .eta-aba[aria-selected="true"] .eta-fill{
    animation:none;transform:scaleX(0);
  }
}

/* ===== O PALCO =====
   Os quatro painéis ocupam a mesma caixa, empilhados e absolutos. Quem dá
   altura à caixa é o aspect-ratio, e não o conteúdo — assim a troca nunca
   muda a altura da dobra, qualquer que seja o recorte da foto que entra. */
.eta-palco{
  position:relative;
  min-width:0;
  aspect-ratio:var(--eta-palco);
  border-radius:var(--radius-media);
  overflow:hidden;
  background:color-mix(in srgb,var(--fg) 6%,transparent);
}
.eta-painel{
  position:absolute;inset:0;
  margin:0;
  opacity:0;visibility:hidden;
  transform:scale(1.03);
  filter:blur(var(--eta-blur));
  transition:opacity var(--eta-swap) var(--ease),
             transform var(--eta-swap) var(--ease),
             filter var(--eta-swap) var(--ease),
             visibility 0s linear var(--eta-swap);
}
/* SEM SCRIPT o primeiro painel já nasce com .is-on no markup: a dobra abre
   com foto, e os outros três ficam fora da tela e fora do foco. */
.eta-painel.is-on{
  opacity:1;visibility:visible;
  transform:scale(1);
  filter:blur(0);
  transition:opacity var(--eta-swap) var(--ease),
             transform var(--eta-swap) var(--ease),
             filter var(--eta-swap) var(--ease),
             visibility 0s;
}
.eta-img{
  width:100%;height:100%;
  object-fit:cover;
  /* block-size:auto seria o contrário do que se quer aqui: a imagem TEM de
     preencher a caixa do palco. Por isso os <img> do markup não trazem
     width/height — atributo de dimensão vence o aspect-ratio e o palco
     desandaria (MANUAL §6, armadilha 2). */
}

/* ===== A LEGENDA =====
   Dentro da imagem, ancorada embaixo (contrato §6): embaixo da foto ela
   viraria uma segunda peça.

   O VÉU É ::before DA PRÓPRIA LEGENDA, e não uma faixa de altura fixa no
   palco: véu medido em porcentagem do card é ultrapassado por um texto de
   três linhas em tela estreita, e aí a legenda sai do gradiente (MANUAL
   §6, armadilha 3). Aqui ele cresce com o texto, sempre.
   backdrop-filter sempre acompanhado de máscara em gradiente — caixa reta
   produz borda dura e lê "chapado".

   O z-index:2 aqui não é para subir na pilha: é o que faz a legenda virar
   CONTEXTO DE EMPILHAMENTO, para que o z-index:-1 do véu fique atrás do
   texto e ainda assim acima da foto. Sem ele o véu desce para a pilha do
   painel e some debaixo do <img>. Precedente: midia/grade-cases-foto. */
.eta-legenda{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  padding:clamp(34px,5vw,54px) clamp(16px,2.4vw,26px) clamp(14px,2vw,20px);
  font-family:var(--sans);font-size:13px;line-height:1.5;
  letter-spacing:normal;
  color:var(--light);
  text-wrap:balance;
}
.eta-legenda::before{
  content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;
  background:linear-gradient(to top,
             color-mix(in srgb,var(--dark) 82%,transparent) 0%,
             color-mix(in srgb,var(--dark) 52%,transparent) 48%,
             transparent 100%);
  backdrop-filter:blur(7px);
  -webkit-backdrop-filter:blur(7px);
  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%);
}

/* ===== A RESSALVA =====
   .nota do core carrega a tipografia; daqui saem só margem e alinhamento,
   que são posição e continuam locais (ver o comentário da primitiva). */
.eta-nota{
  margin-top:clamp(28px,4vh,44px);
  padding-top:16px;
  border-top:1px solid var(--rule);
  --nota-max:78ch;
}

/* ===== TOQUE =====
   Onde não há ponteiro, o hover não existe e a aba precisa nascer legível.
   A tinta de repouso já é --fg-2, que fecha contraste sozinha; o que some
   é só o realce de passagem. */
@media(hover:none){
  .eta-aba:hover{color:var(--fg-2)}
  .eta-aba[aria-selected="true"]:hover{color:var(--fg)}
}

/* ===== LARGURA ===== */

/* Abaixo de 940px a grade desaba em uma coluna e o PALCO SOBE, por order.
   É posicionamento, não ordem de leitura: a foto é ilustração da etapa, e
   quem chega por leitor de tela continua ouvindo a lista antes dela. Com o
   palco embaixo, a troca automática aconteceria fora da tela de quem está
   lendo as etapas — o que é o mesmo que não trocar. Precedente:
   conteudo/rolagem-sincronizada. */
@media(max-width:940px){
  .eta-grid{
    grid-template-columns:minmax(0,1fr);
    row-gap:clamp(24px,4vh,38px);
  }
  .eta-palco{order:-1}
  .eta{--eta-palco:16 / 10}
}

@media(max-width:720px){
  .eta-head{
    grid-template-columns:minmax(0,1fr);
    row-gap:22px;
    align-items:start;
  }
  .eta.is-live .eta-ctl{padding-bottom:0}
  .eta{--eta-blur:8px}
  .eta-desc-in{font-size:14px}
  .eta-legenda{font-size:12.5px}
}

script.js · 369 linhas · 16,4 KB

/* ==========================================================================
   ETAPAS EM ABAS — o relógio, a escolha e o teclado.

   O movimento é todo CSS: a fusão do painel, a abertura da descrição e o
   filete que enche são transição e @keyframes. Este script não desenha
   quadro nenhum, não escreve largura nem opacidade, e não tem
   requestAnimationFrame em laço. A única coisa presa ao tempo é UM
   setTimeout por instância, cancelado em toda pausa.

   O que ele faz, e é só isto:

     1. marca a raiz com .is-live — é o que fecha as descrições e faz os
        três discos existirem. Sem JavaScript a dobra abre INTEIRA, com as
        quatro descrições visíveis e nenhum controle morto na tela;
     2. cria ids únicos por instância e amarra aria-controls /
        aria-labelledby entre cada aba e o painel dela (o markup não traz
        id: id de biblioteca colide na primeira página que repetir a dobra);
     3. troca de etapa sozinho a cada --eta-dur, lido do CSS por
        getComputedStyle — o filete e o relógio nunca têm dois números;
     4. para quando ninguém está vendo (aba do navegador oculta, dobra fora
        da tela) ou quando alguém está lendo (ponteiro sobre a lista, foco
        dentro da grade, botão de pausa apertado) — e retoma do ponto exato
        em que parou, nunca do começo;
     5. ouve clique, as quatro setas, Home/End e os três discos.

   QUEM ESCOLHE DESLIGA O RODÍZIO, e isso é decisão de desenho, não
   economia: uma lista que continua trocando sozinha depois de alguém ter
   clicado numa etapa é uma lista que ninguém consegue ler. Então qualquer
   escolha explícita — clique na aba, seta do teclado, disco de anterior ou
   próxima — vale como um "pause": o relógio para e NÃO volta sozinho. O
   caminho de volta é o próprio botão de reprodução, que já existe e cujo
   rótulo já diz "Retomar as etapas". Precedente da regra:
   conteudo/trio-cards-ui-simulada, que trava o rodízio no primeiro clique.

   O ÍCONE NUNCA DISCORDA DO ESTADO REAL. Toda pausa — pedida no botão,
   pedida por uma escolha, ou automática porque a aba ficou oculta, a dobra
   saiu da tela, o ponteiro entrou na lista ou o foco entrou na seção —
   passa pelo mesmo LIB.pausavel, via ctl.trocar(). Um botão mostrando
   "pausar" enquanto nada anda é pior que botão nenhum: ele afirma um
   estado falso. O preço assumido é que o ícone vira ▶ enquanto o ponteiro
   está sobre a lista; é a verdade, e ela informa.

   MOVIMENTO REDUZIDO: o relógio não liga, o LIB.pausavel não é chamado e o
   botão fica [hidden] como nasceu — não há troca automática para pausar
   (precedente: hero/slideshow-cinematico). As abas, as setas e os discos
   de anterior/próxima continuam funcionando: gesto pedido pela pessoa não
   é movimento imposto a ela.

   Nenhum listener de scroll, próprio ou do core: esta peça não depende da
   rolagem.
   ========================================================================== */

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

  /* Contador de instâncias — a base dos ids únicos por dobra. */
  var conta = 0;

  /* A flag do core é a fonte. O fallback 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;
  }

  /* "6s" e "600ms" viram milissegundos. getComputedStyle devolve o token do
     jeito que foi escrito (com o espaço da declaração), não um número
     normalizado — daí o replace antes do parseFloat. */
  function emMs(txt, padrao) {
    if (!txt) return padrao;
    txt = ('' + txt).replace(/\s/g, '');
    var n = parseFloat(txt);
    if (isNaN(n) || n <= 0) return padrao;
    return txt.indexOf('ms') > -1 ? n : n * 1000;
  }

  function iniciar(raiz) {
    var abas    = [].slice.call(raiz.querySelectorAll('[data-eta-aba]'));
    var paineis = [].slice.call(raiz.querySelectorAll('[data-eta-painel]'));
    var n = Math.min(abas.length, paineis.length);
    /* Menos de duas etapas não é rodízio: sai em silêncio e a dobra fica
       sendo uma lista estática — inclusive sem os discos, porque o
       .is-live só é escrito depois daqui. */
    if (n < 2) return;

    var lista = raiz.querySelector('[data-eta-lista]');
    var grade = raiz.querySelector('[data-eta-grade]');
    var vivo  = raiz.querySelector('[data-eta-vivo]');
    var botao = raiz.querySelector('[data-eta-toggle]');
    var bAnt  = raiz.querySelector('[data-eta-ant]');
    var bProx = raiz.querySelector('[data-eta-prox]');

    var dur   = emMs(getComputedStyle(raiz).getPropertyValue('--eta-dur'), 6000);
    var reduz = reduzido();

    /* Qual etapa o markup deixou acesa. É a primeira por convenção, mas ler
       do DOM evita que trocar a ordem no HTML quebre a primeira pintura. */
    var atual = 0, i;
    for (i = 0; i < n; i++) {
      if (abas[i].getAttribute('aria-selected') === 'true') { atual = i; break; }
    }

    /* ------------------------------------------------------------ ids e ARIA */
    var pre = 'eta-' + (++conta) + '-';
    for (i = 0; i < n; i++) {
      abas[i].id = pre + 't' + i;
      paineis[i].id = pre + 'p' + i;
      abas[i].setAttribute('aria-controls', paineis[i].id);
      paineis[i].setAttribute('aria-labelledby', abas[i].id);
    }

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

    function pintar() {
      for (var k = 0; k < n; k++) {
        var ativa = (k === atual);
        abas[k].setAttribute('aria-selected', ativa ? 'true' : 'false');
        /* Roving tabindex: uma parada de Tab para a lista inteira, e as
           setas andam por dentro dela. É o que o padrão de abas pede. */
        abas[k].setAttribute('tabindex', ativa ? '0' : '-1');

        if (ativa) {
          paineis[k].classList.add('is-on');
          paineis[k].removeAttribute('aria-hidden');
          paineis[k].removeAttribute('inert');
          /* O APG pede tabindex="0" no painel que não tem nada focável
             dentro, senão quem anda de Tab nunca alcança a legenda. */
          paineis[k].setAttribute('tabindex', '0');
        } else {
          paineis[k].classList.remove('is-on');
          paineis[k].setAttribute('aria-hidden', 'true');
          /* Três travas para o mesmo problema, e cada uma cobre o buraco da
             outra: aria-hidden tira da árvore de acessibilidade; inert tira
             do foco e do clique (é recente — Chrome 102, Safari 15.5,
             Firefox 112); tirar o tabindex é a rede para quem não tem
             inert. O visibility:hidden do CSS já tiraria do foco sozinho,
             mas ele só entra DEPOIS da fusão (tem atraso), e nesse
             intervalo o painel que sai continuaria alcançável por Tab. */
          paineis[k].setAttribute('inert', '');
          paineis[k].removeAttribute('tabindex');
        }
      }
    }

    /* O leitor de tela não deve narrar a etapa que a pessoa acabou de
       escolher: a própria aba já é anunciada, com o foco e o aria-selected,
       e a região viva diria tudo de novo. Só a troca AUTOMÁTICA passa por
       aqui — que é justamente a que ninguém pediu e ninguém veria. */
    function anunciar() {
      if (!vivo) return;
      var rot = abas[atual].querySelector('[data-eta-rot]');
      vivo.textContent = 'Etapa ' + (atual + 1) + ' de ' + n + ': ' +
                         (rot ? rot.textContent : '');
    }

    /* ------------------------------------------------------------ relógio */

    var timer = 0, t0 = 0, restante = dur;
    var travado = false;             /* pausa pedida: botão ou escolha */
    var oculto = !!document.hidden;  /* aba do navegador em segundo plano */
    var naTela = true;               /* dobra dentro da janela */
    var sobPonteiro = false;         /* ponteiro sobre a lista de etapas */
    var comFoco = false;             /* foco dentro da seção */
    var ctl = null;                  /* o LIB.pausavel, quando existe */
    var refletindo = false;          /* estamos escrevendo no botão? */

    function podeCorrer() {
      return !reduz && !travado && !oculto && naTela && !sobPonteiro && !comFoco;
    }

    /* Guarda o que FALTA, não quanto já passou: é isso que faz o filete e o
       relógio retomarem juntos. Sem descontar o tempo decorrido, um hover
       de meio segundo devolveria 6s cheios ao timer enquanto o filete
       continuaria de onde parou, e os dois se descolariam a cada pausa. */
    function parar() {
      if (timer) {
        clearTimeout(timer);
        timer = 0;
        restante = Math.max(0, restante - (Date.now() - t0));
      }
      raiz.classList.add('is-paused');
    }

    function correr() {
      if (timer || !podeCorrer()) return;
      t0 = Date.now();
      timer = setTimeout(bater, restante);
      raiz.classList.remove('is-paused');
    }

    function reiniciarRelogio() {
      if (timer) { clearTimeout(timer); timer = 0; }
      restante = dur;
      /* Escreve a pausa antes de tentar correr: se não puder (ponteiro em
         cima, por exemplo), a classe fica e segura o filete da etapa nova
         no primeiro quadro. Não há pintura entre as duas linhas. */
      raiz.classList.add('is-paused');
      correr();
    }

    /* O botão é a cara do estado, então ele mostra o estado EFETIVO, e não
       só o que foi pedido no clique. `refletindo` existe porque
       LIB.pausavel chama de volta a cada troca: sem a trava, a pausa
       automática do ponteiro seria lida como intenção da pessoa e
       apagaria um pause de verdade quando o ponteiro saísse. */
    function refletir() {
      if (!ctl) return;
      refletindo = true;
      ctl.trocar(!podeCorrer());
      refletindo = false;
    }

    function atualizar() {
      if (podeCorrer()) correr(); else parar();
      refletir();
    }

    function bater() {
      timer = 0;
      mudar(atual + 1, true);
    }

    /* ------------------------------------------------------------ troca */

    function mudar(idx, automatico) {
      idx = (idx % n + n) % n;        /* circular: -1 vira a última */
      if (idx === atual) return;
      atual = idx;
      pintar();
      if (automatico) anunciar();
      reiniciarRelogio();
    }

    /* A escolha explícita — clique, seta, disco. Trava ANTES de mudar, para
       que o reiniciarRelogio() lá dentro já nasça sem poder correr. */
    function escolher(idx) {
      travado = true;
      mudar(idx, false);
      atualizar();
    }

    /* ------------------------------------------------------------ eventos */

    for (i = 0; i < n; i++) {
      (function (k) {
        /* Sem roubar o foco: quem clicou já está com o ponteiro ali, e
           mover o foco na hora do clique faria o anel aparecer para quem
           usa mouse em alguns navegadores. Quem chega pelo teclado passa
           pelo keydown abaixo, que move o foco de propósito. */
        abas[k].addEventListener('click', function () { escolher(k); });
      })(i);
    }

    /* As quatro setas, porque a lista é vertical na tela e horizontal na
       leitura de quem veio de um carrossel — e o aria-orientation do markup
       diz "vertical", que é o que faz o leitor de tela sugerir ↑/↓.
       Home/End só valem com o foco numa aba; fora dali elas pertencem à
       página, e roubá-las tiraria de quem só queria ir ao topo. */
    if (lista) {
      lista.addEventListener('keydown', function (e) {
        var pos = -1, j;
        for (j = 0; j < n; j++) if (abas[j] === e.target) pos = j;

        var alvo;
        if (e.key === 'ArrowRight' || e.key === 'ArrowDown') alvo = atual + 1;
        else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') alvo = atual - 1;
        else if (e.key === 'Home' && pos > -1) alvo = 0;
        else if (e.key === 'End' && pos > -1) alvo = n - 1;
        else return;

        e.preventDefault();
        alvo = (alvo % n + n) % n;
        escolher(alvo);
        /* O foco vai SEMPRE para a aba da etapa nova. Sem isto, quem
           apertou a seta ficaria com o foco numa aba que virou
           tabindex="-1", e a próxima tabulação recomeçaria do começo da
           página — a pessoa perde o lugar. */
        abas[alvo].focus();
      });
    }

    if (bAnt)  bAnt.addEventListener('click',  function () { escolher(atual - 1); });
    if (bProx) bProx.addEventListener('click', function () { escolher(atual + 1); });

    /* ------------------------------------------------------------ abertura */

    /* .is-live antes de qualquer outra coisa: é ele que fecha as descrições
       e faz os três discos existirem. O script mora no fim do <body>, então
       isto roda antes da primeira pintura. */
    raiz.classList.add('is-live');
    pintar();

    /* Com movimento reduzido não há troca automática para pausar, e o
       LIB.pausavel não é chamado: o botão fica [hidden] como nasceu. O
       teste de LIB.pausavel também cobre o componente colado sem o
       motion.js do core — ali o botão simplesmente não aparece, e o resto
       da dobra continua inteiro. */
    if (!reduz && botao && window.LIB && typeof window.LIB.pausavel === 'function') {
      ctl = window.LIB.pausavel(botao, function (pausado) {
        if (refletindo) return;      /* fomos nós que escrevemos: não é intenção */
        travado = pausado;
        atualizar();
      });
    }

    /* Daqui para baixo é só rodízio automático. Com movimento reduzido a
       dobra fica na primeira etapa, legível e navegável, sem temporizador e
       sem observador para desligar depois. */
    if (reduz) { raiz.classList.add('is-paused'); return; }

    /* Pausa por ponteiro só na LISTA, e não na seção inteira: a dobra ocupa
       a largura da coluna, e o ponteiro parado em qualquer canto congelaria
       o rodízio para sempre sem que ninguém entendesse por quê. Sobre os
       discos também não pausa — quem está indo apertar o botão não deve
       ver o ícone mudar embaixo do cursor. */
    if (lista) {
      lista.addEventListener('mouseenter', function () { sobPonteiro = true; atualizar(); });
      lista.addEventListener('mouseleave', function () { sobPonteiro = false; atualizar(); });
    }

    /* O FOCO PAUSA NA GRADE, E NÃO NA SEÇÃO INTEIRA — e a diferença aqui
       não é de gosto, é de um defeito que os três discos teriam.

       Clicar num <button> dá foco a ele, e `focusin` dispara no mousedown,
       ANTES do `click`. Com o foco medido na seção inteira, apertar o botão
       de pausa acionaria primeiro o focusin (que pausa e escreve "pausado"
       no botão) e só depois o clique do próprio LIB.pausavel, que alterna a
       partir do estado que acabou de ser escrito — e DESPAUSARIA. O botão
       de pausa faria o contrário do que diz, sem erro nenhum no console.

       Medir o foco só onde alguém está LENDO (a lista e o painel) resolve
       o defeito e é a leitura certa da regra: os controles de reprodução
       não são conteúdo, e ter o foco neles não é motivo para parar nada. */
    if (grade) {
      grade.addEventListener('focusin', function () { comFoco = true; atualizar(); });
      grade.addEventListener('focusout', function (e) {
        /* relatedTarget é quem VAI receber o foco: continuar dentro da
           grade não é sair dela. */
        if (e.relatedTarget && grade.contains(e.relatedTarget)) return;
        comFoco = false;
        atualizar();
      });
    }

    /* Aba em segundo plano: o setTimeout continuaria correndo (o navegador
       só o afrouxa para no mínimo 1s) e a pessoa voltaria três ou quatro
       etapas adiante, sem ter visto nenhuma. Parar e retomar do ponto exato
       é o que faz a volta parecer que nada aconteceu. */
    document.addEventListener('visibilitychange', function () {
      oculto = !!document.hidden;
      atualizar();
    });

    var io = new IntersectionObserver(function (entradas) {
      for (var k = 0; k < entradas.length; k++) naTela = entradas[k].isIntersecting;
      atualizar();
    }, { threshold: .18 });
    io.observe(raiz);

    /* Primeiro estado do relógio e do botão. Sai daqui já certo: se a dobra
       nasceu fora da tela, o IntersectionObserver dispara em seguida e o
       atualizar() dele escreve o mesmo. */
    atualizar();
  }

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

A dobra "como funciona" que anda sozinha no tempo. À esquerda, a lista de etapas: número, título e a descrição, que só fica aberta na etapa ativa; embaixo de cada uma, um filete que enche no tempo da etapa e diz quanto falta. À direita, o painel que troca junto — foto ou peça de interface —, em fusão com desfoque. Acima, três discos de reprodução: anterior, pausar, próxima. A cada seis segundos a dobra passa para a etapa seguinte, e quando alguém escolhe uma etapa o rodízio desliga em definitivo — quem escolheu não quer ser trocado.

Resolve o processo que precisa ser visto acontecer. Uma lista de quatro etapas empilhadas é honesta e não chama ninguém; um processo que se apresenta sozinho, com foto trocando e um filete contando o tempo, é lido por quem só ia passar o olho. A troca no tempo, e não na rolagem, é o que separa esta peça das duas irmãs: aqui a dobra se apresenta mesmo para quem parou de rolar, e quem quiser dirigir assume o controle a qualquer momento.


Compliance


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Etapas Quatro é o ponto de equilíbrio. Três é o piso (com duas a lista vira seletor); cinco é o teto. Acima disso a coluna passa a altura do palco.
Olho 1 a 3 palavras.
Título da seção 3 a 8 palavras, com uma expressão em <em>. Diga o recorte do processo — "como funciona" o olho já disse.
Apoio 60 a 180 caracteres. É o lugar de avisar que a dobra anda sozinha ("pare quando quiser ler com calma"): quem chega no meio de uma troca precisa saber que pode parar.
Número da etapa Dois dígitos (01, 02). É decoração e vai em aria-hidden — ver o gotcha.
Título da etapa 1 a 4 palavras, substantivo, não frase. É o nome acessível da aba: "Política de investimentos" ganha de "Escrevemos a sua política". Até ~26 caracteres, senão quebra em duas linhas em 390px.
Descrição da etapa 30 a 60 palavras, e de tamanho comparável entre as etapas (±10 palavras). Descreve o processo, nunca o resultado.
Legenda do painel 30 a 90 caracteres, uma linha em desktop. Vai dentro da imagem, ancorada embaixo. Acima de 90 caracteres o véu cresce e come a foto.
Ressalva 60 a 260 caracteres, na primitiva .nota. Obrigatória enquanto os painéis forem ilustrativos.

O que mantém a dobra coesa é o paralelismo das descrições. A coluna da esquerda muda de altura a cada troca — a etapa que abre empurra as de baixo. Com quatro descrições do mesmo tamanho o movimento é um respiro; com uma de três linhas ao lado de uma de sete, a lista inteira pula a cada seis segundos e o olho perde o lugar. Medido no padrão: descrição aberta de 103,9px em 1440 e 146,0px em 390, com a lista fechando em 360px contra os 361,9px do palco — as duas colunas terminam praticamente na mesma linha, e é isso que se está protegendo.

Os títulos das etapas devem ter a mesma forma gramatical: quatro substantivos, ou quatro verbos. Misturar "Diagnóstico" com "Implementamos a carteira" desmancha a régua.

Especificação dos assets


Como usar

<link rel="stylesheet" href="componentes/listas/etapas-em-abas/estilo.css">
<script src="componentes/listas/etapas-em-abas/script.js"></script>

Cole o markup.html como uma seção do miolo, troque as fotos, os alt, as legendas e o texto das etapas. Precisa do core/tokens.css e do core/base.css. O core/motion.js é quase obrigatório: é dele que vêm o LIB.reduce e o LIB.pausavel, que é quem mostra e comanda o botão de pausa — sem ele o script cai no matchMedia e o botão fica escondido como nasceu, com a dobra inteira funcionando no resto.

Para acrescentar ou tirar uma etapa, mexa sempre nos dois juntos e na mesma ordem: o <button role="tab"> na lista e a <figure role="tabpanel"> no palco. O script casa os dois por índice e ignora a sobra.

Ganchos

Gancho Papel
[data-eta] Raiz. O script parte daqui, nunca do documento — a dobra pode se repetir na página.
[data-eta-lista] O role="tablist". É ele que pausa no ponteiro e quem recebe as setas do teclado.
[data-eta-grade] A grade de duas colunas. O foco dentro dela pausa; foco nos discos, não — ver o gotcha do focusin.
[data-eta-aba] Cada <button role="tab">.
[data-eta-rot] O título da etapa. É de onde sai o texto da região viva.
[data-eta-painel] Cada <figure role="tabpanel">.
[data-eta-ant] / [data-eta-prox] Os discos de anterior e próxima.
[data-eta-toggle] O botão de pausa. Veste .btn-disc .btn-play do core, nasce [hidden], e quem o mostra é o LIB.pausavel.
[data-eta-vivo] A região aria-live="polite" em .visually-hidden.
.is-live Na raiz, escrito pelo script: fecha as descrições e faz os três discos existirem.
.is-paused Na raiz: relógio parado. Congela o filete de progresso.
.is-on No painel ativo.

Parafusos

Variável Padrão O que faz
--eta-dur 6s O relógio da dobra. O filete e o setTimeout leem este mesmo valor — o script o pega por getComputedStyle na raiz. Nunca há dois números para manter em sincronia.
--eta-swap .7s Fusão entre um painel e o seguinte.
--eta-open .44s Abertura e fecho da descrição. Mais curta que a fusão de propósito.
--eta-blur 14px Desfoque da fusão do painel. 8px abaixo de 720px.
--eta-rot clamp(17px,1.5vw,21px) Corpo do título de cada etapa.
--eta-palco 16 / 11 Proporção do palco. 16 / 10 abaixo de 940px.
--nota-max 78ch Parafuso da primitiva .nota, escrito na .eta-nota.

Variações


Gotchas


Movimento reduzido / toque

Movimento reduzido desliga o que anda sozinho e não desliga o que a pessoa pede. Medido com prefers-reduced-motion: reduce: o relógio não liga e nada troca em 9 segundos; o LIB.pausavel não é chamado e o botão de pausa fica [hidden] (display:none), porque não há troca automática para pausar; o filete tem animation-name:none e fica em scaleX(0) — ele some, em vez de ficar cheio fingindo que contou; a abertura da descrição perde a transição e acontece na hora. As abas, as setas do teclado e os discos de anterior/próxima continuam funcionando: medido, clique na aba 3 abriu a descrição e trocou o painel, e o disco de próxima levou à aba 4.

O filete aqui some, e não fica cheio como o do hero/slideshow-cinematico. Lá ele é a régua de uma barra de abas e um trecho cheio lê como "você está aqui"; aqui ele é a única peça que se move, e um filete cheio parado embaixo da etapa ativa leria como "esta etapa terminou".

Toque. Não há nada que dependa de hover: o realce de passagem da aba é desligado em @media(hover:none) e a tinta de repouso já é --fg-2, que fecha contraste sozinha. Os três discos são alvos de 42px, a aba inteira é clicável (número, título e descrição), e a pausa por ponteiro simplesmente não existe no toque — o que sobra é o botão de pausa, que é justamente por isso que a WCAG 2.2.2 o exige. Abaixo de 940px o palco sobe para cima da lista: a foto trocando precisa estar na mesma tela de quem está lendo as etapas, senão trocar é o mesmo que não trocar.


Histórico

Projeto Onde Data Diferenças
referência: skinboost-design-review.vercel.app dobra #como-funciona 2026-09-21 reescrito do zero; ver abaixo

Origem. Clone de referência (contrato §11): o site de origem foi lido com o olho, não com o inspetor, e nenhuma linha atravessou. O registro consolidado vai em projetos/referencias-externas.md.

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

Normalizações aplicadas na extração: prefixo eta- em toda classe; cores cruas trocadas por token de escopo; ganchos data-eta-* em lugar de seletor de classe no script; data-rv movido para invólucros; legenda posta dentro da imagem com véu mascarado; .nota e .btn-play do core adotadas em lugar de receita local.

Divergências conhecidas vs. a referência: o original tem cinco etapas e uma animação de entrada por ScrollTrigger; aqui são quatro e a entrada é o data-rv do core. O painel do original é um render 3D em three.js — aqui é foto, e a ficha diz qual seria o asset real (foto editorial ou peça de interface, 16/11, até 160 KB).