← Biblioteca componentes/midia/roda-de-cards/ficha.md CódigoTela cheia
midia/roda-de-cards

Roda de cards

🟢 estavel complexidade alta prefixo wheel- linha-do-tempo timeline carrossel arrasto drag inercia encaixe arco roda historia teclado sangria

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

<link rel="stylesheet" href="componentes/midia/roda-de-cards/estilo.css">
<script src="componentes/midia/roda-de-cards/script.js"></script>

markup.html · 209 linhas · 11,4 KB

<!-- ==========================================================================
     RODA DE CARDS — linha do tempo sobre o aro de uma roda-gigante
     Prefixo: wheel-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. A geometria é CSS; o script só mede o raio, o passo e o ângulo.

     A FORMA. O centro do círculo fica ABAIXO do palco — muito abaixo, a mais
     de mil pixels. O que aparece na tela é só a calota de cima do aro: o card
     do topo em pé e nítido, os vizinhos descendo pelas laterais inclinados
     como raios de roda, esmaecendo com a distância angular. Arrastar de lado
     gira a roda; ela encaixa no card mais próximo do topo.

     O CUBO DA RODA NÃO É UM ELEMENTO — é um ponto. Cada card é absoluto,
     ancorado nesse ponto (left:50%, top:calc(100% + --wheel-drop)) e empurrado
     até o aro pelo próprio transform: translate(-50%,-50%) põe o centro do
     card sobre o ponto, rotate() gira em torno dele e translateY(-R) o lança
     para fora. É isso que dá a inclinação tangente de graça: o card sai
     rodado no mesmo ângulo em que está no aro. Posicionar os cards no aro por
     left/top exigiria seno e cosseno no JS e ainda pediria um segundo
     transform só para inclinar.

     A LISTA É O QUADRO, NÃO O PIVÔ. O <ul> cobre o palco inteiro (inset:0),
     não gira e é o único ponto focável da dobra — por isso o anel de foco
     nasce retangular, alinhado ao palco, e não torto junto com a roda. Quem
     gira é cada card, somando --wheel-angle (escrito uma vez por quadro na
     lista) ao próprio --wheel-i * --wheel-step. Um pivô que girasse levaria o
     foco junto e o anel sairia inclinado e cortado pelo recorte do palco.

     A RODA TEM COMEÇO E FIM, de propósito. Não há laço: o primeiro e o último
     card param no topo, com borracha ao passar do limite. Linha do tempo com
     laço infinito mentiria — 2026 não vem antes de 2012.

     OS CARDS NÃO SÃO FOCÁVEIS, e isso é decisão. Oito cards focáveis dariam
     oito paradas de Tab dentro de uma caixa que corta e gira, e o navegador
     tentaria rolar o recorte atrás de cada um. Em vez disso há uma parada só
     — a lista — com ←, →, Home e End. Clicar num card lateral vai até ele;
     quem usa teclado chega ao mesmo lugar pelas setas.

     O PAINEL é o que muda: ano, título e uma linha de texto, com aria-live
     polite e aria-atomic. Ele só é reescrito quando o índice ativo troca, e
     nunca por quadro — senão o leitor de tela falaria sem parar durante o
     arrasto. O texto de cada parada mora no data-wheel-text do próprio card.

     A DOBRA ABRE NA PRIMEIRA PARADA, e por isso o quadro nasce com o lado
     esquerdo vazio: antes de 2012 não há card. É a leitura correta de uma
     linha do tempo — o passado não existe ainda, o futuro se estende para a
     direita. Uma página que prefira o quadro cheio dos dois lados na abertura
     põe data-wheel-start="3" (ou o índice do ano corrente) na seção.

     CONTEÚDO ILUSTRATIVO. A linha do tempo abaixo é fictícia e serve para
     dimensionar o layout. Nenhuma data, número ou marco corresponde a fato
     real de empresa alguma — ao usar, troque tudo.
     ========================================================================== -->

