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

Carrossel em anel 3D

🟢 estavel complexidade alta prefixo ring- carrossel anel 3d perspectiva arrasto drag inercia encaixe galeria casos teclado sangria

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

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

markup.html · 189 linhas · 9,4 KB

<!-- ==========================================================================
     CARROSSEL EM ANEL 3D — oito fotos sobre um cilindro que gira
     Prefixo: ring-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css e script.js
     Sem lib. A perspectiva é CSS; o script só escreve o ângulo e a profundidade.

     TRÊS CAMADAS, E CADA UMA EXISTE POR UM MOTIVO. O palco (.ring-stage)
     declara a perspectiva e é o alvo do gesto e do foco — é o widget. A cena
     (.ring-scene) é a única peça que GIRA: leva transform-style:preserve-3d e
     o único will-change do componente. A lista (.ring-list) é um <ul> de
     verdade, também em preserve-3d, para o leitor de tela anunciar "8 itens"
     antes do primeiro card — que é justamente a informação que um anel
     esconde de quem não vê a metade de trás.

     O FOCO MORA NO PALCO, NÃO NA LISTA. Tudo que está dentro da cena gira
     com ela, e um anel de foco desenhado ali giraria junto, como uma porta.
     O palco fica fora do contexto 3D: seu retângulo é estável, e por isso é
     ele que carrega tabindex, role="group" e aria-roledescription. Quem gira
     por teclado gira o widget, não um card.

     O RECORTE É DA SEÇÃO. O anel é mais largo que a coluna em qualquer tela
     estreita, e no celular ele passa da janela. Quem corta é o overflow da
     <section> — nunca o palco: recorte no pai direto de uma cena em
     preserve-3d achata o 3D (ver o gotcha medido na ficha). Por isso o palco
     é IRMÃO do .wrap e filho direto da seção, sem margem negativa e sem 100vw.

     DUAS LEGENDAS, E NÃO É REDUNDÂNCIA. A visível (.ring-caption) acompanha o
     anel quadro a quadro e é aria-hidden. A invisível (.ring-live) só recebe
     texto quando a pessoa PEDE a troca — seta, botão, clique ou arrasto. Um
     anel que gira sozinho troca de card a cada 7,5 s: um aria-live preso ao
     texto visível anunciaria oito vezes por minuto, para sempre.

     ORDEM DOS BOTÕES. Eles ficam no rodapé da dobra, colados no contador e na
     legenda que mudam — e não no cabeçalho. Assim o mesmo DOM serve o desktop
     e o celular, onde a regra é ter controle visível ABAIXO do palco, e
     ninguém precisa duplicar comando nenhum.
     ========================================================================== -->

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

  <!-- Cabeçalho CENTRADO (primitiva .sec-head do core): o palco é simétrico
       em torno do eixo da página, e um título encostado à esquerda deixaria
       um vazio de meia tela em cima do anel. O rodapé, esse sim, abre para
       os dois cantos — ali a assimetria é o que faz a barra ler como
       controle, e não como mais uma linha de texto. -->
  <div class="wrap">
    <div class="sec-head ring-head">
      <span class="eyebrow" data-rv>Casos atendidos</span>
      <h2 class="sec-title ttl">Estruturas que já <em>montamos</em></h2>
      <p class="sec-lead" data-rv data-d="1">
        Um recorte do que costuma chegar até a mesa — holding, sucessão,
        liquidez, exterior. Gire o anel arrastando, pelas setas do teclado ou
        pelos dois botões.
      </p>
    </div>
  </div>

  <!-- O palco é o widget: recebe foco, arrasto e teclado. role="group" é o
       que permite o aria-roledescription (uma <div> genérica não sustenta o
       atributo), e é o mesmo par que o padrão de carrossel da APG usa. -->
  <div class="ring-stage" data-rv data-ring-stage
       tabindex="0" role="group" aria-roledescription="carrossel"
       aria-label="Casos atendidos — gire com as setas do teclado ou arrastando">

    <div class="ring-scene" data-ring-scene>
      <ul class="ring-list" role="list">

        <!-- --ring-i é a posição do card no anel, e é a ÚNICA coisa que o
             markup precisa dizer sobre geometria: o passo (360/N) e o raio
             saem da conta do script. Trocar oito por seis ou dez cards é
             renumerar esta lista, nada mais. -->
        <li class="ring-card" style="--ring-i:0">
          <img class="ring-img" src="../../../showroom/demo/ph-1.webp"
               alt="Casal em uma varanda com vista para um vilarejo" decoding="async">
          <span class="ring-cap">
            <span class="ring-cap-name">Três irmãos, um acordo</span>
            <span class="pill ring-cap-cat">Holding familiar</span>
          </span>
          <span class="ring-veil" aria-hidden="true"></span>
        </li>

        <li class="ring-card" style="--ring-i:1">
          <img class="ring-img" src="../../../showroom/demo/ph-2.webp"
               alt="Família caminhando por uma alameda arborizada" loading="lazy" decoding="async">
          <span class="ring-cap">
            <span class="ring-cap-name">Testamento antes da urgência</span>
            <span class="pill ring-cap-cat">Sucessão</span>
          </span>
          <span class="ring-veil" aria-hidden="true"></span>
        </li>

        <li class="ring-card" style="--ring-i:2">
          <img class="ring-img" src="../../../showroom/demo/ph-3.webp"
               alt="Duas pessoas conversando junto a uma janela" loading="lazy" decoding="async">
          <span class="ring-cap">
            <span class="ring-cap-name">Patrimônio em duas jurisdições</span>
            <span class="pill ring-cap-cat">Internacional</span>
          </span>
          <span class="ring-veil" aria-hidden="true"></span>
        </li>

        <li class="ring-card" style="--ring-i:3">
          <img class="ring-img" src="../../../showroom/demo/ph-4.webp"
               alt="Três pessoas reunidas em um escritório com vista" loading="lazy" decoding="async">
          <span class="ring-cap">
            <span class="ring-cap-name">Aposentadoria com renda definida</span>
            <span class="pill ring-cap-cat">Previdência</span>
          </span>
          <span class="ring-veil" aria-hidden="true"></span>
        </li>

        <li class="ring-card" style="--ring-i:4">
          <img class="ring-img" src="../../../showroom/demo/ph-5.webp"
               alt="Homem mais velho e criança lendo juntos" loading="lazy" decoding="async">
          <span class="ring-cap">
            <span class="ring-cap-name">Caixa da empresa com política</span>
            <span class="pill ring-cap-cat">Empresas</span>
          </span>
          <span class="ring-veil" aria-hidden="true"></span>
        </li>

        <li class="ring-card" style="--ring-i:5">
          <img class="ring-img" src="../../../showroom/demo/ph-6.webp"
               alt="Globo terrestre antigo em uma biblioteca" loading="lazy" decoding="async">
          <span class="ring-cap">
            <span class="ring-cap-name">Seguro que fecha a conta</span>
            <span class="pill ring-cap-cat">Proteção</span>
          </span>
          <span class="ring-veil" aria-hidden="true"></span>
        </li>

        <li class="ring-card" style="--ring-i:6">
          <img class="ring-img" src="../../../showroom/demo/ph-7.webp"
               alt="Reunião em uma sala de vidro" loading="lazy" decoding="async">
          <span class="ring-cap">
            <span class="ring-cap-name">Um fundo para a próxima geração</span>
            <span class="pill ring-cap-cat">Filantropia</span>
          </span>
          <span class="ring-veil" aria-hidden="true"></span>
        </li>

        <li class="ring-card" style="--ring-i:7">
          <img class="ring-img" src="../../../showroom/demo/ph-8.webp"
               alt="Mesa com café, caderno e óculos" loading="lazy" decoding="async">
          <span class="ring-cap">
            <span class="ring-cap-name">Venda de participação em 90 dias</span>
            <span class="pill ring-cap-cat">Liquidez</span>
          </span>
          <span class="ring-veil" aria-hidden="true"></span>
        </li>

      </ul>
    </div>
  </div>

  <div class="wrap ring-foot" data-rv data-d="1">

    <!-- aria-hidden porque o texto já está no card correspondente da lista
         acima; repeti-lo aqui só faria o leitor de tela ler duas vezes. -->
    <p class="ring-caption" aria-hidden="true" data-ring-caption>
      <span class="ring-caption-title" data-ring-title>Três irmãos, um acordo</span>
      <span class="ring-caption-cat" data-ring-cat>Holding familiar</span>
    </p>

    <div class="ring-ctrl">
      <p class="ring-count" aria-hidden="true">
        <span class="ring-count-now" data-ring-now>01</span>
        <span class="ring-count-sep">/</span>
        <span class="ring-count-all" data-ring-all>08</span>
      </p>
      <button class="btn-disc ring-btn" type="button" data-ring-prev aria-label="Girar para o caso anterior">
        <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
          <path d="M10 2L4 8L10 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
      </button>
      <button class="btn-disc ring-btn" type="button" data-ring-next aria-label="Girar para o próximo caso">
        <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>

    <!-- Fora da tela, nunca vazia depois do primeiro gesto: é aqui que a
         troca PEDIDA pela pessoa é anunciada. Ver o comentário do topo. -->
    <p class="ring-live" data-ring-live aria-live="polite" aria-atomic="true"></p>

  </div>

