Layouts matemáticos com sibling-index() e sibling-count()
Aquele efeito de cascata em que os cards de um grid vão aparecendo um depois do outro parece simples, mas nunca foi. Ou se escrevia um loop em Sass cuspindo regras :nth-child(), ou se recorria ao JavaScript para injetar estilos inline.
Com sibling-index() e sibling-count(), o mesmo efeito cabe em uma linha de CSS (e funciona tanto para 5 itens quanto para 5.000).
As opções sempre foram as mesmas. Digamos que se queira delays escalonados de animação CSS em uma lista de 10 itens. Uma alternativa era escrever um loop em Sass que gerava uma dúzia de regras :nth-child(), cada uma com um --index fixo para aquela posição específica:
/* Uma regra por item. Torça para a lista nunca crescer. */li:nth-child(1) { --idx: 1; }li:nth-child(2) { --idx: 2; }li:nth-child(3) { --idx: 3; }/* ... mais oito dessas ... */li:nth-child(10) { --idx: 10; }
li { animation-delay: calc(var(--idx) * 100ms);}10 itens, 10 regras. E se a lista crescer para 50? Estabelece-se um limite e reza-se para dar certo, ou monta-se um loop em Sass que gera centenas de seletores em tempo de build.
Pessoas como Roman Komarov já elaboraram estratégias O(√N) (coisa legitimamente engenhosa), mas ainda assim o resultado são 63 regras para cobrir 1.023 elementos.
A outra alternativa é iterar pelos elementos em JavaScript e definir estilos inline. style="--index: 3", ali mesmo no DOM. Até que funciona bem.
Também espalha preocupações de layout pelos scripts e quebra silenciosamente seis meses depois, quando alguém refatora o componente sem perceber que o CSS depende de uma variável injetada por JavaScript.
As duas abordagens incomodam pelo mesmo motivo: você está contando ao browser algo que ele já sabe. Foi o browser que construiu a árvore do DOM. Ele sabe qual elemento é o terceiro filho. O dado está lá. O CSS só não conseguia acessá-lo.
Bom, agora consegue:
li { animation-delay: calc(sibling-index() * 100ms);}Uma linha. Funciona para 5 itens ou 5.000. Sem event listeners, sem mutation observers, sem re-renders.
sibling-index() e sibling-count() fazem parte da especificação CSS Values and Units Module Level 5 (seção 9, para quem gosta de ler drafts do W3C por diversão). A proposta foi aprovada na issue #4559 do CSSWG depois de bastante discussão. As funções não recebem argumentos: basta usá-las.
sibling-index()devolve a posição do elemento entre os filhos do pai, começando em 1. O primeiro filho retorna1. O quinto retorna5. Ela conta apenas nós de elemento: nós de texto, comentários e espaços em branco são invisíveis para ela.sibling-count()devolve o total de elementos filhos que o pai tem. Basicamente o equivalente em CSS aoelement.parentElement.children.lengthdo JavaScript, mas disponível na folha de estilos.
As duas funções resolvem para <integer>, não para <string>: é um número de verdade. Isso significa que você pode jogá-las dentro de calc(), min(), max(), round(), mod() e até em funções trigonométricas como sin() e cos().
Quando você escreve calc(sibling-index() * 100ms), o CSS cuida da coerção de tipo e devolve um valor <time> válido. Sem truques. Compare com counter(), que retorna uma string e só pode viver dentro de content em pseudo-elementos. É outra coisa completamente diferente.
Um esclarecimento que costuma confundir: :nth-child() é um seletor. Ele escolhe elementos, mas não produz um valor. Não é possível escrever calc(:nth-child() * 10px), porque isso não é CSS válido.
sibling-index() faz o oposto: ela vive dentro das declarações e devolve um número com o qual se pode calcular. Elas resolvem problemas diferentes e, até agora, o :nth-child() vinha sendo amarrado com fita adesiva em um papel para o qual nunca foi projetado.
Padrões que valem ser copiados
Depois que cai a ficha de que são apenas números inteiros, as ideias vêm rápido.
Escalonamento reverso
Quer que o último item anime primeiro? Subtraia:
.card { animation: fade-in 0.4s ease both; animation-delay: calc((sibling-count() - sibling-index()) * 80ms);}O último filho recebe (N - N) * 80ms = 0ms e dispara na hora. O primeiro filho recebe (N - 1) * 80ms. A animação começa no instante em que a página carrega, em vez de fazer uma pausa esquisita antes.
Larguras iguais automáticas
Pare de contar filhos manualmente para definir porcentagens:
.tab { width: calc(100% / sibling-count());}Cinco abas? 20% cada uma. Adicionou uma sexta? 16,66%. Removeu duas? 25%. Sem media queries, sem resize observers, sem JavaScript nenhum.
Mesmo assim, é fácil imaginar um cenário em que itens demais deixem as abas estreitas demais. Nesse ponto, talvez seja melhor partir para outra solução, como um Flexbox com quebra de linha.
Distribuição de matizes
Espalhe cores uniformemente pela roda cromática:
.swatch { background-color: hsl( calc((360deg / sibling-count()) * sibling-index()) 70% 50% );}Três itens recebem matizes a 120° de distância. Doze itens recebem incrementos de 30°. A paleta se adapta ao que estiver no DOM, o tipo de coisa para a qual normalmente se recorreria a uma biblioteca de cores em JavaScript.
Menus circulares
Distribuir itens em círculo costumava significar calcular seno e cosseno em JavaScript. O CSS agora tem sin() e cos() nativamente (Juan Diego Rodríguez escreveu um ótimo passo a passo prático dessas funções no CSS-Tricks) e, combinado com a contagem na árvore, tudo se resume a CSS puro:
.radial-item { --angle: calc((360deg / sibling-count()) * sibling-index()); --radius: 120px;
position: absolute; left: calc(50% + var(--radius) * cos(var(--angle))); top: calc(50% + var(--radius) * sin(var(--angle))); transform: rotate(calc(var(--angle) * -1));}Seis itens? Hexágono. Oito? Octógono. Adicione ou remova itens e o layout se recalcula. Sem JavaScript computando coordenadas.
Empilhamento com z-index
Montando um leque de cards? Uma linha:
.card { z-index: calc(sibling-count() - sibling-index());}O primeiro card fica no topo da pilha e o último recebe 0. Inverta a conta se quiser o contrário.
As pegadinhas
Vale passar por elas uma a uma, porque não são óbvias a partir da especificação.
Escopo do Shadow DOM
sibling-index() e sibling-count() operam sobre a árvore do DOM, não sobre a árvore visual achatada. Essa distinção será mais importante se você for trabalhar com Web Components.
Digamos que exista um custom element com este shadow DOM:
<section> <slot></slot> <div class="internal"></div></section>Se você estilizar .internal com sibling-index(), o retorno é 2. Sempre. Mesmo que o <slot> projete 300 elementos.
A função vê dois filhos de <section> na shadow tree: o <slot> e a div .internal. O conteúdo projetado do light DOM não existe do ponto de vista da contagem.
Há também uma questão de segurança envolvida. Se uma folha de estilos do light DOM tentar alcançar o interior de um componente via ::part() e usar sibling-index(), o browser retorna 0. Zero na lata.
É uma barreira deliberada para impedir que CSS externo sonde a estrutura interna de componentes de terceiros e, na avaliação do autor, é a decisão correta.
Pseudo-elementos não contam
::before e ::after não são irmãos. Eles não aparecem em sibling-count() e não têm um sibling-index() próprio.
Mas, e esta é a parte que economiza uma sessão de debug, você pode usar essas funções dentro de declarações de pseudo-elementos. Quando você escreve #target::before { width: calc(sibling-index() * 10px); }, a função é avaliada em relação a #target, não ao pseudo-elemento.
O pseudo-elemento não é um nó de verdade, então a função volta ao elemento que o originou. A mesma história vale para ::slotted(*)::before, que verifica o índice do elemento projetado no light DOM.
display: none continua contando
Essa pega quase todo mundo de surpresa… Elementos com display: none desaparecem da árvore de layout; não ocupam espaço; leitores de tela não os enxergam. Mas eles continuam no DOM.
Como sibling-index() lê a árvore do DOM, e não a de layout, elementos escondidos entram na contagem:
<ul> <!-- sibling-index() = 1 --> <li>Maçã</li> <!-- sibling-index() = 2, invisível --> <li style="display:none">Banana</li> <!-- sibling-index() = 3, NÃO 2 --> <li>Cereja</li></ul>Cereja é 3, não 2. A banana escondida continua ocupando o lugar dela.
Isso não faz diferença para a maioria dos layouts. Mas, se você estiver construindo algo como um filtro de busca que esconde os itens não correspondentes com display: none, suas animações escalonadas e layouts circulares vão ficar com lacunas.
Os itens visíveis mantêm os índices originais, que deixam de ser sequenciais. Para qualquer coisa que dependa de uma contagem contínua (menus radiais, larguras proporcionais), será preciso remover de fato os nós filtrados do DOM em vez de apenas escondê-los. Ou recorrer a índices gerenciados por JavaScript.
Custom properties são avaliadas na hora
Este é sutil. Se você tentar centralizar o índice no elemento pai:
.parent { --idx: sibling-index();}Esse --idx é resolvido ali mesmo, em .parent. Ele pega o próprio índice do pai entre os irmãos, congela naquele número e todos os filhos herdam esse único valor fixo. Todos os filhos recebem o mesmo número. Quase com certeza não é o que a maioria quer ou espera.
A correção é simples: coloque a função nos elementos que precisam dela.
.child { --idx: sibling-index(); animation-delay: calc(var(--idx) * 100ms);}O CSSWG já discutiu adicionar um inherits: declaration ao @property que poderia, em teoria, resolver isso. Para quem nunca usou @property, ele permite definir o tipo, o valor inicial e o comportamento de herança de uma custom property, bem mais controle do que uma --variavel crua.
Mas a ideia do inherits: declaration ainda está em discussão inicial no CSSWG, sem estar incorporada a nenhum draft da especificação. Pode levar anos até chegar, ou pode nunca chegar.
Mesmo com o @property de hoje, não existe mecanismo para dizer “não avalie ainda, espere pelo filho”. Então, por enquanto, aplique diretamente.
Performance em escala
Alterar o DOM (isto é, adicionar, remover ou reordenar filhos) dispara recálculo de estilo para os irmãos afetados. O browser resolve isso durante a fase de Cascata, antes de Layout e Paint, o que é mais rápido que a abordagem antiga de iterar em JavaScript e estampar estilos inline.
Mas existe um custo real se você forçar a barra. Inserir um elemento no início de um container com 10.000 filhos obriga o navegador a recalcular o índice de todos os 10.000 elementos seguintes.
Para coisas normais (navegação, grids de cards, barras de abas), você nunca vai notar. Para um ticker de ações ao vivo ou um feed de scroll infinito com milhares de nós em constante rotação, continue com índices gerenciados por JavaScript dentro da janela de virtualização.
Essas funções são rápidas, mas não são de custo zero.
Suporte dos navegadores
Na data de publicação deste artigo, Chrome/Edge 138 já traziam essas funções em versões estáveis (junho de 2025), e o Safari 26.2 veio em seguida.
O Firefox ainda não as lançou no canal estável, mas a posição da Mozilla sobre a especificação é positiva e o trabalho de implementação está em andamento, acompanhado na issue #1953973 do Bugzilla.
Confira o Can I Use antes de colocar em produção.

