Uma fila de cards retrato que encaixa sempre com um deles no meio da tela. O card centrado está em tamanho e brilho cheios; os vizinhos aparecem menores e mais escuros, em proporção à distância que os separa do centro, sempre com um pedaço à mostra dos dois lados. A fila anda por arrasto de mouse, por gesto de toque, pelos dois botões do cabeçalho, pelos seis pontos do rodapé e pelas setas do teclado. Abaixo dela, a descrição do card ativo troca com blur, ao lado de um contador 02 / 06.
O olho não escolhe onde olhar: a escala e o brilho escolhem por ele, e o resto da fila fica visível como promessa em vez de sumir. É a forma para uma lista em que um item por vez merece atenção inteira mas os outros precisam continuar existindo — um cardápio de frentes de trabalho, uma linha de produtos, um conjunto de destinos. Uma grade comum daria os seis com o mesmo peso e obrigaria a pessoa a decidir sozinha por onde começar; uma lista vertical entregaria o primeiro e enterraria o sexto. Aqui a hierarquia é temporária e reversível: o item em foco é o que a pessoa acabou de escolher, e escolher outro custa um gesto.
Terceiro carrossel da categoria mídia e o primeiro com encaixe no centro — os outros dois alinham à coluna.
Quando usar
- Frentes de serviço, linhas de produto, destinos, planos: coisas em que os itens são pares entre si, cada um tem foto própria e uma frase que só faz sentido lida sozinha.
- Quando a lista tem 5 a 8 itens — poucos para uma página de índice, muitos para uma grade que caiba na tela sem virar miniatura.
- Quando cada item tem destino próprio. O card é um
<a>de verdade; se não houver para onde ir, a dobra promete uma página que não existe. - Quando a foto é argumento. O card é 4:5 e a foto ocupa tudo: se as imagens forem fracas ou genéricas, a escala e o brilho só vão amplificar isso.
- Como dobra de decisão, no meio da página: a pessoa já entendeu quem você é e agora precisa achar a porta dela.
Quando não usar
- Menos de 4 itens. Com três, os vizinhos cabem quase inteiros na tela e o encaixe deixa de ter função. Use
[listas/leque-paineis](../../listas/leque-paineis/ficha.md), que mostra tudo de uma vez e expande o escolhido. - Quando os itens precisam ser comparados lado a lado. Escurecer o vizinho é exatamente o contrário de comparar. Grade.
- Quando o item tem texto longo. Aqui cabem uma pílula de categoria, um título de até três linhas dentro da foto e uma frase de 90 a 140 caracteres embaixo. Item com parágrafo pede
[overlay/card-expansivel-fullscreen](../../overlay/card-expansivel-fullscreen/ficha.md), que abre o card em tela cheia. - Quando a lista é um portfólio de coisas que se reconhecem pela capa e valem em conjunto. Aí é
[midia/galeria-scroll-horizontal](../../midia/galeria-scroll-horizontal/ficha.md): alinhamento à coluna, cards iguais, sem hierarquia. - Quando não há para onde clicar e nada deve parar. Depoimento e logo atravessam a tela sozinhos —
[prova/carrossel-depoimentos](../../prova/carrossel-depoimentos/ficha.md)e[midia/carrossel-perfis](../../midia/carrossel-perfis/ficha.md)são esteiras, não carrosséis: não têm índice, ponto nem contador, porque não há posição a marcar. - Acima da dobra. Depende de um gesto lateral que ninguém faz antes de entender a página.
Contra os vizinhos
| Precisa de | Use |
|---|---|
| Um item por vez em foco, com os outros à vista e cada um com sua página | este |
| Muitos itens equivalentes, alinhados à coluna, sem hierarquia | midia/galeria-scroll-horizontal |
| Fila que não para e não se navega | midia/carrossel-perfis, prova/carrossel-depoimentos |
| Poucos itens, todos visíveis, o escolhido expandindo em largura | listas/leque-paineis |
| Card que vira diálogo de tela cheia com texto longo | overlay/card-expansivel-fullscreen |
| Três blocos fixos com texto dentro da foto, sem movimento | conteudo/pilares-texto-na-foto |
galeria-scroll-horizontal e este componente parecem a mesma coisa e são opostos na intenção: lá todos os cards têm o mesmo peso e a ordem é a única hierarquia; aqui existe um item ativo, e é dele que a dobra fala.
Padrão editorial
| Slot | Regra |
|---|---|
Olho (.eyebrow) |
1 a 3 palavras. Nomeia a lista: "Soluções", "Frentes", "Onde atuamos". |
Título (.sec-title) |
1 linha, uma palavra em <em>. Precisa dizer que existem várias portas: "Uma frente para cada momento". |
Pílula de categoria (.pill.focus-tag) |
2 a 4 palavras, sempre do mesmo conjunto fechado. É o nome do serviço; se cada card inventar a própria classificação, a pílula deixa de classificar. Acima de 30 caracteres a pílula quebra em duas linhas no desktop — no celular ela já perde a borda de propósito (ver gotchas). |
Título do card (.focus-title) |
4 a 8 palavras, até 3 linhas em 15ch. É a promessa, não o nome: "Carteira desenhada para o seu horizonte", não "Consultoria". O nome já está na pílula acima. |
Legenda do rodapé (.focus-note) |
90 a 140 caracteres, uma ou duas frases. É o que o título do card não cabe: o mecanismo, o entregável, a ressalva. Fora dessa faixa a barra de pontos passa a subir e descer entre um card e outro. |
alt de cada foto |
Descreve a foto, não o serviço — a pílula e o título já estão dentro da imagem e seriam anunciados duas vezes. |
href de cada card |
A página do serviço. Um card que leva a lugar nenhum é pior que um card a menos. |
| Rótulo dos pontos | aria-label="Ir para o card N", com o N batendo com o contador visível. Repetir o título do serviço ali seria eco: a lista já anuncia os seis itens e cada card tem nome. |
Quantidade: 5 a 8. Com 4 a fila mal sai do lugar em tela larga. Acima de 8, os pontos viram uma régua ilegível e a pessoa perde a noção de onde está — e aí o componente certo é uma página de índice.
Escreva as seis legendas juntas, em paralelo. Elas ocupam o mesmo lugar e se substituem: se uma tem 92 caracteres e a outra 140, a troca muda a altura do bloco e a barra de pontos pula. O min-height do CSS reserva três linhas para segurar isso, mas ele foi calibrado nessa faixa de comprimento.
Especificação das imagens
| Proporção | retrato, recortada em 4:5 pelo object-fit:cover |
| Mínimo | 1040×1300 — o card chega a 520px de largura e a foto ainda faz scale(1.05) sob o ponteiro |
| Recorte | o assunto tem de sobreviver a corte no topo e na base: 3:4 entrando em 4:5 perde cerca de 7% da altura; 2:3 perde 17% |
| Peso | até 200 KB cada. São 5 a 8 na mesma dobra, e as três primeiras carregam de saída |
| Tom | as fotos vão aparecer lado a lado com brilhos diferentes (100% no centro, 55% nos vizinhos). Escolha imagens de exposição parecida entre si — uma foto já escura vira mancha preta quando é a vizinha |
| Base | os últimos 30% da altura ficam sob o véu da legenda. Não deixe rosto nem assunto ali |
Por que ph-* e não ph-wide-* no preview. A caixa é 4:5 retrato. Os ph-* são 3:4 e perdem 7% da altura no recorte — perda realista. Os ph-wide-* são 16:9 e perderiam mais de metade da largura: o preview mostraria um enquadramento que ninguém entregaria de verdade.
Como usar
<link rel="stylesheet" href="estilo.css">
<script src="script.js" defer></script>
Cole markup.html, estilo.css e script.js. Depende do core (tokens, base, motion) e de mais nada.
A conta que centraliza — e por que não é o --inset dos irmãos
galeria-scroll-horizontal e carrossel-depoimentos sangram até a borda e alinham o primeiro item à coluna, com um recuo que refaz a conta do .wrap. Aqui isso não serve: o encaixe é no centro, e o card ativo precisa poder chegar ao meio da tela. O primeiro e o último só chegam lá se houver meia tela de sobra antes e depois deles:
padding-inline:max(0px,calc(50% - var(--focus-card) / 2));
Metade da seção menos metade do card. O 50% é da seção: o trilho tem de continuar filho direto dela. Medido em 1440×900, com --focus-card em 520px: o recuo dá 452,5px de cada lado, o scrollWidth fica em 4155px e o scrollLeft máximo em 2730px — exatamente o valor necessário para centralizar o último card (2731px, 1px de arredondamento). O Chrome conta o padding do fim no scrollWidth; se algum motor não contar, o último card para antes do centro e o sintoma é o botão "próximo" nunca desabilitar. Por isso o disabled dos botões é decidido pelo índice do card ativo, e não pelo scrollLeft.
scroll-padding não é necessário aqui, e isso é diferente do irmão que alinha ao início. Com scroll-snap-align:center o snapport é a caixa de padding inteira, e o centro dela já é o centro do trilho. Medido: o ponto onde o encaixe para e o alvo do scrollIntoView batem com menos de 1px de diferença; o valor computado de scroll-padding continua auto.
O que o script escreve — e o que ele não escreve
Nenhum transform sai do JavaScript. Num rAF preso ao scroll do trilho, o script lê a caixa de cada card, calcula k = |centro do card − centro do trilho| / passo, limita a 1, e escreve --focus-k no <li>. A escala e o brilho são duas linhas de CSS:
transform:scale(calc(1 - var(--focus-k,0) * var(--focus-scale)));
filter:brightness(calc(1 - var(--focus-k,0) * var(--focus-dim)));
O padrão do var() é 0, e essa escolha tem consequência: sem JavaScript, a fila fica inteira em tamanho e brilho cheios — um carrossel de encaixe comum, legível. Com 1 como padrão (o mais óbvio), a dobra sem script apareceria toda encolhida e escura.
O passo vem medido do DOM (rect[1].left − rect[0].left), não da soma de --focus-card com --focus-gap: assim o componente não precisa saber em que unidade o tema escreveu os dois.
O acesso por teclado
| Camada | O quê |
|---|---|
| Trilho focável | tabindex="0" no <ul>. Região que rola tem de ser alcançável pelo teclado, e é onde as setas moram |
| Card focável de graça | cada card é um <a> de verdade. Tab percorre os seis, Enter abre, :focus-visible acende a foto igual ao ponteiro |
| Setas | ← e → andam de card em card, Home e End vão às pontas. Cima e baixo não são capturados: quem aperta seta para baixo quer descer a página |
| Foco centraliza | chegando por Tab, o focusin traz o card focado para o centro — senão o navegador o encosta na borda e o encaixe o puxa de volta em seguida |
| Pontos e botões | <button> de verdade. Os pontos marcam o ativo com aria-current="true", que é também o seletor do estilo: uma fonte de verdade só |
Medido: End leva o scrollLeft a 2730 (o máximo), o contador a 06, o ponto 6 a aria-current e o botão "próximo" a disabled. Focar o quarto card por script deixa o trilho em 1638 contra um alvo de 1639.
Ganchos
| Gancho | O quê |
|---|---|
[data-focus] |
a raiz. O script aceita vários carrosséis na mesma página |
[data-focus-track] |
o <ul> que rola. Filho direto da seção, obrigatoriamente |
[data-focus-prev] / [data-focus-next] |
os dois discos; disabled nas pontas |
[data-focus-notes] |
o contêiner aria-live das legendas |
[data-focus-dots] / [data-focus-now] |
a barra de pontos e o número do contador |
--focus-card |
largura do card; a altura sai do aspect-ratio:4/5 |
--focus-gap |
vão entre cards; entra na conta da fatia do vizinho |
--focus-scale |
quanto o vizinho encolhe (.14 padrão, .08 no celular, .05 sob movimento reduzido) |
--focus-dim |
quanto o vizinho escurece (.45 padrão) |
--focus-dur |
ritmo do zoom da foto sob o ponteiro (.55s) |
--focus-k |
escrita pelo script, por card. Não escreva na mão |
.is-free / .is-dragging / .is-swap |
encaixe suspenso · mão fechada no trilho · meio caminho da troca da legenda |
Variações
Ver componente.json. As duas que mais mudam a leitura: --focus-card decide quantos cards aparecem, e --focus-dim decide se a dobra é um foco ou uma vitrine. Em seção clara, considere --focus-dim:.25 — escurecer muito uma foto clara lê como foto queimada, não como foto fora de foco.
Gotchas
-
Os dois botões são a primitiva
.btn-discdo core, e o markup traz as duas classes (class="btn-disc focus-btn"). A regra do disco estava copiada ao caractere em cinco pastas e foi promovida aocore/base.cssem 2026-09-03, como o §6 do contrato manda fazer já na terceira. Desta pasta sobra uma linha só,pointer-events:auto, porque a faixa de navegação inteira épointer-events:nonepara não cobrir o trilho que passa por baixo. -
Sem suspender o encaixe, o arrasto simplesmente não existe. Com
scroll-snap-type:x mandatory, escrevertrilho.scrollLeft = 300devolve546no mesmo quadro: o motor puxa de volta ao ponto de parada mais próximo. Medido. É por isso que opointerdownpõe.is-free(scroll-snap-type:none) antes de qualquer coisa. Quem "consertar" isso tirando a classe vai ver o trilho tremer sob o dedo e não vai achar a causa no console. -
Devolver o encaixe fora de um ponto de parada custa um salto seco de 246px. Também medido: parado a 300 e devolvendo o
scroll-snap-type, o trilho pula para 546 instantaneamente, sem animação. Por isso o.is-freenão sai nopointerup: ele sobrevive até o deslize de volta assentar (sete quadros sem oscrollLeftmudar). Já parados no ponto certo, o custo do retorno é 0px — medido. E se o deslize foi interrompido por um gesto novo e a fila parou torta, o script endireita antes de devolver, com teto de duas tentativas. -
setPointerCapturenopointerdownmata os seis links. Este é o gotcha mais caro do componente. A captura redireciona os eventos de ponteiro para quem capturou, e oclickque nasce deles vai junto: medido, com a captura tomada nopointerdowno clique num card cai em.focus-track; sem captura, cai em.focus-img, dentro do<a>. Nenhum erro no console, nenhum sintoma além de "clicar no card não faz nada". A saída é adiar a captura para depois do limiar de 6px — quando já é arrasto e o clique não deve mesmo acontecer. Depois do conserto: gestos de 0, 3 e 6px abrem o link; de 9 e 400px, não. Omidia/carrossel-perfiscaptura nopointerdowne não sofre disso porque os cards dele não são link — ele trata o clique num ouvinte do próprio trilho. -
transition:transformno card é tentador e está errado. Com--focus-kreescrito a cada quadro do scroll, a transição é reiniciada a cada quadro e o card persegue o valor com atraso. Medido comtransition:transform .2sdurante um deslize de três cards: a escala renderizada chega a 0,111 de distância do que o--focus-kpede — numa amplitude total de 0,14, ou seja, 79% do efeito ficando para trás. Sem transição o erro é 0,0000. A suavidade não falta: quem é contínuo é o scroll. -
filter:brightnessno card NÃO cega obackdrop-filterda legenda. A dúvida é legítima —filtercria uma raiz de backdrop, e o desfoque de trás passa a enxergar só o que está dentro dela. Só que a<img>está dentro do card, e é ela que a legenda precisa desfocar. Medido comparando o mesmo recorte de 240×90 sobre a legenda: com o desfoque ligado o PNG sai com 34,1 KB, desligado com 36,3 KB, e os pixels diferem. Funciona. Mas a ordem importa: se um dia o véu subir para um ancestral do card, ou se ofilterfor para o<li>por fora do.focus-cap, a raiz muda e o resultado também. -
Escrever
--focus-kem 6 cards por quadro custa 0,018ms. Medido em 300 iterações: 6getBoundingClientRectmais 6setPropertydão 0,018ms por quadro; o mesmo laço trocando uma classe dá 0,013ms. A diferença de 5µs é 0,03% de um quadro de 16,7ms — e a classe só entregaria dois degraus (perto/longe), não a rampa contínua que faz o card do meio crescer enquanto chega. O que protege de verdade não é a escolha, é o limiar: o script só reescreve quando okmudou mais de.002, senão o recálculo de estilo dos seis cards aconteceria a cada quadro por nada. -
aria-livena legenda só funciona porque ela é escrita quando o ativo MUDA. As seis frases são seis<p>de verdade, cinco com[hidden]; o script troca qual aparece. Se a troca fosse feita no laço do scroll, o leitor de tela receberia uma mudança de conteúdo por quadro — sessenta anúncios por segundo. E há uma segunda trava: a troca é adiada 150ms e osetTimeoutanterior é cancelado, então um arrasto rápido por seis cards produz um anúncio, não seis. -
O
.focus-notenão tem regra dedisplay, e isso é de propósito. Declarardisplay:blockali venceria o[hidden]da folha do navegador e as seis frases apareceriam empilhadas. É a mesma armadilha que o.hgal-nav[hidden]documenta do outro lado — lá a saída foi declarardisplay:noneexplicitamente, aqui é simplesmente não declarar nada. -
A caixa da navegação cobre o título inteiro, e por isso tem
pointer-events:none. Os botões ficam na mesma célula de grid do cabeçalho, os dois como.wrap, para que cada um pouse exatamente sobre a coluna sem precisar saber a largura do outro. A caixa de cima é invisível mas ocupa a largura toda: sempointer-events:nonenela (eautonos botões), a metade direita do título ficaria sob um retângulo que engole clique e seleção de texto. -
A seção é
display:gridpor causa do celular, não do desktop. Os botões precisam ficar à direita do título em tela larga e embaixo de tudo no telefone. Cabeçalho e rodapé são caixas diferentes, entãoorderde flex não alcança — grid alcança, comgrid-row:4no bloco de largura. Se alguém trocar o grid por bloco, o desktop continua parecendo certo e o celular perde os botões dentro do cabeçalho. -
A fatia do vizinho que aparece é
(largura do trilho − card) / 2 − vão, e ainda encolhe pela escala. Medido em 390px com o valor "óbvio" de76vw: sobravam 21px do próximo card, escuros, que se leem como um risco na borda e não como promessa. Com68vwe o vão em 12px sobram cerca de 40px — um card cortado, reconhecível. Custa 30px de largura do card ativo. Sempre que mexer em--focus-cardou--focus-gapno celular, refaça essa conta antes de olhar para o resultado. -
A pílula perde a borda no celular, e isso não é enfeite.
.pillé uma forma: só se sustenta em uma linha. "Consultoria de investimentos" tem 28 caracteres e não cabe na largura do card de telefone — a borda quebra em duas e vira um retângulo torto. Abaixo de 760px o rótulo abre mão da pílula e vira uma linha de rótulo, que quebra sem ficar feia. Encurtar o conteúdo para caber na forma seria o caminho errado. -
Sob movimento reduzido a escala encolhe, não morre.
--focus-scalecai de.14para.05(e de.08para.04no celular); o brilho fica inteiro, porque escurecer não é deslocamento. Zerar tudo deixaria seis cards idênticos e a dobra pararia de comunicar a única coisa que ela comunica. É a mesma decisão dogaleria-scroll-horizontal, onde o zoom do hover cai de 1.06 para 1.02 em vez de sumir. O que o script desliga é outra coisa: o deslocamento programado passa abehavior:'auto'(medido: o botão move a fila em um quadro, 9 a 17ms) e a legenda troca sem transição. -
Os cards fora da tela só revelam quando entram nela.
data-rvusaIntersectionObserver, que respeita o recorte do contêiner que rola: medido no carregamento, os cards 3 a 6 estão emopacity:0e entram com fade e blur quando a pessoa os traz. É comportamento desejado e o mesmo do irmão — mas quem esperava todos prontos de saída vai achar que travou. Se a página do cliente for pesada, tirardata-rvdos<li>é uma troca legítima: cada[data-rv].indeixa umfilter:blur(0)permanente no item, e isso é um contexto de composição por card. -
Um resíduo de 0,5px sobra depois do encaixe, e é inofensivo. Com o trilho em 1425px (largura ímpar, por causa da barra de rolagem da página), o centro cai em 712,5 e o card para em 712 ou 713. O
kdo card ativo fica em0.002em vez de0.000— escala renderizada de 0,99972. Não tente corrigir arredondando oscrollLeft: o ganho é invisível e o custo é uma escrita de layout por quadro.
Histórico
| Projeto | Onde | Data | O quê |
|---|---|---|---|
| autoral | construído do zero na biblioteca | 2026-09-03 | — |
Origem. O padrão — fila de cards grandes com um deles em foco no centro e os vizinhos menores e apagados dos dois lados — é recorrente em sites premium de produto: as vitrines da Apple e da Nike, os templates de carrossel do Framer, boa parte dos vencedores de Awwwards na categoria e-commerce. Não há referência única, e nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O que essas implementações têm em comum, e que aqui não veio junto, é o motor: quase todas movem a fila com transform escrito por JavaScript quadro a quadro, com inércia própria, e por isso perdem o gesto nativo de toque, o encaixe do sistema operacional e a barra de rolagem de quem usa trackpad.
Decisões de tradução para o contrato. O motor virou scroll-snap nativo com scroll-snap-align:center: o navegador rola, encaixa e trata o gesto, e a fila funciona antes de o script existir. O script ficou reduzido a medir a distância de cada card ao centro e escrever --focus-k (0 a 1) por item — a escala e o escurecimento são duas linhas de CSS lendo essa variável, com --focus-scale e --focus-dim como os dois parafusos públicos. O padrão do var() é 0, e não 1, para que a dobra sem JavaScript apareça inteira em tamanho cheio. Toda cor virou token (--light sobre a foto, --dark-rgb no véu, --accent no ponto ativo, --rule-2 na borda dos botões), a curva virou --ease, o canto virou --radius-media e o rótulo de categoria virou a primitiva .pill do core, retonalizada para a ilha escura pelo mesmo seletor duplo que o galeria-scroll-horizontal usa. O alinhamento, ao contrário dos dois irmãos que sangram, é ao centro e não à coluna: padding-inline:calc(50% - card/2) substitui o --inset e é o que permite ao primeiro e ao último card chegarem ao meio da tela.
A acessibilidade é a parte que o padrão comum quase nunca tem: trilho focável com ←, →, Home e End; focusin que centraliza o card alcançado por Tab; pontos que são <button> com aria-label e aria-current="true" — o mesmo atributo servindo de estado para o leitor de tela e de seletor para o estilo; legenda em aria-live="polite" escrita só quando o card ativo muda, com debounce de 150ms; contador marcado aria-hidden para não ecoar o que os pontos já dizem; role="list" no <ul>, porque o reset do core tira o list-style e sem ele o Safari deixa de anunciar "lista de 6 itens". E a captura do ponteiro adiada até depois do limiar de 6px, sem o que o clique nos seis links morre em silêncio.
Calibragem no primeiro uso. Espere ajustar --focus-card e --focus-dim — são as duas decisões que dependem do acervo de fotos do cliente: quanto o card ativo ocupa, e quanto os vizinhos apagam.