</section>

estilo.css · 320 linhas · 14,6 KB

/* ==========================================================================
   CARROSSEL EM ANEL 3D — prefixo ring-
   Oito cards de foto dispostos sobre um cilindro visto de frente. O anel gira
   sozinho, no arrasto, nas setas e nos dois botões, encaixando no card da
   frente. A metade de trás não aparece; as laterais escurecem.

   Tokens usados:
     --display --sans     tipografia do projeto
     --ease               curva única do projeto
     --accent             anel de foco do palco e hover dos botões
     --dark               véu de profundidade (cortina sólida sobre o card)
     --dark-rgb           gradiente da legenda dentro da foto
     --dark-2             fundo do card enquanto a foto não pintou
     --light              texto DENTRO da foto — ilha escura, não escopo de tom
     --radius-media       canto do card
     --radius-pill        disco dos botões e pílula da categoria
     --fg --fg-2 --fg-muted   legenda, contador e ícone dos botões
     --rule-2             borda dos botões

   O respiro da dobra (--sec-gap) e a coluna (--pad, --max) chegam pelas
   primitivas .sec-pad e .wrap; nenhuma regra deste arquivo os lê.

   TRÊS DECISÕES QUE EXPLICAM O ARQUIVO INTEIRO:

   1. A GEOMETRIA É UMA CONTA, NÃO UMA TABELA. Cada card só declara
      --ring-i (a sua casa no anel). O passo (--ring-step = 360/N) e o raio
      (--ring-radius) são escritos pelo script a partir da largura REAL do
      card. Seis, oito ou dez cards fecham o anel sozinhos, e mudar
      --ring-card não pede tocar em mais nada.

   2. O FRENTE É O PLANO DA PERSPECTIVA. A cena inteira recua --ring-radius
      antes de girar, então o card da frente pousa exatamente em z=0: escala
      1, sem ampliação e sem o borrão que texto ampliado em 3D carrega. Quem
      recua é a cena, e não cada card — a conta do anel exige que todos
      partam do mesmo centro.

   3. O RECORTE É DA SEÇÃO. overflow no palco ou na cena achataria o 3D
      (grouping property: overflow ≠ visible força transform-style:flat).
      Aqui o palco tem overflow visível e o anel sangra; quem corta é o
      overflow:hidden do .ring, que está FORA do contexto de perspectiva.
   ========================================================================== */