<section class="sec-dark sec-pad wheel" data-wheel>

  <div class="wrap wheel-head">
    <div class="wheel-head-left">
      <span class="eyebrow" data-rv>Linha do tempo</span>
      <h2 class="sec-title ttl">Catorze anos, <em>oito</em> decisões</h2>
    </div>

    <div class="wheel-nav" data-rv data-d="1">
      <!-- aria-disabled e não disabled: o botão desabilitado de verdade perde
           o foco para o <body> ao chegar na ponta, e quem navega por teclado
           é jogado para fora da dobra. Aqui ele continua focável e o script
           ignora o clique. -->
      <button class="btn-disc wheel-btn" type="button" data-wheel-prev
              aria-label="Ver o ano anterior" aria-disabled="true">
        <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 wheel-btn" type="button" data-wheel-next
              aria-label="Ver o próximo ano" aria-disabled="false">
        <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>
    </div>
  </div>

  <!-- O palco é IRMÃO do .wrap, filho direto da seção: já nasce com a largura
       da página, sem margem negativa e sem 100vw (contrato §4). Ele é quem
       corta o aro que continua para fora da tela. -->
  <div class="wheel-stage" data-rv>
    <ul class="wheel-list" role="list" tabindex="0" data-wheel-list
        aria-roledescription="carrossel"
        aria-label="Linha do tempo da assessoria — arraste para o lado ou use as setas do teclado">

      <li class="wheel-card" style="--wheel-i:0" aria-current="true"
          data-wheel-year="2012"
          data-wheel-title="Uma mesa e dois sócios"
          data-wheel-text="Dois assessores, uma sala emprestada e a decisão de não vender produto de banco.">
        <img class="wheel-img" src="../../../showroom/demo/ph-8.webp"
             alt="Mesa com café, caderno e óculos" decoding="async">
        <span class="wheel-top" aria-hidden="true"></span>
        <span class="wheel-stamp">2012</span>
        <span class="wheel-cap"><span class="wheel-label">Fundação</span></span>
      </li>

      <li class="wheel-card" style="--wheel-i:1"
          data-wheel-year="2014"
          data-wheel-title="Registro como consultoria de valores mobiliários"
          data-wheel-text="O modelo vira fee-based: taxa previamente acordada com o cliente, sem comissão de distribuidor.">
        <img class="wheel-img" src="../../../showroom/demo/ph-3.webp"
             alt="Duas pessoas conversando junto a uma janela" loading="lazy" decoding="async">
        <span class="wheel-top" aria-hidden="true"></span>
        <span class="wheel-stamp">2014</span>
        <span class="wheel-cap"><span class="wheel-label">Registro</span></span>
      </li>

      <li class="wheel-card" style="--wheel-i:2"
          data-wheel-year="2016"
          data-wheel-title="Primeira estrutura internacional para um cliente"
          data-wheel-text="Custódia no exterior, política de investimentos escrita e relatório consolidado nas duas moedas.">
        <img class="wheel-img" src="../../../showroom/demo/ph-6.webp"
             alt="Globo antigo sobre estante de biblioteca" loading="lazy" decoding="async">
        <span class="wheel-top" aria-hidden="true"></span>
        <span class="wheel-stamp">2016</span>
        <span class="wheel-cap"><span class="wheel-label">Exterior</span></span>
      </li>

      <li class="wheel-card" style="--wheel-i:3"
          data-wheel-year="2018"
          data-wheel-title="Escritório em Porto Alegre"
          data-wheel-text="A segunda casa da assessoria. O time dobra e o comitê passa a se reunir toda semana.">
        <img class="wheel-img" src="../../../showroom/demo/ph-7.webp"
             alt="Reunião de trabalho em sala de vidro" loading="lazy" decoding="async">
        <span class="wheel-top" aria-hidden="true"></span>
        <span class="wheel-stamp">2018</span>
        <span class="wheel-cap"><span class="wheel-label">Porto Alegre</span></span>
      </li>

      <li class="wheel-card" style="--wheel-i:4"
          data-wheel-year="2020"
          data-wheel-title="Comitê de investimentos aberto aos clientes"
          data-wheel-text="A reunião que decide a alocação passa a ser transmitida para quem quiser assistir.">
        <img class="wheel-img" src="../../../showroom/demo/ph-4.webp"
             alt="Três pessoas reunidas em escritório com vista para a cidade" loading="lazy" decoding="async">
        <span class="wheel-top" aria-hidden="true"></span>
        <span class="wheel-stamp">2020</span>
        <span class="wheel-cap"><span class="wheel-label">Comitê</span></span>
      </li>

      <li class="wheel-card" style="--wheel-i:5"
          data-wheel-year="2022"
          data-wheel-title="Portal do cliente com posição consolidada"
          data-wheel-text="Custódia, previdência e exterior na mesma tela, com relatório mensal a um clique.">
        <img class="wheel-img" src="../../../showroom/demo/ph-1.webp"
             alt="Casal em varanda com vista para um vilarejo" loading="lazy" decoding="async">
        <span class="wheel-top" aria-hidden="true"></span>
        <span class="wheel-stamp">2022</span>
        <span class="wheel-cap"><span class="wheel-label">Portal</span></span>
      </li>

      <li class="wheel-card" style="--wheel-i:6"
          data-wheel-year="2024"
          data-wheel-title="Primeira sucessão concluída em três gerações"
          data-wheel-text="Holding familiar, doação com reserva de usufruto e o plano seguindo de pé sem o fundador.">
        <img class="wheel-img" src="../../../showroom/demo/ph-5.webp"
             alt="Homem mais velho e criança lendo juntos" loading="lazy" decoding="async">
        <span class="wheel-top" aria-hidden="true"></span>
        <span class="wheel-stamp">2024</span>
        <span class="wheel-cap"><span class="wheel-label">Sucessão</span></span>
      </li>

      <li class="wheel-card" style="--wheel-i:7"
          data-wheel-year="2026"
          data-wheel-title="Assessoria para famílias em todo o país"
          data-wheel-text="Famílias e empresários em todo o país, com o mesmo comitê e a mesma taxa acordada desde o começo.">
        <img class="wheel-img" src="../../../showroom/demo/ph-2.webp"
             alt="Família caminhando por uma alameda arborizada" loading="lazy" decoding="async">
        <span class="wheel-top" aria-hidden="true"></span>
        <span class="wheel-stamp">2026</span>
        <span class="wheel-cap"><span class="wheel-label">Hoje</span></span>
      </li>

    </ul>
  </div>

  <div class="wrap">
    <div class="wheel-foot" data-rv data-d="1">
      <!-- aria-atomic: sem ele o leitor de tela anuncia só o pedaço que
           mudou, e "2014" solto não diz nada. Com ele, ano, título e linha
           saem como uma frase só. -->
      <div class="wheel-panel" aria-live="polite" aria-atomic="true" data-wheel-panel>
        <p class="wheel-year" data-wheel-out="year">2012</p>
        <h3 class="wheel-title" data-wheel-out="title">Uma mesa e dois sócios</h3>
        <p class="wheel-text" data-wheel-out="text">Dois assessores, uma sala emprestada e a decisão de não vender produto de banco.</p>
      </div>

      <!-- Marcas, não botões: a navegação já existe em três lugares (arrasto,
           setas do teclado, os dois botões do cabeçalho) e oito paradas de Tab
           a mais só atrapalhariam. Puramente gráfico, logo aria-hidden. -->
      <div class="wheel-dots" aria-hidden="true" data-wheel-dots>
        <span class="wheel-dot is-on"></span>
        <span class="wheel-dot"></span>
        <span class="wheel-dot"></span>
        <span class="wheel-dot"></span>
        <span class="wheel-dot"></span>
        <span class="wheel-dot"></span>
        <span class="wheel-dot"></span>
        <span class="wheel-dot"></span>
      </div>
    </div>
  </div>

</section>

estilo.css · 342 linhas · 14,8 KB

/* ==========================================================================
   RODA DE CARDS — prefixo wheel-
   Linha do tempo sobre o aro de uma roda-gigante cujo centro está abaixo da
   dobra. Vê-se só a calota de cima: card do topo em pé, vizinhos descendo
   pelas laterais inclinados como raios.

   Tokens usados:
     --display --sans     ano e rótulo (display), painel de texto (sans)
     --ease               curva única do projeto — troca do painel e hover
     --accent             ano do painel, ponto ativo, fio do card ativo
     --dark-rgb           véus de topo e de base sobre a foto
     --light              texto DENTRO da foto (ilha escura, ver abaixo)
     --fg --fg-2          título e linha do painel, herdados do escopo de tom
     --fg-muted --rule --rule-2   botões, pontos apagados, fundo de carregamento
     --radius-media       canto do card
     --radius-pill        disco dos botões e ponto do indicador
     --sec-gap            respiro vertical da dobra (via .sec-pad)

   A GEOMETRIA MORA AQUI, NÃO NO SCRIPT. O cubo da roda é um PONTO, não um
   elemento: fica em (50%, 100% + --wheel-drop) do palco. Cada card é
   absoluto nesse ponto e vai até o aro pelo próprio transform —
   translate(-50%,-50%) põe o centro do card sobre o cubo, rotate() gira em
   volta dele, translateY(-R) lança para fora. O transform-origin fica no
   padrão (centro do card), e é por isso que a ordem das funções importa:
   o translate tem de vir PRIMEIRO, senão o giro acontece em torno de um
   ponto que ainda não é o cubo.

   Efeito colateral bem-vindo: o card sai inclinado no mesmo ângulo em que
   está no aro — tangente ao arco, de graça. Posicionar os cards no aro por
   left/top exigiria seno e cosseno no JS e ainda pediria um segundo
   transform só para inclinar.

   O que o script escreve é só --wheel-radius e --wheel-step (medidos) e
   --wheel-angle (por quadro). Sem JavaScript o arco continua desenhado,
   com os valores padrão abaixo — os cards não empilham num ponto só.

   O CARD É UMA ILHA ESCURA: ano e rótulo vivem dentro da foto e leem
   --light, não --fg. Assim a mesma roda roda numa seção clara sem virar
   texto escuro sobre véu escuro. O que está fora da foto (painel, botões,
   pontos) continua lendo o escopo de tom da seção.
   ========================================================================== */