O suporte a sibling-index() e sibling-count() no Can I Use em agosto de 2026.
Chrome e Safari juntos cobrem algo em torno de 75% a 80% do tráfego global. É uma maioria sólida, mas a ausência do Firefox significa que ainda é necessário um fallback.
Para colocar no ar hoje, o @supports é seu amigo:
/* Baseline que funciona em todo lugar */.item { width: 25%; animation-delay: 0ms;}
/* Melhoria progressiva onde há suporte */@supports (z-index: sibling-index()) { .item { width: calc(100% / sibling-count()); animation-delay: calc(sibling-index() * 80ms); }}Fallback estático para o Firefox, layout matemático para todos os outros. Ninguém recebe uma página quebrada.
Notas sobre acessibilidade
Isso precisa ser dito, porque é fácil se empolgar e esquecer: essas funções são puramente visuais. Elas mudam a aparência das coisas. Não mudam o que as coisas significam.
Se você usar a matemática do sibling-index() para reordenar visualmente uma lista (via order ou posicionamento no grid), o leitor de tela continua lendo o DOM na ordem do código. A ordem de tabulação pelo teclado também segue o DOM.
Layout visual e estrutura semântica vão se contradizer, e isso é uma falha de acessibilidade.
Para componentes interativos como data grids, menus radiais ou listboxes customizadas que se apoiam na contagem na árvore para o layout, ainda é necessário JavaScript para sincronizar os atributos ARIA. aria-posinset e aria-setsize não têm a menor ideia do que o CSS está calculando.
Se o seu CSS diz “este é visualmente o item 3 de 7” mas o ARIA diz outra coisa (ou nada), pessoas que usam tecnologias assistivas recebem uma experiência ruim.
No lado do debug, versões recentes do Chrome DevTools permitem inspecionar os valores computados de sibling-index() e sibling-count() diretamente no painel Elements, o que ajuda quando a matemática não faz o que se espera.

O que vem por aí
A especificação atual conta apenas todos os elementos irmãos. Mas o CSSWG documentou uma extensão planejada na issue #9572: um argumento of <selector>, equivalente ao que o :nth-child() já suporta.
Algo como sibling-index(of .active) permitiria contar apenas os irmãos que correspondem a um seletor específico. Um elemento que é o oitavo filho no geral, mas o terceiro filho .active, retornaria 3.
Em interfaces dinâmicas com filtros ou alternância de visibilidade, isso manteria o índice sequencial sem exigir manipulação do DOM.
Também houve discussão no CSSWG em torno das funções children-count() e descendant-count(). A primeira informaria quantos filhos um elemento tem (útil para layouts controlados pelo pai) e a segunda contaria todos os descendentes recursivamente.
As duas ainda estão em estágio de proposta, mas completariam a história da contagem na árvore: sibling-index() e sibling-count() dão a visão horizontal (onde estou entre meus pares?), enquanto children-count() e descendant-count() dariam a visão vertical (o que existe abaixo de mim?).
E aquela sensação mencionada no começo, de escrever dez regras :nth-child() para uma animação escalonada e desconfiar que se está deixando passar algo óbvio? Não estava. O óbvio simplesmente ainda não existia.