.ring{
  /* Largura do card. O anel inteiro é dimensionado a partir daqui. */
  --ring-card:clamp(180px,17vw,260px);
  /* Retrato 3:4 — a mesma proporção dos placeholders 900×1200. */
  --ring-h:calc(var(--ring-card) * 4 / 3);
  /* Respiro vertical entre o card mais alto e a borda do palco. Existe para
     o anel de foco do palco não encostar na foto. */
  --ring-air:clamp(16px,2.4vw,34px);

  /* Profundidade da câmera, em px. Menor = mais dramático. Abaixo de ~900 o
     card da frente começa a distorcer nas quinas. */
  --ring-perspective:1400px;

  /* FOLGA DO ANEL, sem unidade. O raio de um polígono regular de N lados de
     largura W é (W/2)/tan(pi/N) — nesse raio os cards fecham como um tambor,
     encostados um no outro. A folga afasta o card do centro na proporção
     dada: 1.12 abre um vão de 12% da largura entre vizinhos, o suficiente
     para o olho ler CARDS EM VOLTA de um eixo, e não uma parede curva.
     Quem lê e aplica é o script. */
  --ring-slack:1.12;

  /* Velocidade da rotação livre, em GRAUS POR SEGUNDO. A conta do script é
     por tempo, não por quadro: 60Hz e 120Hz giram igual. 0 deixa o anel
     parado, girando só no gesto. */
  --ring-speed:6;

  /* Escritos pelo script. Os valores aqui são o estado sem JS: o anel nasce
     desenhado e legível, com o card 0 na frente. */
  --ring-step:45deg;
  --ring-radius:340px;

  position:relative;
  /* Único recorte do componente, e ele é da SEÇÃO — ver a decisão 3 do topo.
     O anel é mais largo que a coluna e, no celular, mais largo que a janela. */
  overflow:hidden;
}

/* ---------------------------------------------------------------- cabeçalho */
/* Só o ajuste de largura: o resto — inclusive a centralização dos filhos vem do .sec-head do core (desde 2026-09-29) — vem da
   primitiva. */
.ring-head{--head-max:700px}

/* ---------------------------------------------------------------- palco */
/* Irmão do .wrap e filho direto da seção: nasce com a largura da página.
   O max-width existe só para o anel de foco: sem ele, o retângulo de
   :focus-visible atravessaria a tela inteira para cercar um anel de 620px.
   Dois raios é a medida certa, e não dois raios mais um card: o card mais
   externo que ainda aparece está a ~60° e a perspectiva já o puxou para
   dentro — medido, 311px de meia-largura contra os 331px do raio. */
.ring-stage{
  position:relative;
  width:100%;
  max-width:calc(2 * var(--ring-radius));
  margin-inline:auto;
  height:calc(var(--ring-h) + 2 * var(--ring-air));
  margin-block:clamp(34px,5vh,56px);
  perspective:var(--ring-perspective);
  /* O gesto horizontal é do anel; o vertical continua sendo da página. */
  touch-action:pan-y;
  /* Arrastar sobre foto e legenda selecionaria as duas. */
  user-select:none;-webkit-user-select:none;
  cursor:grab;
}
.ring-stage.is-dragging{cursor:grabbing}

/* O anel de foco do core desenha 3px PARA FORA; aqui ele cairia na borda da
   seção em tela estreita. Desenhado para dentro, cerca o palco inteiro — que
   é a verdade do widget: quem tem o foco gira o anel, não um card. */
.ring-stage:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}

/* O reveal do core termina em filter:blur(0), que NÃO é filter:none — fica
   uma superfície de composição permanente no elemento. Medido aqui, sobre o
   palco, custa 1% do quadro e não achata nada: o palco tem perspective, não
   preserve-3d. O motivo de tirar mesmo assim é outro: filter é uma das
   grouping properties que ACHATAM 3D, e o mesmo blur(0px) posto no <ul>
   entre a cena e os cards derruba o anel na hora (medido: o card a 90° cai
   de 38px de fatia para 0, e o da frente de 245px para 121). Um componente
   que não carrega grouping property nenhuma dentro da cadeia de perspectiva
   é um componente a menos para quebrar quando alguém mexer aqui.
   A transição continua saindo de blur(14px): `none` interpola como a
   identidade do filtro, então o reveal desfoca do mesmo jeito.
   Especificidade acima do [data-rv].in do core de propósito — o §6.4 proíbe
   depender de qual folha carregou primeiro. */
.ring .ring-stage.in{filter:none}

/* ---------------------------------------------------------------- cena */
/* A única peça que gira, e o único will-change do componente. preserve-3d
   nela E na lista: o <ul> no meio é um plano de achatamento por padrão, e
   sem preserve-3d ele esmagaria os oito cards contra o próprio plano.

   translateZ(-raio) ANTES do rotateY: recua o eixo do anel para trás da tela
   na medida exata do raio, e assim o card da frente pousa em z=0. Invertida,
   a ordem giraria um anel já deslocado e o resultado seria uma órbita torta. */
.ring-scene{
  position:absolute;inset:0;
  transform-style:preserve-3d;
  transform:translateZ(calc(var(--ring-radius) * -1)) rotateY(var(--ring-angle,0deg));
  will-change:transform;
}
.ring-list{position:absolute;inset:0;transform-style:preserve-3d}

/* ---------------------------------------------------------------- card */
/* inset:0 + margin:auto centra o card no palco sem depender de metade da
   própria largura em margem negativa — e o transform-origin cai no centro
   do card, que é o eixo que o rotateY precisa.

   backface-visibility:hidden é o que apaga a metade de trás do anel. Sem
   ela, quatro cards espelhados apareceriam atrás dos da frente e o cilindro
   viraria uma bagunça de texto invertido. */