.wheel{
  /* ---- os cinco parafusos da forma ---- */

  /* Largura do card. A altura é derivada e não é aspect-ratio de propósito:
     a conta do cubo precisa da altura em px, e aspect-ratio não é legível
     dentro de calc(). Quem quiser outro recorte troca as duas. */
  --wheel-card:clamp(200px,20vw,300px);
  --wheel-card-h:calc(var(--wheel-card) * 4 / 3);

  /* Altura do palco. Não é a altura da roda — é quanto da descida do aro
     aparece. Subir este valor mostra mais dos cards laterais mergulhando. */
  --wheel-h:clamp(380px,52vh,560px);

  /* Quanto o card do topo fica abaixo da borda do palco. Sem isto ele
     encosta em 0 e o anel de foco da lista passa por cima dele. */
  --wheel-rise:16px;

  /* Vão entre um card e o próximo, medido no aro, como FRAÇÃO da largura do
     card (.1 = 10%). Unitless de propósito: o script lê com parseFloat, e um
     clamp() devolveria só o primeiro número. Como fração, escala junto com o
     card e dispensa override por breakpoint. */
  --wheel-gap:.1;

  /* Raio da roda, em px, ESCRITO PELO SCRIPT a cada medida:
       R = max(largura da seção * --wheel-reach, --wheel-radius-min)
     Os dois parafusos abaixo são números puros (px implícito no segundo),
     porque quem os lê é o parseFloat do script. Raio maior = arco mais raso
     e cards mais em pé; raio menor = roda mais fechada e mais inclinação. */
  --wheel-reach:.9;
  --wheel-radius-min:900;
  --wheel-radius:1100px;      /* fallback sem JS */

  /* Passo angular entre dois cards, ESCRITO PELO SCRIPT:
       passo = 2 * asin((cardW + vão) / (2R))
     Não é 360/N. São oito cards e a roda NÃO fecha: eles ocupam um trecho do
     aro (~98° no padrão de desktop) e o encaixe tem começo e fim. Usar 360/N
     com N=8 daria 45° por card e os vizinhos sairiam da tela. */
  --wheel-step:14deg;

  /* Giro atual da roda. O script escreve na .wheel-list; aqui fica só o
     valor de repouso, que é o primeiro card no topo. */
  --wheel-angle:0deg;

  /* Onde fica o cubo, contado a partir do TOPO do palco. A conta é a
     inversa da posição do card do topo: para o centro do primeiro card cair
     em (altura do card / 2 + --wheel-rise), o cubo tem de estar a
     R - H + h/2 + rise abaixo da base do palco. */
  --wheel-drop:calc(var(--wheel-radius) - var(--wheel-h)
                    + var(--wheel-card-h) / 2 + var(--wheel-rise));

  /* Esmaecimento por distância angular. O script escreve --wheel-k em cada
     card (0 no topo, 1 a um passo de distância) já limitado a 1.45 — sem o
     teto, o card a três passos daria opacity negativa e o encolhimento
     comeria metade da caixa. */
  --wheel-shrink:.05;

  /* Altura reservada para o painel. Existe para os pontos não dançarem
     quando o texto troca de duas linhas para uma. Medido no conteúdo mais
     alto do padrão editorial — 176px em 1440 e 177px em 390, com folga para
     as larguras entre os dois. Subir o limite de caracteres pede subir isto. */
  --wheel-panel-h:186px;
}

/* ---------------------------------------------------------------- cabeçalho */
/* Título à esquerda, botões à direita, alinhados pela base. Não usa
   .sec-head do core de propósito: aquele é cabeçalho CENTRADO, e aqui os
   dois lados precisam se olhar de ponta a ponta da coluna. */
.wheel-head{
  display:flex;align-items:flex-end;justify-content:space-between;
  gap:clamp(18px,4vw,48px);
}
.wheel-head-left{min-width:0}

.wheel-nav{display:flex;align-items:center;gap:12px;flex:0 0 auto;padding-bottom:6px}

/* 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.
   A ponta da linha do tempo usa aria-disabled e não o atributo disabled: o
   botão desabilitado de verdade sai da ordem de tabulação no exato momento
   em que está com o foco, e o foco cai no <body> — quem chegou de teclado é
   jogado para fora da dobra. Esta dobra foi a primeira a precisar disso, e a
   variante foi junto para a primitiva, que atende as duas formas. */
/* ---------------------------------------------------------------- palco */
/* Irmão do .wrap, filho direto da seção: já nasce com a largura da página,
   sem margem negativa e sem 100vw (contrato §4). O overflow é o que corta o
   aro, que continua muito além dos dois lados da tela. */
.wheel-stage{
  position:relative;
  height:var(--wheel-h);
  overflow:hidden;
  margin-block-start:clamp(30px,5vh,52px);
}

/* A LISTA É O QUADRO, NÃO O PIVÔ. Cobre o palco inteiro, não gira, e é o
   único ponto focável da dobra. Um pivô que girasse (um "cubo" elemento com
   rotate) levaria o foco junto: o anel sairia inclinado e, no fim da linha
   do tempo, girado quase 100° para fora do recorte. */
.wheel-list{
  position:absolute;inset:0;
  cursor:grab;
  /* O gesto horizontal é nosso; o vertical continua sendo da página. Sem
     isto, arrastar de lado no celular trava a rolagem. */
  touch-action:pan-y;
  /* Arrastar sobre o ano e o rótulo selecionaria os dois. */
  user-select:none;-webkit-user-select:none;
}
.wheel-list.is-dragging{cursor:grabbing}

