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
- Casos atendidos, tipos de estrutura, linhas de serviço, capítulos de uma história — de 6 a 10 itens, cada um com uma foto forte e um título de uma linha.
- Quando a dobra precisa de um momento de presença no meio de uma página de texto, e a página aguenta uma seção de ~980px em desktop.
- Quando os itens não levam a lugar nenhum: aqui o card não é link. Ele existe para ser olhado e nomeado.
Quando não usar
- Se cada item precisa abrir uma página: use
midia/galeria-scroll-horizontal, onde o card é um<a>de verdade, o encaixe é nativo do navegador e o Tab passa por todos. - Se cada item tem texto de verdade para ler (bio, descrição, número):
midia/carrossel-perfistem card com rodapé e painel de texto; aqui o card é foto e três palavras. - Menos de seis itens: o anel fica com vão grande demais entre vizinhos e a volta se fecha rápido demais para valer o 3D. Com quatro ou cinco fotos,
midia/galeria-pastaoulistas/leque-paineiscontam a mesma coisa com menos maquinário. - Mais de doze: a volta fica longa e o card da frente demora a mudar. Uma esteira contínua serve melhor —
midia/colunas-cascatapara parede de fotos,prova/carrossel-depoimentospara fila que atravessa sem parar. - Se o clique tem de abrir o item em tela cheia:
overlay/card-expansivel-fullscreen. - Se a dobra é o assunto principal da página e precisa de título, número e frase em cada item:
conteudo/pilares-texto-na-fotoé a grade que já faz isso, parada.
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
- Formato e dimensão mínima: 900×1200 (retrato 3:4), WebP. O card é exatamente 3:4 — foto nessa proporção não sofre corte nenhum.
- Peso alvo: até 150 KB por foto. São 6 a 10 imagens simultâneas na mesma dobra.
- Enquadramento: assunto no terço superior ou central. O quinto inferior da foto fica sob o véu da legenda e não deve carregar informação.
- Coerência: mesma temperatura de cor e mesma densidade nas 8. O anel mostra três fotos lado a lado o tempo todo, e uma clara no meio de escuras salta como erro.
- O que não pode ter: rosto encostado na borda inferior (some sob a legenda), texto na imagem, logo de terceiro, e foto de proporção muito diferente de 3:4 sem ajustar
--ring-focus.
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
- seis ou dez cards — acrescente ou remova
<li>e renumere--ring-i. Passo e raio saem da conta do script; nada mais muda. - anel parado —
--ring-speed:0. Gira só no gesto. - anel mais fechado —
--ring-slack:1.0encosta um card no outro e o anel vira tambor contínuo. - cards maiores —
--ring-card:clamp(220px,21vw,320px). Confira que2 × raioainda cabe na coluna. - em seção clara —
.sec-paperno lugar de.sec-dark. O véu de profundidade continua escuro de propósito: é sombra, não fundo. - sem legenda dentro da foto — apague os
.ring-cap, mantendo.ring-cap-namee.ring-cap-catnum<span hidden>: é de lá que o script lê o texto da legenda do rodapé.
Gotchas
Tudo abaixo foi medido no preview.html com Chrome headless (node bin/medir.js), em 1440×900 e em 390×844.
-
O recorte não pode ficar no
<ul>— e você não descobre isso lendo estilo computado.overflow:hidden,clip-path,filter:blur(0px)e atéopacity:.99no.ring-listachatam o 3D: a assinatura das larguras projetadas cai de137,245,137,38,138,166,138,38para140,121,0,121,140,83,0,83— o card a 90° perde a fatia de perspectiva (38px → 0) e o da frente encolhe de 245px para 121px, com o espalhamento indo de 539px para 385px. São as grouping properties do spec: qualquer uma delas forçatransform-style:flatno elemento. E ogetComputedStyle(lista).transformStylecontinua dizendopreserve-3dnos dois casos — a única forma de detectar é medir a geometria. As mesmas propriedades no.ring-scene(que carrega o transform) e no.ring-stage(que carrega a perspectiva) não achataram nada. Por isso o recorte que vai no ar está na<section>, fora da cadeia de perspectiva, e o palco temoverflowvisível. -
A perspectiva fica no pai, não em cada card. Trocar
perspective:1400pxno palco portransform:perspective(1400px) rotateY(…) translateZ(…)em cada card parece equivalente e não é: cada card ganha a própria câmera, plantada bem à frente dele, e o anel perde o ponto de fuga único. Medido: card da frente vai de 245×326 para 319×427, e o espalhamento do conjunto de 553px para 687px. De quebra, o recuo da cena fica inerte — sem perspectiva no ancestral não há projeção onde aplicá-lo. -
A cena recua o raio ANTES de girar, e é isso que deixa o texto nítido.
translateZ(calc(var(--ring-radius) * -1)) rotateY(var(--ring-angle)), nessa ordem: o eixo do anel vai para trás da tela na medida exata do raio e o card da frente pousa em z=0. Medido: layout 245×326, projetado 245×326 — escala 1,00. Sem o recuo, o anel fica centrado em z=0, o card da frente vai para z=+331 e a perspectiva o amplia: 318×431, ou 1,30×. Texto ampliado em 3D é texto reamostrado, e nenhumwill-changeconserta isso. Invertida a ordem das duas funções, o rotateY passa a girar um anel já deslocado e o resultado é uma órbita torta. -
--ring-speedé graus por SEGUNDO, eparseFloat(...) || 6seria um bug.--ring-speed:0é uma variação documentada (o anel parado), e o||a engoliria, devolvendo o padrão justamente para quem pediu zero. O script usaisNaN. A conta é pordtcom teto de 50ms: 60Hz e 120Hz giram igual, e voltar de uma aba em segundo plano não dá meia volta de uma vez. -
O
%do JavaScript devolve negativo, e o encaixe depende disso.-3 % 8é-3, não5. O índice do card da frente é((k % n) + n) % n, ondek = Math.round(-ângulo / passo)é a casa contínua — quantos passos o anel andou desde a origem, podendo passar dene ficar negativo. Sem o módulo duplo, a primeira volta para trás faz a legenda lerundefined. -
Dois cliques seguidos no mesmo botão andavam um card só. Medido antes do conserto: três cliques rápidos em "próximo" paravam em
03em vez de04. A causa é queMath.round(-ângulo/passo)lê a posição de agora, e o segundo clique chega antes de o anel sair do lugar. O comando parte da casa de destino quando há encaixe em curso (casaBase()). Depois: três cliques →-135°, card04; cincoArrowRightem rajada →-270°, card07. Vale igual para o auto-repeat de tecla segurada. -
O parafuso da inércia é a soma da progressão, não o multiplicador. Com decaimento de
.94por quadro, o arremesso andavel/(1−.94)= 16,7 vezes a velocidade inicial. O.9domidia/carrossel-perfisdaria ~100° num arremesso rápido — duas casas e meia de anel só de escorregar. Com.5, dá ~57°, pouco mais de uma casa. Medido: arrasto de −300px em 1440 pousa exatamente em-135,00°(3 cards); de −140px em 390, em-90,00°(2 cards). A inércia é cortada em.35°/quadroe passa a bola ao encaixe: esperar ela morrer de verdade custaria mais 45 quadros para andar 1° somado. -
A legenda visível é
aria-hidden; quem anuncia é outra, fora da tela. A 6°/s o card da frente troca a cada360/8/6= 7,5 segundos. Umaria-livepreso ao texto visível anunciaria oito vezes por minuto, para sempre, em qualquer página que tivesse esta dobra. Por isso são dois elementos:.ring-captionacompanha o anel quadro a quadro e éaria-hidden(o texto já está no card correspondente do<ul>, então nada se perde), e.ring-livesó recebe texto quando a troca foi pedida — seta, botão, clique ou fim de arrasto. Se alguém "simplificar" juntando os dois, o componente vira uma máquina de interromper leitor de tela. -
O véu de profundidade é opacidade, não
filter:brightness. São oito elementos reescritos a cada quadro: opacidade é mudança de composição, filtro é repintura. E o véu não temtransitionde propósito — o valor já anda quadro a quadro, e uma transição por cima transformaria cada escrita numa animação nova, com a sombra chegando atrasada em relação ao card. O véu fica acima da legenda (z-index:3) para o card recuar inteiro, texto junto. -
backface-visibility:hiddenjá tira o card de trás do clique — no Chrome. Medido: com e sem.is-back,elementFromPointno ponto em que a caixa projetada de um card virado cobre a de um card de frente devolve sempre o de frente. A regra.is-back{pointer-events:none}fica como guarda porque os motores já divergiram nisso, e o custo é uma troca de classe quando o estado vira — não por quadro. É também o gancho para quem quiser estilizar 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 e o retângulo dele é estável — daí
tabindex,role="group"earia-roledescription="carrossel"estarem nele, e não no<ul>. Ooutline-offseté negativo (-2px): o core desenha 3px para fora, e em tela estreita isso cairia no recorte da seção. Omax-widthdo palco é2 × raiojustamente para o anel de foco não atravessar a tela inteira para cercar um anel de 620px — medido, a meia-largura visível do anel chega a 291px contra os 331px do raio. -
A folga não é o parafuso de "caber". Afastar o card do eixo aumenta a meia-largura do anel na tela, porque o recuo em z que a folga também provoca encolhe menos do que o afastamento em x cresce. Medido em 390px:
--ring-slack:1.12chega a 219px de borda;1.28chega a 250px. Quem faz caber é--ring-card. Em 390px, commin(44vw,200px), o anel tem 205px de meia-largura contra 195px de meia-tela: os laterais são cortados por 10 a 45px conforme o ângulo, e isso é a sangria — o motivo de o palco ser irmão do.wrape o recorte ser da seção. -
--ring-focusé inerte no preview, e existe mesmo assim. Os placeholders são exatamente 3:4 e o card também, entãoobject-fit:covernão corta nada e mudar oobject-positionnão faz diferença nenhuma. O gancho existe para a foto do cliente, que nunca chega em 3:4 exato. O padrão é50% 42%, que privilegia o terço de cima. -
Math.roundno raio não é cosmético.translateZfracionário faz o navegador reamostrar a textura do card, e o texto sai borrado mesmo com o anel parado. O raio é arredondado para inteiro antes de virar--ring-radius. -
offsetWidthe nãogetBoundingClientRectpara medir o card. O card está transformado em 3D: o rect devolve a caixa projetada — menor, e diferente a cada ângulo. Medir por ele faria o raio encolher a cadaresizeaté o anel implodir.offsetWidthignora transform e devolve a largura de layout, que é a que a conta pede. -
O
backdrop-filterda legenda não custou nada de medível. Oito legendas comblur(3px)dentro de uma cena que gira: 4,17 ms/quadro contra 4,20 ms sem — razão 0,99, dentro do ruído (Chrome headless, rasterização por software; o número absoluto não vale, a razão vale). O custo do anel está no compositor da cena, não no véu. O mesmo teste com ofilter:blur(0px)que o reveal do core deixaria no palco deu 1% de diferença — o motivo de removê-lo com.ring .ring-stage.in{filter:none}não é desempenho, é quefilteré uma grouping property e não convém deixar uma dentro da cadeia de perspectiva esperando alguém movê-la para o lugar errado.
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.
- A geometria virou conta, não tabela. Cada card declara só
--ring-i. O passo (360/n) e o raio saem do script a partir da largura medida do card: o raio de um polígono regular denlados de larguraWé(W/2)/tan(π/n)— nesse raio os cards fecham como um tambor, encostados um no outro — e--ring-slackafasta na proporção dada. Com1.12, o vão entre vizinhos é 12% da largura do card, o suficiente para o olho ler cards em volta de um eixo e não uma parede curva. Consequência prática: trocar oito cards por seis ou dez é renumerar a lista, e mudar--ring-cardreajusta tudo sozinho. A implementação comum desse padrão crava raio e passo no CSS e obriga a recalcular à mão a cada mudança. - Cor, fonte, curva e raio, só token. Nenhuma cor crua: o véu de profundidade é
--darkcom opacidade variável, o gradiente da legenda érgba(var(--dark-rgb),…), o texto sobre a foto é--light(ilha escura, não escopo de tom), e o que está fora da foto — legenda do rodapé, contador, botões — lê--fg,--fg-2,--fg-mutede--rule-2do escopo da seção. - Ajuste por variável do prefixo.
--ring-card,--ring-h,--ring-air,--ring-perspective,--ring-slacke--ring-speed, todas com unidade e padrão documentados no cabeçalho do CSS. A velocidade é em graus por segundo, com a conta pordt— a versão comum desse padrão anda por quadro e roda o dobro numa tela de 120Hz. - Entrada pelos ganchos do core.
data-rv/data-de.ttl; nenhum observer próprio para "aparecer". O único observer do componente é o que faz o rAF dormir quando a dobra sai da tela — medido: com a seção acima da janela, o--ring-anglepara de mudar. - Sangria pela regra da casa. O palco é irmão do
.wrape filho direto da seção; o recorte éoverflow:hiddenna seção. Nenhuma margem negativa, nenhum100vw. -
Acessibilidade que o padrão comum não tem. O palco é
role="group"focável comaria-roledescription="carrossel"; setas giram um card,Home/Endvão ao primeiro e ao último pelo caminho curto (do card 3 ao 8 o anel anda três passos para trás, não cinco para a frente); os dois botões são<button>de verdade comaria-label; o<ul>temrole="list"para o leitor de tela anunciar "8 itens" — a informação que um anel esconde de quem não vê a metade de trás; e a regiãoaria-livesó fala quando a pessoa pediu. Movimento reduzido desliga a rotação livre e a inércia, e encurta o encaixe para.5por quadro (~8 quadros): o gesto pedido continua funcionando, e nada anda sozinho — medido,--ring-anglefica em0.00degdepois de 120 quadros. -
Os dois botões são a primitiva
.btn-discdo core, e o markup traz as duas classes (class="btn-disc ring-btn"). A regra do disco estava copiada ao caractere em cinco pastas —midia/galeria-scroll-horizontal,midia/carrossel-anel-3d,midia/carrossel-foco-central,midia/pilha-swipeemidia/roda-de-cards— e foi promovida aocore/base.cssem 2026-09-03, como o §6 do contrato manda fazer já na terceira. Forma, cor, hover e o estado desabilitado vêm de lá; o parafuso é--disc-size. Não é.btn: aquela é pílula de texto, com padding horizontal fixo.