.ring-card{
  position:absolute;inset:0;margin:auto;
  width:var(--ring-card);height:var(--ring-h);
  transform:rotateY(calc(var(--ring-i) * var(--ring-step))) translateZ(var(--ring-radius));
  backface-visibility:hidden;-webkit-backface-visibility:hidden;
  border-radius:var(--radius-media);
  overflow:hidden;
  background:var(--dark-2);
}
/* Os cards da metade de trás não são pintados, mas continuam existindo para
   o teste de acerto em alguns motores. O script marca quem passou dos 90° e
   a regra tira o card da frente do clique — senão um card invisível roubaria
   o gesto do que está visível atrás dele. */
.ring-card.is-back{pointer-events:none}

.ring-img{
  position:absolute;inset:0;
  width:100%;height:100%;object-fit:cover;
  object-position:var(--ring-focus,50% 42%);
}

/* Legenda DENTRO da foto, ancorada embaixo (contrato §6). O padding de topo
   generoso é o que põe o texto na parte OPACA da máscara: a máscara apaga o
   véu de baixo para cima, e sem esse respiro ela apagaria as letras junto. */
.ring-cap{
  position:absolute;left:0;right:0;bottom:0;z-index:2;
  display:flex;flex-direction:column;align-items:flex-start;gap:9px;
  padding:52px 15px 15px;
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.93) 0%,
    rgba(var(--dark-rgb),.56) 46%,
    transparent 100%);
  backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
  mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
  -webkit-mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 44%,transparent 100%);
}

.ring-cap-name{
  font-family:var(--display);font-weight:700;
  font-size:clamp(14px,1.2vw,17px);line-height:1.22;letter-spacing:-.02em;
  color:var(--light);
}

/* Reaproveita a .pill do core — forma, respiro e tracking vêm de lá. Só o tom
   muda, porque a pílula está sobre foto: ali o texto é ilha escura e lê
   --light, não o escopo de tom da seção. Seletor duplo de propósito: .pill e
   .ring-cap-cat têm a mesma especificidade, e o §6.4 proíbe depender de qual
   folha carregou primeiro. */
.ring-cap .ring-cap-cat{
  font-size:10px;letter-spacing:.14em;padding:4px 10px;
  color:var(--light);
  border-color:color-mix(in srgb,var(--light) 32%,transparent);
}

/* VÉU DE PROFUNDIDADE. --ring-depth vem do script: 0 no card da frente, 1 no
   que está a 90°. É uma cortina sólida com opacidade variável, e não
   filter:brightness no card, por uma razão de custo: opacidade é mudança de
   composição, filtro é repintura — e aqui são oito elementos reescritos a
   cada quadro. Sem transition de propósito: o valor já anda quadro a quadro,
   e uma transição por cima transformaria cada escrita numa animação nova.

   Fica ACIMA da legenda (z maior) para o card recuar inteiro, texto junto. */
.ring-veil{
  position:absolute;inset:0;z-index:3;pointer-events:none;
  background:var(--dark);
  opacity:calc(var(--ring-depth,0) * .66);
}

/* ---------------------------------------------------------------- rodapé */
.ring-foot{
  display:flex;align-items:center;justify-content:space-between;
  gap:clamp(16px,3vw,40px);
}

.ring-caption{
  display:flex;flex-direction:column;gap:6px;min-width:0;
}
.ring-caption-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(18px,2vw,26px);line-height:1.2;letter-spacing:-.02em;
  color:var(--fg);
}
.ring-caption-cat{
  font-family:var(--sans);font-size:13px;line-height:1.4;
  letter-spacing:.14em;text-transform:uppercase;color:var(--fg-muted);
}

/* A troca da legenda entra com blur e subida, como manda o §5 — e por
   animação, não por transition: a classe é posta e tirada pelo script, e
   animação dispara de novo sem depender do estado anterior. */
.ring-caption.is-changing{animation:ringCap .44s var(--ease) both}
@keyframes ringCap{
  from{opacity:0;filter:blur(9px);transform:translateY(9px)}
  to{opacity:1;filter:blur(0);transform:translateY(0)}
}
@media(prefers-reduced-motion:reduce){
  .ring-caption.is-changing{animation:none}
}

.ring-ctrl{display:flex;align-items:center;gap:12px;flex:0 0 auto}

.ring-count{
  display:inline-flex;align-items:baseline;gap:5px;
  font-family:var(--display);font-weight:700;font-size:12px;
  letter-spacing:.12em;color:var(--fg-muted);
  /* Tabular: sem isto o contador dança um pixel a cada troca de dígito. */
  font-variant-numeric:tabular-nums;
  margin-inline-end:6px;
}
.ring-count-now{color:var(--fg-2)}
.ring-count-sep{opacity:.5}

/* Disco de ícone. Não é .btn: .btn é pílula de TEXTO com padding fixo, e
   espremer 42px nela seria brigar com a primitiva. É a QUARTA cópia desta
   regra na biblioteca (galeria-scroll-horizontal, duas-colunas-produto,
   card-expansivel-fullscreen) — proposta de promoção ao core registrada na
   ficha, conforme o §6 do contrato. */
/* O disco de ícone é a primitiva .btn-disc do core (core/base.css): forma,
   cor, hover e o estado desabilitado vêm de lá, e o markup traz as duas
   classes. Esta pasta escrevia as mesmas seis linhas — era a segunda de cinco
   cópias idênticas, e o §6 manda promover na terceira. */

/* Região viva, fora da tela. clip-path e não display:none nem visibility:
   as duas tiram o elemento da árvore de acessibilidade e o anúncio nunca
   sai. Ver o comentário de topo do markup. */
.ring-live{
  position:absolute;width:1px;height:1px;
  margin:-1px;padding:0;overflow:hidden;
  clip-path:inset(50%);white-space:nowrap;
}