/* O anel do core desenha 3px PARA FORA, e o palco corta com overflow:hidden.
   Offset negativo joga o anel para dentro — mesma solução do
   midia/carrossel-perfis. Aumentar o padding do palco não resolveria: o
   palco sangra até a borda da página, e o anel cairia sob o overflow-x do
   <body>. */
.wheel-list:focus-visible{outline-offset:-4px}

/* ---------------------------------------------------------------- card */
/* Ancorado no cubo; quem o leva até o aro é o transform. Ver o cabeçalho. */
.wheel-card{
  --wheel-k:0;
  position:absolute;
  left:50%;
  top:calc(100% + var(--wheel-drop));
  width:var(--wheel-card);
  height:var(--wheel-card-h);
  overflow:hidden;
  border-radius:var(--radius-media);
  background:var(--rule);
  transform:
    translate(-50%,-50%)
    rotate(calc(var(--wheel-angle) + var(--wheel-i) * var(--wheel-step)))
    translateY(calc(-1 * var(--wheel-radius)))
    scale(calc(1 - var(--wheel-k) * var(--wheel-shrink)));
  opacity:calc(1 - var(--wheel-k) * .55);
}

/* will-change SÓ ENQUANTO GIRA, e a diferença é visível. Promovido para
   sempre, cada card vira uma textura rasterizada uma vez e depois só
   transformada pelo compositor: parado, o ano e o rótulo ficam com a borda
   levemente mole. Ligado só durante o gesto, o texto volta a ser rasterizado
   na posição final assim que a roda encaixa, e aí o card do topo — que
   encaixa em rotação exatamente zero — sai nítido. */
.wheel-list.is-spinning .wheel-card{will-change:transform}

/* Fio na cor de marca marcando de quem o painel está falando. Sombra interna
   e não border: border entraria na caixa de 3/4 e comeria 2px da foto. */
.wheel-card[aria-current]{
  box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent) 58%,transparent);
}

.wheel-img{
  position:absolute;inset:0;
  width:100%;height:100%;object-fit:cover;
}

/* Véu de topo: gradiente puro, SEM backdrop-filter. São oito cards girando
   ao mesmo tempo, e cada backdrop-filter obriga o compositor a reler o que
   está atrás a cada quadro. O de baixo pode pagar esse preço porque é onde
   mora o texto corrido; o de cima carrega quatro dígitos em corpo grande e
   contraste alto, e gradiente já basta. */
.wheel-top{
  position:absolute;left:0;right:0;top:0;height:40%;
  z-index:1;pointer-events:none;
  background:linear-gradient(to bottom,
    rgba(var(--dark-rgb),.74) 0%,
    rgba(var(--dark-rgb),.30) 54%,
    transparent 100%);
}

.wheel-stamp{
  position:absolute;top:clamp(12px,1.3vw,18px);left:clamp(12px,1.3vw,18px);
  z-index:2;
  font-family:var(--display);font-weight:700;
  font-size:clamp(20px,1.9vw,28px);line-height:1;letter-spacing:-.02em;
  color:var(--light);
}

/* Rótulo DENTRO da foto, ancorado embaixo, sobre gradiente + blur mascarado
   (contrato §6 — texto sobre imagem). A máscara é o que impede a borda dura
   do backdrop-filter em caixa reta. */
.wheel-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  display:block;
  padding:44px clamp(12px,1.3vw,18px) clamp(12px,1.3vw,18px);
  pointer-events:none;
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.90) 0%,
    rgba(var(--dark-rgb),.50) 48%,
    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%);
}

.wheel-label{
  display:block;
  font-family:var(--display);font-weight:700;
  font-size:clamp(11px,1vw,13px);line-height:1.2;
  letter-spacing:.14em;text-transform:uppercase;
  color:var(--light);
}

/* ---------------------------------------------------------------- painel */
.wheel-foot{
  display:flex;flex-direction:column;align-items:center;
  gap:clamp(18px,2.6vh,26px);
  margin-block-start:clamp(28px,4.5vh,48px);
}

/* min-height reserva o conteúdo mais alto do padrão editorial. Sem ela, uma
   parada de duas linhas seguida de outra de uma faz os pontos subirem e
   descerem a cada troca — e o olho lê isso como defeito, não como conteúdo. */
.wheel-panel{
  max-width:600px;
  min-height:var(--wheel-panel-h);
  text-align:center;
}

/* A animação reinicia por classe, e não por transition, para poder disparar
   sem depender do estado anterior — mesma razão do .ttl do core. Entra com
   blur indo a zero (§5); o repouso fica SEM filter, para não custar quadro. */
.wheel-panel.is-swap{animation:wheelSwap .5s var(--ease)}
@keyframes wheelSwap{
  0%{opacity:.15;filter:blur(10px);transform:translateY(9px)}
  100%{opacity:1;filter:blur(0);transform:none}
}
@media(prefers-reduced-motion:reduce){
  .wheel-panel.is-swap{animation:none}
}

/* O ano é o dado, não o título da dobra — por isso não é a primitiva
   .display-xl do core: aquela é a peça gráfica que enche a coluna e pinta
   --fg. Este é um número em corpo grande, na cor de marca, dentro de um
   painel de 600px. */
.wheel-year{
  font-family:var(--display);font-weight:700;
  font-size:clamp(40px,5.2vw,72px);line-height:1;letter-spacing:-.03em;
  color:var(--accent);
}
.wheel-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(19px,1.8vw,24px);line-height:1.25;letter-spacing:-.02em;
  color:var(--fg);
  margin-block-start:12px;
}
.wheel-text{
  font-size:clamp(14px,1.15vw,16px);line-height:1.55;
  color:var(--fg-2);
  max-width:56ch;margin-inline:auto;margin-block-start:12px;
}

/* ---------------------------------------------------------------- pontos */
.wheel-dots{display:flex;align-items:center;gap:8px}
.wheel-dot{
  width:6px;height:6px;
  border-radius:var(--radius-pill);
  background:var(--rule-2);
  transition:width .4s var(--ease),background-color .4s var(--ease);
}
.wheel-dot.is-on{width:22px;background:var(--accent)}
@media(prefers-reduced-motion:reduce){
  .wheel-dot{transition:none}
}

/* ---------------------------------------------------------------- toque */
/* Sem ponteiro não existe hover, e o cursor de arrasto não quer dizer nada. */
@media(hover:none){
  .wheel-list{cursor:default}
}