/* ---------------------------------------------------------------- movimento reduzido */
/* A rotação livre e a inércia são desligadas no script.js — gesto pedido
   pela pessoa (arrasto, seta, botão) continua funcionando, sem arremesso.
   Aqui fica só o que é CSS: a troca da legenda, tratada junto da regra que
   ela desliga, mais acima. */

/* ---------------------------------------------------------------- toque */
/* Sem ponteiro não existe hover: o cursor de arrasto vira ruído, e os dois
   botões passam a ser o alvo de quem prefere tocar. */
@media(hover:none){
  .ring-stage{cursor:default}
  .ring-btn{color:var(--fg)}
}

/* ---------------------------------------------------------------- mobile */
@media(max-width:760px){
  .ring{
    /* Card menor, folga IGUAL à do desktop. A folga não é o parafuso de
       caber: afastar o card do eixo aumenta a meia-largura do anel na tela
       (medido em 390px — 1.12 chega a 219px de borda, 1.28 chega a 250px),
       porque o recuo em z que ela também provoca encolhe menos do que o
       afastamento em x cresce. Quem faz caber é --ring-card. */
    --ring-card:min(44vw,200px);
    /* Câmera mais curta: em tela estreita a profundidade precisa ser mais
       óbvia, senão o anel lê como três cards tortos lado a lado. O card da
       frente não sofre — em z=0 a escala é 1 para qualquer perspectiva. */
    --ring-perspective:1000px;
  }
  .ring-stage{margin-block:clamp(26px,4vh,40px)}
  .ring-foot{flex-direction:column;align-items:center;text-align:center;gap:22px}
  .ring-caption{align-items:center}
  .ring-cap{padding:44px 13px 13px;gap:7px}
}

@media(max-width:420px){
  .ring-caption-title{font-size:17px}
  .ring-count{margin-inline-end:2px}
}

script.js · 409 linhas · 16,8 KB