/* ---------------------------------------------------------------- mobile */
@media(max-width:760px){
  .wheel{
    /* Card menor e arco MAIS RASO ao mesmo tempo: --wheel-reach sobe de .9
       para 1.4 porque a largura da tela caiu muito mais que a do card. Com
       .9 num celular o raio ficaria em ~350px, o passo passaria de 40° e os
       vizinhos entrariam quase deitados. */
    --wheel-card:min(58vw,240px);
    /* Palco mais baixo que a proporção do desktop, e não por espaço de tela:
       no celular só o card do topo e duas fatias dos vizinhos aparecem, e os
       vizinhos param de descer aos 373px. Os 440px do desktop deixariam 67px
       de palco vazio embaixo, que o olho lê como buraco entre a roda e o
       painel. */
    --wheel-h:min(54vh,392px);
    --wheel-reach:1.4;
    --wheel-radius-min:420;
  }
  /* Empilha o cabeçalho. Os botões ficam sob o título, e não abaixo do
     painel: movê-los de container entre larguras exigiria dois conjuntos no
     DOM — dois pares de controles anunciados pelo leitor de tela — ou um
     grid que quebraria a conta do .wrap. */
  .wheel-head{flex-direction:column;align-items:flex-start;gap:18px}
  .wheel-nav{padding-bottom:0}
}

@media(max-width:460px){
  .wheel-cap{padding-top:36px}
  .wheel-text{font-size:14px}
}

script.js · 434 linhas · 18,2 KB