/* ==========================================================================
   CARROSSEL EM ANEL 3D — ângulo, encaixe, arrasto e profundidade.

   O 3D é CSS. Este script escreve três coisas e nada mais:

     1. --ring-radius e --ring-step, uma vez e a cada resize, a partir da
        largura REAL do card — é o que faz seis, oito ou dez cards fecharem
        o anel sozinhos;
     2. --ring-angle na cena, quadro a quadro — a rotação;
     3. --ring-depth em cada card, quadro a quadro — o quanto ele recuou.

   E cuida do resto da interação: arrasto com inércia e encaixe, setas,
   Home/End, os dois botões, clique num card lateral, legenda visível,
   anúncio para leitor de tela e a pausa sob o ponteiro.

   UM rAF por instância, e ele dorme: fora da tela (IntersectionObserver),
   parado sob o ponteiro, com o foco dentro, ou com movimento reduzido e sem
   gesto em curso. A rotação livre anda por TEMPO (graus por segundo), com
   teto de 50ms no dt — voltar de uma aba em segundo plano não dá meia volta
   de uma vez.

   MOVIMENTO REDUZIDO: o anel não gira sozinho e não arremessa ao soltar. As
   setas, os botões e o arrasto continuam — gesto pedido pela pessoa não é
   movimento imposto a ela. O encaixe fica curto (fator .5 no lugar de .16),
   que resolve em ~8 quadros: nem salto seco, nem viagem.
   ========================================================================== */

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

  /* 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 dois(v) { return (v < 10 ? '0' : '') + v; }

  function iniciar(raiz) {
    var palco = raiz.querySelector('[data-ring-stage]');
    var cena  = raiz.querySelector('[data-ring-scene]');
    if (!palco || !cena) return;

    var cards = [].slice.call(cena.querySelectorAll('.ring-card'));
    var n = cards.length;
    /* Menos de três não fecha anel: dois cards ficariam costas com costas e
       o passo de 180° deixaria a cena vazia metade do tempo. */
    if (n < 3) return;

    var reduz = reduzido();
    var passo = 360 / n;                            /* graus entre vizinhos */

    var elLegenda = raiz.querySelector('[data-ring-caption]');
    var elTitulo  = raiz.querySelector('[data-ring-title]');
    var elCat     = raiz.querySelector('[data-ring-cat]');
    var elAgora   = raiz.querySelector('[data-ring-now]');
    var elTotal   = raiz.querySelector('[data-ring-all]');
    var elVivo    = raiz.querySelector('[data-ring-live]');
    var btAntes   = raiz.querySelector('[data-ring-prev]');
    var btDepois  = raiz.querySelector('[data-ring-next]');

    /* Os textos da legenda saem do próprio card: uma fonte só, e trocar a
       copy do markup não pede tocar no script. */
    var titulos = [], cats = [], i;
    for (i = 0; i < n; i++) {
      var elT = cards[i].querySelector('.ring-cap-name');
      var elC = cards[i].querySelector('.ring-cap-cat');
      titulos.push(elT ? elT.textContent.replace(/\s+/g, ' ').replace(/^ | $/g, '') : '');
      cats.push(elC ? elC.textContent.replace(/\s+/g, ' ').replace(/^ | $/g, '') : '');
    }
    if (elTotal) elTotal.textContent = dois(n);

    var ang = 0;              /* rotação da cena, em graus */
    var alvo = null;          /* ângulo de destino do encaixe */
    var vel = 0;              /* inércia do arrasto, em graus por quadro */
    var raio = 340;
    var vAuto = 0;            /* rotação livre, em graus por segundo */
    var fator = .27;          /* graus por pixel de arrasto */
    var parado = false;       /* ponteiro em cima ou foco dentro */
    var dentro = false;       /* dobra visível */
    var laco = 0, antes = 0;
    var frente = 0;           /* índice na frente, já publicado */
    var trocar = false;       /* há troca de legenda a escrever no próximo quadro */
    var avisarNoFim = false;  /* o encaixe atual veio de um arrasto: anunciar */
    var profAntes = [], atrasAntes = [];
    var arrastando = false, ponteiro = -1, xAnt = 0, dxUlt = 0, andou = 0;

    /* ------------------------------------------------------------ medidas */

    /* A CONTA DO ANEL. O raio de um polígono regular de n lados de largura W
       é (W/2)/tan(pi/n): nesse raio os cards fecham como um tambor, encostados
       um no outro. --ring-slack afasta na proporção dada e abre o vão que faz
       o olho ler cards em volta de um eixo, e não uma parede curva. */
    function medir() {
      var cs = getComputedStyle(raiz);

      var folga = parseFloat(cs.getPropertyValue('--ring-slack'));
      if (isNaN(folga) || folga <= 0) folga = 1.12;

      /* parseFloat || padrao engoliria um --ring-speed:0 legítimo (anel
         parado, que é uma variação da ficha) e devolveria o padrão. */
      var v = parseFloat(cs.getPropertyValue('--ring-speed'));
      if (isNaN(v)) v = 6;
      vAuto = reduz ? 0 : v;

      /* offsetWidth e NÃO getBoundingClientRect: o card está transformado em
         3D, e o rect devolve a caixa PROJETADA — menor, e diferente a cada
         ângulo. offsetWidth ignora transform e devolve a largura de layout,
         que é a que a conta do raio precisa. */
      var larg = cards[0].offsetWidth || 240;

      /* Math.round: translateZ fracionário faz o navegador reamostrar a
         textura do card e o texto sai borrado mesmo com o anel parado. */
      raio = Math.round((larg / 2) / Math.tan(Math.PI / n) * folga);

      raiz.style.setProperty('--ring-radius', raio + 'px');
      raiz.style.setProperty('--ring-step', passo + 'deg');

      /* 180° por largura do palco: arrastar a caixa inteira dá meia volta, e
         um card sai a cerca de um quarto dela. Lido DEPOIS de escrever o
         raio, porque o max-width do palco depende dele. */
      var w = palco.getBoundingClientRect().width || 1;
      fator = 180 / w;
    }

    /* ------------------------------------------------------------ índices */

    /* Casa contínua: quantos passos o anel andou desde a origem. Pode ser
       negativa e pode passar de n — é a volta inteira, não o índice. */
    function casa() { return Math.round(-ang / passo); }

    /* A casa de onde o PRÓXIMO comando parte. Com um encaixe em curso, é o
       destino dele — não a posição de agora. Sem isto, dois cliques seguidos
       no mesmo botão andam um card só: o segundo chega antes de o anel sair
       do lugar, lê a casa antiga e pede o mesmo destino. Medido: três cliques
       rápidos paravam em 03 em vez de 04. */
    function casaBase() {
      return alvo !== null ? Math.round(-alvo / passo) : casa();
    }

    /* O % de JavaScript devolve NEGATIVO para operando negativo (-3 % 8 é
       -3, não 5). Sem o + n e o segundo %, o card da frente viraria índice
       negativo na primeira volta para trás e a legenda leria undefined. */
    function indice(k) { return ((k % n) + n) % n; }

    /* ------------------------------------------------------------ legenda */

    function texto(idx) {
      if (elTitulo) elTitulo.textContent = titulos[idx];
      if (elCat) elCat.textContent = cats[idx];
      if (elAgora) elAgora.textContent = dois(idx + 1);
    }

    /* A região viva só recebe texto quando a troca foi PEDIDA — seta, botão,
       clique ou arrasto. A rotação livre troca de card a cada 360/n/velocidade
       segundos (7,5 s no padrão): ligada a ela, esta região anunciaria oito
       vezes por minuto, para sempre. */
    function anunciar(idx) {
      if (!elVivo) return;
      elVivo.textContent = titulos[idx] + ', ' + cats[idx] +
                           '. ' + (idx + 1) + ' de ' + n + '.';
    }

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

    function encaixar() { alvo = -casa() * passo; }

    /* Caminho curto até um card: a diferença de índice é dobrada para o
       intervalo [-n/2, n/2] antes de virar ângulo. Sem isso, ir do último ao
       primeiro daria a volta inteira para trás em vez de um passo à frente. */
    function irPara(destino, avisar) {
      var k = casaBase();
      var d = destino - indice(k);
      while (d > n / 2) d -= n;
      while (d < -n / 2) d += n;
      vel = 0;
      alvo = -(k + d) * passo;
      if (avisar) anunciar(destino);
      ligar();
    }

    function girar(dir) {
      var k = casaBase() + dir;
      vel = 0;
      alvo = -k * passo;
      anunciar(indice(k));
      ligar();
    }

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

    function ocupado() {
      return arrastando || vel !== 0 || alvo !== null || trocar ||
             (!parado && vAuto > 0);
    }

    function pintar() {
      cena.style.setProperty('--ring-angle', ang.toFixed(2) + 'deg');

      for (var j = 0; j < n; j++) {
        /* Ângulo do card em relação a quem olha, dobrado para [-180,180). */
        var t = (ang + j * passo) % 360;
        if (t > 180) t -= 360; else if (t < -180) t += 360;
        var abs = t < 0 ? -t : t;

        /* 0 na frente, 1 a 90° (de perfil) e daí para trás. Duas casas
           bastam: abaixo disso a escrita é ruído e o olho não vê. */
        var prof = abs / 90;
        if (prof > 1) prof = 1;
        prof = Math.round(prof * 100) / 100;
        if (profAntes[j] !== prof) {
          cards[j].style.setProperty('--ring-depth', prof);
          profAntes[j] = prof;
        }

        var atras = abs > 90;
        if (atrasAntes[j] !== atras) {
          if (atras) cards[j].classList.add('is-back');
          else cards[j].classList.remove('is-back');
          atrasAntes[j] = atras;
        }
      }

      /* Troca em dois quadros de propósito: tirar a classe num quadro e
         devolvê-la no seguinte é o que faz a animação reiniciar sem forçar
         reflow no meio do laço. */
      var idx = indice(casa());
      if (idx !== frente) {
        frente = idx;
        trocar = true;
        if (elLegenda) elLegenda.classList.remove('is-changing');
      } else if (trocar) {
        trocar = false;
        texto(idx);
        if (elLegenda) elLegenda.classList.add('is-changing');
      }
    }

    function quadro(agora) {
      /* Teto de 50ms: voltar de uma aba em segundo plano daria um dt enorme
         e o anel saltaria meia volta de uma vez. */
      var dt = antes ? Math.min((agora - antes) / 1000, .05) : 0;
      antes = agora;

      if (!arrastando) {
        if (vel) {
          ang += vel;
          vel *= .94;
          /* Corta em .35°/quadro (≈21°/s) e passa a bola ao encaixe. Esperar
             a inércia morrer de verdade custaria mais 45 quadros para andar
             1° somado — tempo parado de olho num anel que já chegou. */
          if ((vel < 0 ? -vel : vel) < .35) { vel = 0; encaixar(); }
        } else if (alvo !== null) {
          var d = alvo - ang;
          if ((d < 0 ? -d : d) < .04) {
            ang = alvo;
            alvo = null;
            if (avisarNoFim) { avisarNoFim = false; anunciar(indice(casa())); }
          } else {
            ang += d * (reduz ? .5 : .16);
          }
        } else if (!parado && vAuto > 0) {
          ang -= vAuto * dt;
          /* 360° é a mesma pose: normalizar mantém o número pequeno e o
             índice intacto, porque n passos fecham exatamente uma volta. */
          while (ang <= -360) ang += 360;
          while (ang >= 360) ang -= 360;
        }
      }

      pintar();
      laco = (dentro && ocupado()) ? requestAnimationFrame(quadro) : 0;
    }

    function ligar() {
      if (laco || !dentro) return;
      antes = 0;
      laco = requestAnimationFrame(quadro);
    }

    function desligar() {
      if (!laco) return;
      cancelAnimationFrame(laco);
      laco = 0;
    }

    /* ------------------------------------------------------------ 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. */
    palco.addEventListener('dragstart', function (e) { e.preventDefault(); });

    palco.addEventListener('pointerdown', function (e) {
      if (typeof e.button === 'number' && e.button !== 0) return;
      arrastando = true;
      andou = 0;
      vel = 0;
      alvo = null;
      ponteiro = e.pointerId;
      xAnt = e.clientX;
      dxUlt = 0;
      palco.classList.add('is-dragging');
      /* Captura para o arrasto sobreviver ao ponteiro sair do palco. */
      try { palco.setPointerCapture(ponteiro); } catch (err) {}
      ligar();
    });

    palco.addEventListener('pointermove', function (e) {
      if (!arrastando || e.pointerId !== ponteiro) return;
      var dx = e.clientX - xAnt;
      xAnt = e.clientX;
      dxUlt = dx;
      andou += (dx < 0 ? -dx : dx);
      ang += dx * fator;
    });

    function soltar(e) {
      if (!arrastando) return;
      if (e && e.pointerId !== undefined && e.pointerId !== ponteiro) return;
      arrastando = false;
      palco.classList.remove('is-dragging');
      try { palco.releasePointerCapture(ponteiro); } catch (err) {}
      /* O último deslocamento vira arremesso e decai até o encaixe assumir.
         Com movimento reduzido o anel para onde a mão soltou — e encaixa.

         O parafuso que importa não é o .5, é a SOMA da progressão: com decaimento
         de .94 por quadro, o arremesso anda vel/(1-.94) = 16,7 vezes a velocidade
         inicial. A .9 do carrossel-perfis daria 100° num arremesso rápido — duas
         casas e meia de anel só de escorregar. A .5 dá ~57°, pouco mais de uma. */
      vel = reduz ? 0 : dxUlt * fator * .5;
      if (vel < .35 && vel > -.35) { vel = 0; encaixar(); }
      if (andou > 6) avisarNoFim = true;
      ligar();
    }

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

    /* 6px de tolerância: um clique sempre treme um pouco, e um arrasto de
       verdade passa longe disso. Sem o limiar, girar o anel levaria até o
       card onde a mão soltou. */
    palco.addEventListener('click', function (e) {
      if (andou > 6) return;
      var no = e.target;
      while (no && no !== palco) {
        var idx = cards.indexOf(no);
        if (idx > -1) { irPara(idx, true); return; }
        no = no.parentNode;
      }
    });

    /* ------------------------------------------------------------ teclado */
    /* Só o eixo do anel. Cima e baixo continuam rolando a PÁGINA — quem
       aperta seta para baixo quer descer, não girar. */
    palco.addEventListener('keydown', function (e) {
      if (e.key === 'ArrowRight') { e.preventDefault(); girar(1); }
      else if (e.key === 'ArrowLeft') { e.preventDefault(); girar(-1); }
      else if (e.key === 'Home') { e.preventDefault(); irPara(0, true); }
      else if (e.key === 'End') { e.preventDefault(); irPara(n - 1, true); }
    });

    if (btAntes)  btAntes.addEventListener('click',  function () { girar(-1); });
    if (btDepois) btDepois.addEventListener('click', function () { girar(1); });

    /* ------------------------------------------------------------ pausa */
    /* mouseenter/mouseleave e não pointerenter: no toque não existe hover, e
       pausar ali deixaria o anel parado desde o primeiro toque.
       O foco é ouvido na RAIZ, não no palco: os dois botões vivem no rodapé,
       e quem acabou de mandar girar não quer o anel escorregando em seguida. */
    palco.addEventListener('mouseenter', function () { parado = true; });
    palco.addEventListener('mouseleave', function () { parado = false; ligar(); });
    raiz.addEventListener('focusin', function () { parado = true; });
    raiz.addEventListener('focusout', function () { parado = false; ligar(); });

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

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

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

    medir();
    frente = indice(casa());
    texto(frente);          /* sem .is-changing: no primeiro quadro nada mudou */
    pintar();               /* o anel nasce com a profundidade já escrita */
    io.observe(raiz);
  }

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

Oito cards de foto pousados sobre um cilindro visto de frente. O anel gira sozinho, muito devagar, e o olho segue o card que está chegando ao centro: ele cresce até ficar de frente, nítido e cheio, enquanto os vizinhos se inclinam para dentro como portas entreabertas e escurecem conforme recuam. A metade de trás não existe — o card virado some, e o cilindro lê como um punhado de fotos em volta de um eixo, não como uma parede curva. Arrastar o anel com o mouse ou com o dedo o gira com inércia; ao soltar, ele encaixa no card mais próximo da frente. As setas do teclado e os dois botões do rodapé fazem o mesmo passo a passo. Abaixo do palco, uma legenda diz o título e a categoria do card da frente, e troca com blur e subida a cada volta de casa.

Resolve a dobra de "o que já fizemos" quando os casos são muitos, cada um cabe numa foto e num título curto, e uma grade os transformaria em inventário. Uma fileira que rola para o lado mostra tudo com o mesmo peso; o anel elege um card por vez e diz, pela forma, que há mais girando atrás dele. É a dobra que dá presença a uma lista que, em grade, seria só um catálogo — e é por isso que o conteúdo aqui é curto de propósito: o anel não é para ler, é para percorrer até parar em alguma coisa.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho Duas ou três palavras. Nomeia a natureza da lista ("Casos atendidos"), não a interação.
Título Uma linha, com <em> numa palavra. Centrado — o palco é simétrico e um título à esquerda deixa meia tela vazia em cima do anel.
Apoio Duas ou três linhas, até 220 caracteres. A última frase diz como girar; sem ela, uma parte das pessoas só olha.
Cards 6 a 10, 8 no padrão. Menos de 6 não fecha anel legível; mais de 10 aperta o vão entre vizinhos (suba --ring-slack para 1.18).
Título do card 18 a 34 caracteres, no máximo duas linhas dentro da foto. Frase, não rótulo: "Três irmãos, um acordo" diz mais que "Holding".
Categoria do card Uma palavra, até 14 caracteres. É a pílula, e ela não quebra linha.
Legenda do rodapé Não se escreve: o script copia o título e a categoria do card da frente. Editar o markup do card é editar a legenda.