/* ==========================================================================
   RODA DE CARDS — medida da roda, arrasto com inércia e encaixe.

   A geometria mora no CSS (ver estilo.css). Este script faz cinco coisas:

     1. MEDE a roda — raio a partir da largura da seção, passo angular a
        partir da largura do card, e escreve os dois em --wheel-radius e
        --wheel-step. Sem ele o arco ainda existe, nos valores padrão do CSS;
        com ele, o arco cabe na tela em que está;
     2. GIRA — escreve --wheel-angle uma vez por quadro na lista, e --wheel-k
        (distância angular ao topo) em cada card, só quando muda;
     3. ARRASTA — dx em pixels vira ângulo pela tangente (dθ = dx / R), com
        inércia ao soltar e borracha nas duas pontas;
     4. ENCAIXA — parada a inércia, aproxima exponencialmente do card mais
        próximo do topo. A roda NÃO dá volta: há um primeiro e um último;
     5. TROCA O PAINEL — ano, título e linha, com aria-live, só quando o
        índice ativo muda de verdade.

   POR QUE dθ = dx / R. O card do topo está em (0, -R) em torno do cubo.
   Girando a roda de A, ele vai para (R·senA, -R·cosA), logo dx/dA = R·cosA,
   que perto do topo é R. Dividir o arrasto pelo raio faz o card do topo
   acompanhar o dedo pixel a pixel — que é a única leitura possível quando o
   gesto é horizontal e o movimento é circular.

   INÉRCIA E ENCAIXE SÃO POR QUADRO, não por tempo, e é decisão. Nada aqui
   anda sozinho: os dois só existem depois de um gesto, e duram menos de meio
   segundo. Numa tela de 120Hz o repouso chega em ~28 quadros em vez de ~55,
   ou seja, 230ms em vez de 460ms — diferença que ninguém percebe num gesto
   que a pessoa acabou de fazer. É a mesma convenção do midia/carrossel-
   perfis. O que ali é por TEMPO é a esteira que anda sozinha; aqui não há
   nenhuma.

   Um rAF só por instância, e ele dorme: fora da tela (IntersectionObserver),
   ou quando não há arrasto, inércia nem folga até o encaixe. Nenhum listener
   de scroll — a roda não é presa à rolagem da página.

   MOVIMENTO REDUZIDO: sem inércia e sem aproximação. O arrasto continua
   funcionando e, ao soltar, a roda vai direto ao índice mais próximo; seta e
   botão trocam seco. Gesto pedido pela pessoa não é movimento imposto a ela.
   ========================================================================== */

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

  var GRAU = 180 / Math.PI;
  var FREIO = .93;        /* decaimento da inércia, por quadro */
  var ENCAIXE = .14;      /* fatia do que falta consumida por quadro */
  var LIMIAR = 6;         /* px que separam um clique de um arrasto */
  var K_MAX = 1.45;       /* teto do esmaecimento — ver o comentário em pintar() */
  var BORRACHA = 2;       /* excesso máximo nas pontas, em passos */

  /* 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;
  }

  function iniciar(raiz) {
    var lista = raiz.querySelector('[data-wheel-list]');
    if (!lista) return;

    var cards = [], filhos = lista.children, c;
    for (c = 0; c < filhos.length; c++) cards.push(filhos[c]);
    if (!cards.length) return;

    var painel   = raiz.querySelector('[data-wheel-panel]');
    var saidaAno = raiz.querySelector('[data-wheel-out="year"]');
    var saidaTit = raiz.querySelector('[data-wheel-out="title"]');
    var saidaTxt = raiz.querySelector('[data-wheel-out="text"]');
    var btnAnt   = raiz.querySelector('[data-wheel-prev]');
    var btnProx  = raiz.querySelector('[data-wheel-next]');
    var caixaPontos = raiz.querySelector('[data-wheel-dots]');

    var pontos = [], p;
    if (caixaPontos) for (p = 0; p < caixaPontos.children.length; p++) pontos.push(caixaPontos.children[p]);

    var reduz = reduzido();

    var R = 1100;        /* raio da roda, em px */
    var passo = 14;      /* passo angular entre dois cards, em graus */
    var MIN = 0, MAX = 0;

    var ang = 0;         /* giro atual, em graus */
    var angBruto = 0;    /* giro sem borracha, só durante o arrasto */
    var vel = 0;         /* inércia, em graus por quadro */
    var ativo = 0;       /* índice anunciado no painel */
    var destino = 0;     /* índice em que a roda quer encaixar */

    var kAnterior = [];
    for (c = 0; c < cards.length; c++) kAnterior.push(-1);

    var arrastando = false, ponteiro = -1, xAnterior = 0, dxUltimo = 0, andou = 0;
    var dentro = false, laco = 0, pedido = 0, reinicia;

    /* ------------------------------------------------------------ medida */

    /* A CORDA, NÃO A DIVISÃO DO CÍRCULO. Dois cards vizinhos precisam ficar a
       (largura + vão) um do outro NO ARO. Essa distância é a corda de um
       ângulo, e a corda de θ num círculo de raio R vale 2R·sen(θ/2) — daí
       θ = 2·asen(corda / 2R). Usar 360/N daria 45° com oito cards: os
       vizinhos sairiam da tela e o vão entre eles cresceria com o raio, em
       vez de ficar constante. O erro não é pequeno — no desktop padrão o
       passo certo é ~14° e 360/N erra por mais de três vezes. */
    function medir() {
      var estilo = getComputedStyle(raiz);
      var largura = raiz.getBoundingClientRect().width;

      var alcance = parseFloat(estilo.getPropertyValue('--wheel-reach'));
      var piso    = parseFloat(estilo.getPropertyValue('--wheel-radius-min'));
      var vao     = parseFloat(estilo.getPropertyValue('--wheel-gap'));
      if (isNaN(alcance)) alcance = .9;
      if (isNaN(piso))    piso = 0;
      if (isNaN(vao))     vao = .1;

      R = Math.max(largura * alcance, piso);
      if (!(R > 1)) R = 1100;

      /* offsetWidth e não getBoundingClientRect: o rect de um card já vem
         girado, e devolveria a caixa envolvente do retângulo inclinado —
         maior que o card, e maior ainda quanto mais inclinado ele está. */
      var cardW = cards[0].offsetWidth || 240;

      var seno = (cardW * (1 + vao)) / (2 * R);
      if (seno > .96) seno = .96;   /* card quase do tamanho do diâmetro */
      passo = 2 * Math.asin(seno) * GRAU;
      if (!(passo > .1)) passo = 14;

      MAX = 0;
      MIN = -(cards.length - 1) * passo;

      raiz.style.setProperty('--wheel-radius', Math.round(R) + 'px');
      raiz.style.setProperty('--wheel-step', passo.toFixed(4) + 'deg');

      /* O passo mudou, então o ângulo antigo aponta para outro card: reancora
         no índice que está anunciado, para o resize não trocar o assunto do
         painel no meio da leitura. */
      vel = 0;
      destino = ativo;
      ang = -ativo * passo;
      pintar(true);
    }

    /* ------------------------------------------------------------ borracha */

    /* Resistência que começa em 1:1 e satura. O excesso mostrado nunca passa
       de BORRACHA passos (2, ou ~28° no desktop padrão), por mais que se
       arraste: em e = teto a conta já devolve metade, e a partir daí o card
       praticamente para sob o dedo. É esse travamento progressivo que diz
       "acabou" sem precisar de mensagem. Ao soltar, o encaixe traz de volta
       ao limite — o alvo já é o índice das pontas, porque maisProximo()
       corta em 0 e N-1. */
    function amortece(e) {
      var teto = passo * BORRACHA;
      return teto * e / (e + teto);
    }
    function comBorracha(bruto) {
      if (bruto > MAX) return MAX + amortece(bruto - MAX);
      if (bruto < MIN) return MIN - amortece(MIN - bruto);
      return bruto;
    }

    function maisProximo() {
      var i = Math.round(-ang / passo);
      if (i < 0) i = 0;
      if (i > cards.length - 1) i = cards.length - 1;
      return i;
    }

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

    /* `exato` desliga o corte de escrita. Ele vale no quadro em que a roda
       assenta: sem isso o card do topo pararia no último valor escrito antes
       do corte — 0.99 de opacity em vez de 1 —, e o esmaecimento do vizinho
       ficaria congelado meio grau fora do lugar. É invisível, mas é o tipo de
       resto que aparece somado depois de vinte trocas. */
    function pintar(exato) {
      /* Três casas de grau. Num raio de 1283px, 0.001° move o card por
         0.022px — muito abaixo do subpixel. Arredondar não é o remédio para
         tremor de texto (o remédio está no will-change do estilo.css); é só
         para não reescrever uma string de dezessete dígitos por quadro. */
      lista.style.setProperty('--wheel-angle', ang.toFixed(3) + 'deg');

      var i, k;
      for (i = 0; i < cards.length; i++) {
        /* Distância angular ao topo, medida em PASSOS: 0 no card do topo, 1
           no vizinho. O teto existe porque o CSS faz 1 - k * .55 e, a três
           passos, isso daria opacity negativa e um encolhimento de 15%. */
        k = Math.abs(ang + i * passo) / passo;
        if (k > K_MAX) k = K_MAX;
        /* Escrever custa: cada setProperty num custom property invalida o
           estilo daquele card. Com o corte, um quadro parado escreve zero
           vezes e um quadro de arrasto escreve nos poucos cards que de fato
           mudaram de faixa. */
        if (exato || Math.abs(k - kAnterior[i]) > .008) {
          cards[i].style.setProperty('--wheel-k', k.toFixed(2));
          kAnterior[i] = k;
        }
      }

      if (destino !== ativo) trocar(destino);
    }

    /* ------------------------------------------------------------ painel */

    /* Escreve o estado do índice n em todo lugar que o mostra. Separado de
       trocar() porque a abertura em data-wheel-start precisa do conteúdo sem
       a animação de troca — e sem a animação, a região aria-live não é
       reescrita durante a leitura inicial da página. */
    function aplicar(n) {
      var card = cards[n];
      if (!card) return false;

      if (cards[ativo]) cards[ativo].removeAttribute('aria-current');
      card.setAttribute('aria-current', 'true');
      ativo = n;

      if (saidaAno) saidaAno.textContent = card.getAttribute('data-wheel-year')  || '';
      if (saidaTit) saidaTit.textContent = card.getAttribute('data-wheel-title') || '';
      if (saidaTxt) saidaTxt.textContent = card.getAttribute('data-wheel-text')  || '';

      for (var d = 0; d < pontos.length; d++) {
        if (d === n) pontos[d].classList.add('is-on');
        else pontos[d].classList.remove('is-on');
      }

      if (btnAnt)  btnAnt.setAttribute('aria-disabled',  ativo <= 0 ? 'true' : 'false');
      if (btnProx) btnProx.setAttribute('aria-disabled', ativo >= cards.length - 1 ? 'true' : 'false');
      return true;
    }

    function trocar(n) {
      if (!aplicar(n) || !painel) return;
      painel.classList.remove('is-swap');
      /* Leitura obrigatória. Sem forçar o reflow entre o remove e o add, o
         navegador junta os dois no mesmo quadro, conclui que a classe nunca
         saiu e a animação não reinicia. É o mesmo truque que o .ttl do core
         evita por usar animation em vez de transition — aqui a animação
         precisa tocar de novo no MESMO elemento. */
      reinicia = painel.offsetWidth;
      painel.classList.add('is-swap');
    }

    /* ------------------------------------------------------------ laço */

    function ocupado() {
      if (arrastando || vel) return true;
      return Math.abs(-destino * passo - ang) > .015;
    }

    function quadro() {
      var assentou = false;

      if (arrastando) {
        destino = maisProximo();
      } else if (vel) {
        ang += vel;
        vel *= FREIO;
        if (Math.abs(vel) < .05) vel = 0;
        /* Nas pontas a inércia PARA seca, sem borracha: a borracha é do
           gesto, e um arremesso que quica na ponta lê como defeito. */
        if (ang > MAX) { ang = MAX; vel = 0; }
        if (ang < MIN) { ang = MIN; vel = 0; }
        destino = maisProximo();
      } else {
        var alvo = -destino * passo;
        var falta = alvo - ang;
        if (reduz || Math.abs(falta) < .02) { ang = alvo; assentou = true; }
        else ang += falta * ENCAIXE;
      }

      pintar(assentou);

      if (dentro && ocupado()) {
        laco = requestAnimationFrame(quadro);
      } else {
        laco = 0;
        lista.classList.remove('is-spinning');
      }
    }

    function ligar() {
      if (laco || !dentro) return;
      /* will-change entra JUNTO com o movimento e sai com ele. Ver o
         comentário da regra no estilo.css: promovido para sempre, o texto
         dos cards fica mole mesmo com a roda parada. */
      lista.classList.add('is-spinning');
      laco = requestAnimationFrame(quadro);
    }

    function desligar() {
      if (!laco) return;
      cancelAnimationFrame(laco);
      laco = 0;
      lista.classList.remove('is-spinning');
    }

    function ir(n) {
      if (n < 0) n = 0;
      if (n > cards.length - 1) n = cards.length - 1;
      vel = 0;
      destino = n;
      ligar();
      /* Fora da tela o laço não roda; escreve uma vez para o estado não
         ficar para trás enquanto a dobra não volta. */
      if (!dentro) { ang = -n * passo; pintar(true); }
    }

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

    /* O navegador tem um arrasto PRÓPRIO para imagem: puxar uma 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 dragstart. É o irmão do user-select:none
       do CSS: dentro de uma roda arrastável, todo gesto de puxar é dela. */
    lista.addEventListener('dragstart', function (e) { e.preventDefault(); });

    lista.addEventListener('pointerdown', function (e) {
      if (typeof e.button === 'number' && e.button !== 0) return;
      arrastando = true;
      andou = 0;
      vel = 0;
      ponteiro = e.pointerId;
      xAnterior = e.clientX;
      dxUltimo = 0;
      angBruto = ang;
      lista.classList.add('is-dragging');
      /* Captura para o arrasto sobreviver ao ponteiro sair do palco — e ele
         sai fácil, porque o palco tem menos de meia tela de altura. */
      try { lista.setPointerCapture(ponteiro); } catch (err) {}
      ligar();
    });

    lista.addEventListener('pointermove', function (e) {
      if (!arrastando || e.pointerId !== ponteiro) return;
      var dx = e.clientX - xAnterior;
      xAnterior = e.clientX;
      dxUltimo = dx;
      andou += Math.abs(dx);
      /* O bruto acumula sem limite; quem corta é a borracha. Guardar os dois
         separados é o que permite voltar do excesso sem histerese: soltar e
         arrastar de novo continua exatamente de onde parou. */
      angBruto += dx / R * GRAU;
      ang = comBorracha(angBruto);
    });

    function soltar(e) {
      if (!arrastando) return;
      if (e && e.pointerId !== undefined && e.pointerId !== ponteiro) return;
      arrastando = false;
      lista.classList.remove('is-dragging');
      try { lista.releasePointerCapture(ponteiro); } catch (err) {}
      /* Sem arremesso com movimento reduzido, e sem arremesso saindo da
         borracha — ali o que a mão pede é voltar, não seguir. */
      vel = (reduz || ang > MAX || ang < MIN) ? 0 : (dxUltimo / R * GRAU) * .9;
      ligar();
    }

    lista.addEventListener('pointerup', soltar);
    lista.addEventListener('pointercancel', soltar);

    /* ------------------------------------------------------------ clique */

    function indiceDe(no) {
      for (var i = 0; i < cards.length; i++) if (cards[i] === no) return i;
      return -1;
    }

    /* 6px de tolerância: um clique sempre treme um pouco, e um arrasto de
       verdade passa longe disso. Sem o limiar, girar a roda faria a parada
       onde a mão soltou virar a parada escolhida. */
    lista.addEventListener('click', function (e) {
      if (andou > LIMIAR) return;
      var no = e.target, i;
      while (no && no !== lista) {
        i = indiceDe(no);
        if (i > -1) { ir(i); return; }
        no = no.parentNode;
      }
    });

    /* ------------------------------------------------------------ teclado */

    /* Parte de `destino`, não de `ativo`: duas setas em quadros seguidos
       avançam duas paradas. `ativo` só é atualizado quando o painel troca, e
       basear a conta nele engoliria a segunda tecla. */
    lista.addEventListener('keydown', function (e) {
      var n;
      if (e.key === 'ArrowRight') n = destino + 1;
      else if (e.key === 'ArrowLeft') n = destino - 1;
      else if (e.key === 'Home') n = 0;
      else if (e.key === 'End') n = cards.length - 1;
      else return;
      e.preventDefault();
      ir(n);
    });

    if (btnAnt)  btnAnt.addEventListener('click',  function () { ir(destino - 1); });
    if (btnProx) btnProx.addEventListener('click', function () { ir(destino + 1); });

    /* ------------------------------------------------------------ tela */

    addEventListener('resize', function () {
      if (pedido) return;
      /* Uma medida por quadro: medir() lê layout, e fazer isso a cada evento
         de resize trava a janela ao redimensionar. */
      pedido = requestAnimationFrame(function () { pedido = 0; medir(); });
    }, { passive: true });

    var io = new IntersectionObserver(function (entradas) {
      for (var i = 0; i < entradas.length; i++) {
        dentro = entradas[i].isIntersecting;
        if (dentro) ligar(); else desligar();
      }
    }, { threshold: 0 });

    /* Parada de abertura. O padrão é a primeira — linha do tempo abre no
       começo. data-wheel-start existe para a página que prefere abrir no
       meio (o quadro fica cheio dos dois lados) ou no ano corrente. Aplicado
       sem animação de troca, para a região aria-live não ser reescrita no
       meio da leitura inicial da página. */
    var inicio = parseInt(raiz.getAttribute('data-wheel-start'), 10);
    if (isNaN(inicio) || inicio < 0) inicio = 0;
    if (inicio > cards.length - 1) inicio = cards.length - 1;
    if (inicio !== ativo) aplicar(inicio);

    medir();
    io.observe(raiz);
  }

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