O que mantém o anel coeso é o paralelismo dos títulos: todos frases, ou todos rótulos. Misturar "Três irmãos, um acordo" com "Previdência corporativa" faz o anel parecer duas listas girando juntas. E o par categoria + título tem de funcionar lido em voz alta na ordem em que o leitor de tela lê: título, categoria, posição.

Especificação dos assets


Como usar

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

Ganchos

Gancho Papel
[data-ring] Raiz, na <section>. O script parte daqui, nunca do documento; é onde moram todas as variáveis de ajuste.
[data-ring-stage] O palco: perspectiva, foco por teclado (tabindex="0", role="group", aria-roledescription) e alvo do arrasto.
[data-ring-scene] A única peça que gira. Recebe --ring-angle e é o único will-change do componente.
[data-ring-prev] / [data-ring-next] Os dois botões. Um passo por clique, e cliques rápidos se acumulam.
[data-ring-caption] · -title · -cat Legenda visível do card da frente, trocada quadro a quadro. É aria-hidden.
[data-ring-now] / [data-ring-all] Contador 03 / 08. O total é preenchido pelo script.
[data-ring-live] Região aria-live fora da tela. Só recebe texto quando a troca foi pedida.
--ring-i No style de cada card: a casa dele no anel, de 0 a n−1. É a única geometria que o markup declara.
--ring-card Largura do card. O anel inteiro é dimensionado a partir dela. clamp(180px,17vw,260px).
--ring-h Altura do card: --ring-card × 4/3. Trocar aqui é trocar a proporção do recorte.
--ring-air Respiro entre o card e a borda do palco, para o anel de foco não encostar na foto.
--ring-perspective Profundidade da câmera. 1400px no desktop, 1000px no celular.
--ring-slack Folga do anel, sem unidade. 1.12 abre um vão de 12% da largura entre vizinhos.
--ring-speed Rotação livre em graus por segundo. 6 no padrão; 0 deixa o anel parado.
--ring-focus Enquadramento do object-fit por card, no style do <img>. Inerte com foto 3:4 exata.
--ring-step / --ring-radius / --ring-angle / --ring-depth Escritos pelo script. Não se mexe à mão; os dois primeiros têm padrão no CSS para o anel nascer desenhado sem JS.
.is-dragging No palco, durante o arrasto. Só troca o cursor.
.is-back No card que passou dos 90°. Tira do clique o que não está sendo pintado.
.is-changing Na legenda, durante a animação de troca.

Variações


Gotchas

Tudo abaixo foi medido no preview.html com Chrome headless (node bin/medir.js), em 1440×900 e em 390×844.


Histórico

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

Origem. É um componente autoral (contrato §11): um padrão de interação que aparece em muitos sites premium — vitrines de estúdio premiadas no Awwwards, os componentes de galeria em anel dos templates de Framer, as prateleiras de produto em perspectiva das páginas de produto da Apple — construído aqui do zero, sem referência única e sem uma linha copiada. Não há URL a creditar porque não há original: a forma é do repertório, o arquivo é da casa. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O primeiro projeto 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.