Uma roda-gigante de cards com o cubo enterrado abaixo da dobra. O que aparece na tela é só a calota de cima do aro: o card do topo em pé, centrado e nítido; os vizinhos descendo pelas laterais, inclinados como raios de roda porque são tangentes ao arco, e esmaecendo à medida que mergulham. Arrastar de lado gira a roda, com inércia ao soltar e borracha nas duas pontas; ao parar, ela encaixa no card mais próximo do topo. Abaixo, um painel troca com blur: o ano em corpo grande, o título da parada e uma linha de texto. O olho segue o card que sobe, lê o painel, e volta ao arco.

Resolve a página de história — "quem somos desde quando" — quando a lista de datas é longa demais para caber empilhada e cada marco tem uma foto que vale mostrar. Uma linha do tempo vertical resolve a ordem, mas gasta uma tela inteira e transforma catorze anos em rolagem. Uma grade de cards resolve a foto, mas apaga a sequência: numa grade, 2012 e 2024 são vizinhos de tabela, não de tempo. O arco devolve as duas coisas ao mesmo tempo — a ordem está no gesto (o passado sai pela esquerda, o futuro entra pela direita) e a foto está em cada card. E, ao contrário de um carrossel comum, a roda tem começo e fim: o primeiro e o último card param no topo, sem laço, porque 2026 não vem antes de 2012.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho Uma a três palavras. "Linha do tempo", "Trajetória", "Desde 2012".
Título (h2.sec-title.ttl) 4 a 7 palavras. Diz o arco inteiro, não a primeira parada: "Catorze anos, oito decisões". Evite "Nossa história", que não informa nada.
Ano (.wheel-stamp e .wheel-year) O mesmo texto nos dois lugares. Quatro dígitos, ou "2012–14" se a parada cobrir um período. Acima de sete caracteres o carimbo do card encosta na borda.
Rótulo do card (.wheel-label) Uma ou duas palavras, sempre substantivo. "Fundação", "Registro", "Porto Alegre". Ele é a etiqueta da parada, não o título — quem carrega a frase é o painel. Acima de 16 caracteres o rótulo quebra em duas linhas dentro do véu e o card fica com a base pesada.
Título do painel (.wheel-title) 22 a 48 caracteres, uma linha em desktop. É a frase do marco: "Registro como consultoria de valores mobiliários".
Linha do painel (.wheel-text) 80 a 100 caracteres, duas linhas em desktop. Diz o que mudou na prática, não o que a empresa sentiu.
Quantidade 6 a 10 paradas, uma marca em .wheel-dots para cada.

O que mantém a roda coesa é o paralelismo dos rótulos: se um é substantivo simples ("Registro"), todos são. Um "Abrimos o escritório de Porto Alegre" no meio de oito substantivos quebra a régua, porque os rótulos aparecem lado a lado no arco, três de uma vez, e a diferença de comprimento é lida como desalinhamento.

A segunda regra é de comprimento comparável no painel: a altura reservada (--wheel-panel-h) é fixa para os pontos não dançarem a cada troca, então uma parada de uma linha ao lado de outra de três deixa um buraco visível. Escreva as oito no mesmo tamanho.

Sobre a abertura assimétrica. A dobra nasce com o primeiro ano no topo e o lado esquerdo vazio — antes da primeira parada não há card. Isso é a leitura correta de uma linha do tempo, e a diagonal que desce à direita compõe com o título no alto à esquerda. Se a página precisar do quadro cheio dos dois lados na abertura, data-wheel-start="3" resolve, ao custo de a leitura não começar no começo.

Especificação dos assets


Como usar

<link rel="stylesheet" href="componentes/midia/roda-de-cards/estilo.css">
<script src="componentes/midia/roda-de-cards/script.js"></script>

O palco é irmão do .wrap, filho direto da <section> — é assim que ele sangra até a borda sem margem negativa e sem 100vw. Cada <li> traz o próprio --wheel-i no style e o conteúdo do painel nos data-wheel-year|title|text.

Ganchos

Gancho Papel
[data-wheel] Raiz. O script parte daqui, e é nela que escreve --wheel-radius e --wheel-step medidos.
[data-wheel-start] Índice da parada de abertura. Padrão 0.
[data-wheel-list] O <ul> que cobre o palco: único ponto focável, alvo do arrasto e do teclado, dono de --wheel-angle.
[data-wheel-prev] / [data-wheel-next] Os dois botões. Recebem aria-disabled nas pontas — nunca disabled.
[data-wheel-panel] / [data-wheel-out="…"] A região aria-live e os três nós de texto reescritos na troca.
[data-wheel-dots] A régua de marcas.
--wheel-card / --wheel-card-h Largura do card e altura derivada (4/3).
--wheel-h Altura do palco — quanto da descida do aro aparece.
--wheel-rise Folga entre o topo do palco e o card do topo. 16px.
--wheel-gap Vão entre cards no aro, como fração da largura do card. .1.
--wheel-reach / --wheel-radius-min R = max(largura da seção × reach, min). .9 e 900 no desktop; 1.4 e 420 no celular.
--wheel-radius / --wheel-step / --wheel-angle Escritos pelo script. Os valores no CSS são o desenho sem JavaScript.
--wheel-drop Posição do cubo, derivada em CSS do raio, da altura do palco e da altura do card.
--wheel-i / --wheel-k Índice do card (inline) e distância angular ao topo em passos (por quadro).
--wheel-shrink Encolhimento por passo de distância. .05.
--wheel-panel-h Altura reservada do painel. 186px.
.is-dragging / .is-spinning / .is-swap / .is-on Cursor de arrasto · will-change ligado · animação de troca do painel · marca ativa.

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, não clone de site: a roda-gigante de cards com o cubo abaixo da dobra aparece em muitos lugares — vitrines premiadas do Awwwards, componentes de carrossel em arco distribuídos para Framer, e as páginas de "nossa história" de marcas de moda e de automóveis, que quase sempre põem os anos num arco em vez de numa lista. Nenhuma delas foi a referência: não há URL, não há captura, e não há uma linha copiada. A forma foi construída aqui a partir da geometria — corda, raio, tangente —, e o resto saiu do contrato. Antes de construir, o catalogo.json foi consultado: nenhum componente catalogado resolvia sequência com ponta e foto por parada. Status externo: nunca rodou em produção. O primeiro site que usar promove, e esta ficha registra qual foi. O registro consolidado está em projetos/autorais.md.

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

Divergências conhecidas vs. o desenho pedido.