Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
ConceitoAvançadocódigo testado

Stacking context: como o CSS empilha as camadas

O que cria um contexto de empilhamento, por que z-index 9999 pode perder para z-index 1 e como ler a árvore de camadas de uma página inteira.

Rodolfo Mori16 min de leitura

Stacking context é o agrupamento que o navegador cria para decidir quem fica na frente de quem. Dentro de um contexto, os z-index dos filhos se comparam entre si; de fora, o contexto inteiro vira uma peça só, e nenhum número de dentro atravessa a fronteira. É por isso que um z-index: 9999 perde, calado, para um z-index: 1.

Todos os exemplos deste artigo são da mesma loja: a Livraria Beira-Mar, com cabeçalho fixo, vitrine de livros, um balão de “últimas unidades”, um widget de avaliações de terceiros e um modal de “avise-me quando chegar”. Cada saída colada aqui saiu de uma execução real no Chromium 151, dirigido por Playwright, usando document.elementFromPoint como juiz: quem o navegador devolve naquele ponto é quem está pintado por cima.

A página não é um plano, é uma pilha de pilhas

Pense em folhas de acetato empilhadas. O navegador pinta uma folha de cada vez, de baixo para cima, e o que você vê é o resultado sobreposto. Um contexto de empilhamento é um maço dessas folhas grampeado: por dentro tem ordem própria, mas para o maço de cima ele é uma folha só, que entra na pilha inteira numa posição única.

O <html> cria o contexto raiz — toda página tem pelo menos um. A partir daí, certos elementos criam maços novos. Veja o caso mais comum de todos: o balão de estoque precisa aparecer por cima do cabeçalho fixo.

html
<style>
  body { margin: 0; font: 16px system-ui; }
  .cabecalho { position: fixed; top: 0; left: 0; right: 0; height: 56px;
               background: #1b2a4a; color: #fff; z-index: 2; }
  .catalogo  { position: relative; z-index: 1; margin-top: 40px;
               height: 300px; background: #eef2f7; }
  .estoque   { position: absolute; top: 0; left: 40px;
               width: 220px; height: 60px; background: gold; z-index: 9999; }
</style>

<div class="cabecalho" id="cabecalho">Livraria Beira-Mar</div>
<div class="catalogo" id="catalogo">
  <div class="estoque" id="estoque">Últimas 3 unidades</div>
</div>

O .estoque mora dentro do .catalogo e pede z-index: 9999. O cabeçalho pede 2. Nove mil ganha de dois, certo? O teste pergunta ao navegador quem ele pinta no ponto (150, 50), onde os dois se cruzam:

js
// base = a string com o HTML do bloco acima, inteiro, <style> junto
const casos = {
  '.catalogo { z-index: 1 }': '',                        // como está no CSS acima
  '.catalogo { z-index: auto }': '.catalogo { z-index: auto; }',
};

for (const [nome, css] of Object.entries(casos)) {
  await page.setContent(`${base}<style>${css}</style>`);
  const info = await page.evaluate(() => ({
    topo: document.elementFromPoint(150, 50).id,
    zCatalogo: getComputedStyle(document.getElementById('catalogo')).zIndex,
  }));
  console.log(
    `${nome.padEnd(28)} z-index do .catalogo=${info.zCatalogo.padEnd(5)}` +
      ` quem pinta em (150,50): #${info.topo}`,
  );
}
.catalogo { z-index: 1 } z-index do .catalogo=1 quem pinta em (150,50): #cabecalho .catalogo { z-index: auto } z-index do .catalogo=auto quem pinta em (150,50): #estoque

Uma linha de diferença, e o resultado inverte. O 9999 do balão nunca chegou a disputar com o 2 do cabeçalho: ele disputou apenas com os irmãos dele, dentro do .catalogo. Quem competiu lá em cima foi o .catalogo inteiro, com o 1 dele, contra o cabeçalho com 2.

z-index só compete dentro do próprio contexto

A regra cabe em duas frases, e vale a pena decorar as duas.

Primeira: position diferente de static mais um z-index que não seja auto cria um contexto de empilhamento — com uma exceção que eu meço daqui a pouco, o item de flex e o item de grid, que obedecem ao z-index mesmo estáticos. Segunda: dentro de um contexto, os filhos são ordenados pelo z-index deles; fora, o que vale é o z-index do contexto, não o dos filhos.

Foi exatamente isso que a saída mostrou. Com z-index: 1, o .catalogo virou um maço grampeado e afundou inteiro para a posição 1. Com z-index: auto, o .catalogo deixou de ser contexto, o balão passou a competir direto no contexto raiz e o 9999 finalmente valeu alguma coisa.

Isso também explica por que a cascata não ajuda aqui. Ganhar a briga de especificidade no CSS só garante que a sua declaração de z-index é a que vai valer — não garante que ela vai ser comparada com a do vizinho. São dois campeonatos diferentes, e o de empilhamento acontece depois.

As sete propriedades que criam um contexto sem avisar

position mais z-index é o caso que todo mundo conhece. O problema é a lista das outras — propriedades que ninguém escreve pensando em camada e que criam contexto do mesmo jeito.

Para medir, montei um teste com três elementos: uma .secao que recebe a propriedade sob suspeita e não tem z-index; uma filha .estoque com z-index: 9999; e uma irmã .promo com z-index: 1. Se a .secao criar contexto, o 9999 fica preso e a .promo vence.

html
<style>
  body { margin: 0; font: 16px system-ui; }
  .palco   { position: relative; z-index: 0; width: 500px; height: 300px; }
  .secao   { width: 400px; height: 200px; background: #eef2f7; }
  .estoque { position: relative; z-index: 9999; top: 20px; left: 20px;
             width: 260px; height: 80px; background: gold; }
  .promo   { position: absolute; z-index: 1; top: 40px; left: 60px;
             width: 260px; height: 80px; background: #ffb3b3; }
</style>

<div class="palco">
  <div class="secao" id="secao"><div class="estoque" id="estoque">Últimas 3 unidades</div></div>
  <div class="promo" id="promo">Frete grátis acima de R$ 99</div>
</div>
js
// base = a string com o HTML acima; cada candidata entra só na .secao
const candidatas = {
  'nada (referência)': '',
  'opacity: 0.99': 'opacity: 0.99;',
  'transform: translateZ(0)': 'transform: translateZ(0);',
  'will-change: top': 'will-change: top;',
  // …e as outras dezesseis, uma por linha da saída
};

console.log('propriedade aplicada em .secao          | topo em (100,60) | criou contexto?');
console.log('---------------------------------------|------------------|----------------');

for (const [nome, decl] of Object.entries(candidatas)) {
  await page.setContent(`${base}<style>.secao { ${decl} }</style>`);
  const topo = await page.evaluate(() => document.elementFromPoint(100, 60).id);
  console.log(
    `${nome.padEnd(38)} | ${('#' + topo).padEnd(16)} | ${topo === 'promo' ? 'SIM' : 'nao'}`,
  );
}
propriedade aplicada em .secao | topo em (100,60) | criou contexto? ---------------------------------------|------------------|---------------- nada (referência) | #estoque | nao opacity: 0.99 | #promo | SIM transform: translateZ(0) | #promo | SIM filter: blur(0px) | #promo | SIM backdrop-filter: blur(2px) | #promo | SIM perspective: 500px | #promo | SIM clip-path: inset(0) | #promo | SIM mix-blend-mode: multiply | #promo | SIM isolation: isolate | #promo | SIM will-change: transform | #promo | SIM will-change: opacity | #promo | SIM will-change: top | #estoque | nao contain: paint | #promo | SIM contain: layout | #promo | SIM container-type: inline-size | #estoque | nao position: fixed (sem z-index) | #promo | SIM position: sticky (sem z-index) | #promo | SIM position: relative (sem z-index) | #estoque | nao overflow: hidden | #estoque | nao border-radius: 12px | #estoque | nao

Vale reparar nas três últimas linhas tanto quanto nas de cima. overflow: hidden não cria contexto — ele recorta, e recortar não é empilhar. position: relative sozinho também não. E position: fixed cria contexto mesmo sem z-index nenhum, o que faz de todo cabeçalho fixo uma fronteira.

Rodei uma segunda rodada, com os casos de borda e mais três linhas de controle:

container-type: inline-size | #estoque | nao container-type: size | #estoque | nao contain: strict | #promo | SIM contain: content | #promo | SIM contain: size | #estoque | nao contain: style | #estoque | nao opacity: 1 | #estoque | nao filter: none | #estoque | nao mask-image: linear-gradient(#000,#000) | #promo | SIM view-transition-name: capa | #promo | SIM position: relative + z-index: 0 | #promo | SIM

container-type, apesar de existir justamente para conter o elemento, não criou contexto de empilhamento no Chromium 151 — usar container query não custa uma camada nova. Já contain depende do valor: layout, paint, strict e content criam; size e style não.

As três linhas de controle confirmam o resto por negação. opacity: 1 e filter: none estão lá e não criam nada: o gatilho não é escrever a propriedade, é o valor computado sair diferente do inicial. E position: relative com z-index: 0 cria — guarde essa, porque ela volta quando eu falar da ordem de pintura.

A exceção que não é propriedade nenhuma: o display do pai

Falta um criador de contexto que não está no CSS do elemento, e sim no do pai dele. Em item de flex e item de grid, um z-index diferente de auto cria contexto mesmo com position: static — é a regra do Flexbox e do Grid, não a do posicionamento. Reaproveitei o mesmo base, mantive a .secao estática o tempo todo e só mexi no display do .palco. A .promo subiu para 5000, abaixo do 9999 da filha e acima de qualquer número que a .secao carregue, para que #promo no topo signifique exatamente uma coisa: o 9999 ficou preso.

js
console.log('display do .palco | z-index da .secao | topo em (100,60) | criou contexto?');
console.log('------------------|-------------------|------------------|----------------');

for (const display of ['block', 'flex', 'grid']) {
  for (const zi of ['auto', '0', '2']) {
    await page.setContent(
      `${base}<style>.palco { display: ${display} } .secao { z-index: ${zi} }` +
        ` .promo { z-index: 5000 }</style>`,
    );
    const r = await page.evaluate(() => {
      const s = getComputedStyle(document.getElementById('secao'));
      return { z: s.zIndex, topo: document.elementFromPoint(100, 60).id };
    });
    console.log(
      `${display.padEnd(17)} | ${r.z.padEnd(17)} | ${('#' + r.topo).padEnd(16)} | ${r.topo === 'promo' ? 'SIM' : 'nao'}`,
    );
  }
}
display do .palco | z-index da .secao | topo em (100,60) | criou contexto? ------------------|-------------------|------------------|---------------- block | auto | #estoque | nao block | 0 | #estoque | nao block | 2 | #estoque | nao flex | auto | #estoque | nao flex | 0 | #promo | SIM flex | 2 | #promo | SIM grid | auto | #estoque | nao grid | 0 | #promo | SIM grid | 2 | #promo | SIM

Com o pai em block, o z-index da .secao é ignorado — ela é estática, e z-index em elemento não posicionado não vale nada. Basta o pai virar flex ou grid e o mesmo z-index: 0 passa a grampear o maço. Na prática isso quer dizer que trocar um display: block por display: flex num contêiner qualquer pode criar contextos em filhos que já tinham z-index escrito e ignorado até então. É uma mudança de uma palavra que reorganiza camadas.

Se for para guardar sete nomes na cabeça, guarde estes — são os que aparecem em CSS escrito por gente que não estava pensando em z-index:

propriedade por que costuma estar ali cria contexto
opacity menor que 1 fade, estado desabilitado, hover suave sim
transform animação, centralização, hack de GPU sim
filter blur, grayscale, drop-shadow sim
backdrop-filter vidro fosco atrás de modal sim
mix-blend-mode selo, marca-d’água, arte sim
isolation: isolate escrito de propósito, para isolar sim
will-change otimização preventiva depende da propriedade citada

opacity: 0.99 e o contexto criado por engano

opacity é a mais traiçoeira da lista, porque 0.99 é visualmente igual a 1. Ninguém olha para a tela e desconfia. Fiz uma varredura de valores para achar onde o navegador vira a chave:

js
for (const v of ['1', '0.999999', '0.99999999', '0.99', '0.5']) {
  await page.setContent(`${base}<style>.secao { opacity: ${v}; }</style>`);
  const info = await page.evaluate(() => ({
    computado: getComputedStyle(document.getElementById('secao')).opacity,
    topo: document.elementFromPoint(100, 60).id,
  }));
  console.log(
    `opacity: ${v.padEnd(12)} computado: ${info.computado.padEnd(10)}` +
      ` topo: #${info.topo.padEnd(9)} contexto: ${info.topo === 'promo' ? 'SIM' : 'nao'}`,
  );
}
opacity: 1 computado: 1 topo: #estoque contexto: nao opacity: 0.999999 computado: 0.999999 topo: #promo contexto: SIM opacity: 0.99999999 computado: 1 topo: #estoque contexto: nao opacity: 0.99 computado: 0.99 topo: #promo contexto: SIM opacity: 0.5 computado: 0.5 topo: #promo contexto: SIM

A linha do meio é a mais interessante do artigo. 0.999999 cria contexto; 0.99999999, com dois noves a mais, não cria — porque o Chromium arredonda o valor computado para 1, e a regra olha para o valor computado, não para o que você escreveu. O gatilho não é “é diferente de 1 no seu arquivo”, é “é menor que 1 depois de computado”.

Pior: o contexto pode aparecer e sumir enquanto a pessoa usa a página. Um card com transition: opacity e um :hover { opacity: .92 } cria contexto só durante o hover. Montei o card .livro com o balão #balao (z-index: 9999) dentro dele e a .promo (z-index: 1) como irmã do card, e medi com o mouse do próprio navegador:

js
// pagina = o card .livro com transition: opacity e .livro:hover { opacity: .92 }
const ler = () => ({
  opacidade: getComputedStyle(document.getElementById('livro')).opacity,
  topo: document.elementFromPoint(100, 60).id,
});

await page.setContent(pagina);
console.log('antes do hover  ->', await page.evaluate(ler));
await page.mouse.move(30, 150); // ponto dentro do .livro
await page.waitForTimeout(300);
console.log('durante o hover ->', await page.evaluate(ler));
await page.mouse.move(480, 290);
await page.waitForTimeout(300);
console.log('depois do hover ->', await page.evaluate(ler));
antes do hover -> { opacidade: '1', topo: 'balao' } durante o hover -> { opacidade: '0.92', topo: 'promo' } depois do hover -> { opacidade: '1', topo: 'balao' }

O balão está por cima, some quando o mouse entra no card, e volta quando sai. É o tipo de bug que a pessoa não consegue reproduzir com o DevTools aberto, e que o time fecha como “não consegui reproduzir”.

transform, filter e will-change: os culpados silenciosos

transform empata com opacity em número de vítimas, mas por outro motivo: ele quase nunca é escrito para transformar coisa alguma. transform: translate3d(0, 0, 0) e transform: translateZ(0) são hacks antigos para empurrar o elemento para a GPU. Continuam em milhares de bases de código, em regras genéricas do tipo .carrossel { transform: translate3d(0,0,0) }, e cada uma delas grampeia um maço de folhas no meio do caminho.

O mesmo vale para animação de entrada. Se você anima um card com transition e transform, esse card é um contexto — durante a animação e depois dela, enquanto a propriedade final não for none.

will-change é o caso mais sutil, e a saída explica melhor que qualquer parágrafo: will-change: transform e will-change: opacity criaram contexto, mas will-change: top não criou. A regra é composta — will-change só cria contexto quando a propriedade citada seria capaz de criar por conta própria. Ele avisa o navegador “prepare-se para animar isto”, e o navegador se prepara promovendo o elemento a camada. Como top não cria contexto, avisar sobre top também não cria.

A ordem de pintura dentro de um contexto, camada por camada

Até aqui só falamos de quem é contexto. Falta a outra metade: dentro de um contexto, o navegador pinta em sete camadas, nesta ordem, de baixo para cima.

  1. O fundo e as bordas do próprio elemento que criou o contexto.
  2. Os filhos com z-index negativo, do mais negativo para o menos.
  3. Os blocos no fluxo normal, não posicionados.
  4. Os elementos flutuantes.
  5. O conteúdo em linha — texto, span, inline-block.
  6. Os filhos posicionados com z-index: auto ou z-index: 0.
  7. Os filhos com z-index positivo, do menor para o maior.

Dá para verificar essa lista sem confiar em ninguém. Empilhei seis elementos exatamente no mesmo retângulo, um de cada camada, e chamei document.elementsFromPoint, que devolve os elementos do topo para o fundo:

html
<style>
  body { margin: 0; font: 16px system-ui; }
  .vitrine { position: relative; z-index: 0; width: 320px; height: 90px;
             margin: 30px; background: #dfe9f3; }
  .vitrine > * { width: 300px; height: 60px; }
  .fundo-negativo { position: relative; z-index: -1; background: #f6c; }
  .bloco-no-fluxo { margin-top: -60px; background: #cfc; }
  .flutuante      { float: left; margin-top: -60px; background: #fc9; }
  .em-linha       { display: inline-block; margin-top: -60px; background: #9cf; }
  .posicionada    { position: relative; margin-top: -60px; background: #ccf; }
  .z-positivo     { position: relative; z-index: 1; margin-top: -60px; background: #ff9; }
</style>

<div class="vitrine" id="vitrine">
  <div class="fundo-negativo" id="1-z-negativo">z-index: -1</div>
  <div class="bloco-no-fluxo" id="3-bloco-no-fluxo">bloco no fluxo</div>
  <div class="flutuante"      id="4-float">float</div>
  <span class="em-linha"      id="5-inline">inline-block</span>
  <div class="posicionada"    id="6-z-auto">position: relative (z-index: auto)</div>
  <div class="z-positivo"     id="7-z-positivo">z-index: 1</div>
</div>
js
const retangulos = [...document.querySelectorAll('.vitrine > *')].map((el) => {
  const r = el.getBoundingClientRect();
  return `${el.id.padEnd(18)} x:${Math.round(r.left)}..${Math.round(r.right)}` +
    `  y:${Math.round(r.top)}..${Math.round(r.bottom)}`;
});
console.log('retângulos de cada camada:');
for (const linha of retangulos) console.log('  ' + linha);

const pilha = document
  .elementsFromPoint(100, 60)
  .map((el) => el.id || el.tagName.toLowerCase());
console.log('\nelementsFromPoint(100, 60), do topo para o fundo:');
pilha.forEach((id, i) => console.log(`  ${i + 1}. ${id}`));

Primeiro confirmei que os seis ocupam o mesmo retângulo, para o teste não medir geometria por engano:

retângulos de cada camada: 1-z-negativo x:30..330 y:30..90 3-bloco-no-fluxo x:30..330 y:30..90 4-float x:30..330 y:30..90 5-inline x:30..330 y:30..90 6-z-auto x:30..330 y:30..90 7-z-positivo x:30..330 y:30..90

E então a ordem que o navegador realmente pintou:

elementsFromPoint(100, 60), do topo para o fundo: 1. 7-z-positivo 2. 6-z-auto 3. 5-inline 4. 4-float 5. 3-bloco-no-fluxo 6. 1-z-negativo 7. vitrine 8. body 9. html

Bate com a lista, item por item, de trás para frente. E repare no detalhe da penúltima posição: o filho com z-index: -1 ficou acima da .vitrine, que é o elemento que criou o contexto. Isso é a camada 2 em ação — z-index negativo afunda o filho até o fundo do maço, mas não além do fundo do maço. Se a .vitrine não criasse contexto, esse mesmo filho afundaria até sumir atrás do fundo do avô.

Duas consequências práticas dessa lista. Um texto simples numa <div> fica na frente de um <div> de fundo que veio antes, sem ninguém escrever z-index — é a camada 5 ganhando da 3. E um elemento posicionado com z-index: 0 não é igual a um com z-index: auto para efeito de contexto: os dois pintam na camada 6, mas só o 0 cria contexto novo.

O diagrama: a árvore de contextos da Livraria Beira-Mar

Junte tudo numa página real e o desenho fica assim. Cada retângulo é um contexto; o número ao lado é o z-index com que ele entra na disputa do contexto pai.

contexto raiz — <html> #vitrine — transform entra como z-index: auto (ordem 0) #card__selo — z-index: 9999 #rv-widget — opacity: .99 entra como z-index: auto (ordem 0) #rv-widget__nota — 2147483647 preso: nunca sai deste retângulo #cabecalho — fixed, z-index: 100 #modal__vidro — z-index: 999 #modal — z-index: 1000 ordem de pintura no contexto raiz 0 (vitrine) → 100 → 999 → 1000 os números 9999 e 2147483647 nunca entram na disputa da linha de cima

A leitura é direta: a única disputa que existe no contexto raiz é entre 0, 100, 999 e 1000. Os dois números gigantes vivem em outro andar do prédio.

Mapeando os contextos reais de uma página com um script

Ler o CSS procurando as sete propriedades funciona numa página de exemplo. Numa página de verdade, com CSS de terceiros, é inviável. Melhor perguntar ao próprio navegador, depois de a página estar montada — o mesmo motivo pelo qual você inspeciona o DOM em vez de reler o HTML quando quer saber como o navegador montou a árvore.

O detector abaixo aplica as condições uma a uma sobre o valor computado:

js
const GATILHOS_WILL_CHANGE = new Set([
  'opacity', 'transform', 'translate', 'rotate', 'scale', 'filter',
  'backdrop-filter', 'perspective', 'clip-path', 'mask', 'mask-image',
  'mix-blend-mode', 'isolation', 'contain', 'view-transition-name', 'z-index',
]);
const kebab = (p) => p.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());

function motivos(el) {
  const s = getComputedStyle(el);
  const pai = el.parentElement && getComputedStyle(el.parentElement);
  const lista = [];
  const z = s.zIndex === 'auto' ? '' : ` + z-index: ${s.zIndex}`;

  if (el === document.documentElement) lista.push('elemento raiz <html>');
  if (s.position === 'fixed' || s.position === 'sticky') lista.push(`position: ${s.position}${z}`);
  else if (s.zIndex !== 'auto') {
    if (s.position !== 'static') lista.push(`position: ${s.position}${z}`);
    else if (pai && /flex|grid/.test(pai.display)) lista.push(`item de ${pai.display}${z}`);
  }
  if (parseFloat(s.opacity) < 1) lista.push(`opacity: ${s.opacity}`);
  if (s.mixBlendMode !== 'normal') lista.push(`mix-blend-mode: ${s.mixBlendMode}`);
  if (s.isolation === 'isolate') lista.push('isolation: isolate');
  for (const prop of ['transform', 'rotate', 'scale', 'translate', 'filter',
                      'backdropFilter', 'perspective', 'clipPath', 'maskImage',
                      'offsetPath']) {
    if (s[prop] && s[prop] !== 'none') lista.push(`${kebab(prop)}: ${s[prop]}`);
  }
  if (/\b(layout|paint|strict|content)\b/.test(s.contain)) lista.push(`contain: ${s.contain}`);
  for (const p of s.willChange.split(',').map((x) => x.trim())) {
    if (GATILHOS_WILL_CHANGE.has(p)) lista.push(`will-change: ${p}`);
  }
  return lista;
}

E a varredura desce a árvore aumentando a indentação a cada contexto encontrado, para o resultado sair já no formato de árvore:

js
const achados = [];
(function visitar(el, profundidade) {
  const lista = motivos(el);
  if (lista.length) {
    achados.push({ profundidade, alvo: el.id ? '#' + el.id : el.tagName.toLowerCase(), motivos: lista });
    profundidade += 1;
  }
  for (const filho of el.children) visitar(filho, profundidade);
})(document.documentElement, 0);

Rodando na página da livraria, com o CSS da casa e o CSS do widget de avaliações que veio de fora:

html → elemento raiz <html> #cabecalho → position: fixed + z-index: 100 #vitrine → transform: matrix(1, 0, 0, 1, 0, 0) #card__selo → position: absolute + z-index: 9999 #comprar → will-change: transform #rv-widget → opacity: 0.99 #rv-widget__nota → position: relative + z-index: 2147483647 #rv-badge → mix-blend-mode: multiply #modal__vidro → position: fixed + z-index: 999 | backdrop-filter: blur(4px) #modal → position: fixed + z-index: 1000 10 contextos de empilhamento na página.

Dez contextos numa página com vinte linhas de markup. Repare no #rv-widget: o CSS de terceiros trouxe um opacity: .99 que ninguém da equipe escreveu, e ele prende tudo que está dentro. A nota 4,8 de 5 pede z-index: 2147483647, o maior inteiro possível, e mesmo assim:

#rv-widget__nota x:40..860 y:266..290 z:2147483647 #modal x:90..810 y:120..480 z:1000 #modal__vidro x:0..900 y:0..600 z:999 elementsFromPoint(60, 278), do topo para o fundo: 1. modal__vidro 2. rv-widget__nota 3. rv-widget 4. vitrine 5. body 6. html

O overlay de vidro, com 999, cobre o elemento com o maior z-index que o CSS aceita. Não é bug do navegador — é a #vitrine (transform) e o #rv-widget (opacity) grampeando o maço duas vezes antes de a nota ter qualquer chance.

Como consertar sem sair aumentando o z-index

Com a árvore na mão, a correção deixa de ser tentativa e erro. São três caminhos, e a escolha depende de quem é dono do CSS problemático.

situação o que fazer custo
o contexto no meio é seu e é acidental remover a propriedade (opacity: 1, transform: none) zero, se ninguém dependia do efeito
o contexto no meio é necessário subir o elemento na árvore, para fora dele mexe no HTML e às vezes no componente
o CSS é de terceiros, você não pode tocar tirar o elemento da árvore com a top layer precisa de dialog ou popover

A terceira linha é a novidade que resolve o caso mais desagradável. Um elemento promovido à top layer — via <dialog> com showModal() ou via atributo popover — é pintado fora de toda a árvore de contextos, acima de tudo, sem z-index nenhum. Testei os dois balões lado a lado dentro do mesmo widget grampeado:

html
<style>
  body { margin: 0; font: 16px system-ui; }
  .vitrine { transform: translate3d(0, 0, 0); }
  .rv-widget { opacity: .99; }
  .balao { position: absolute; top: 40px; left: 40px; z-index: 9999;
           width: 240px; height: 60px; background: gold; }
  #balao-popover { position: fixed; top: 40px; left: 320px; margin: 0; border: 0;
                   width: 240px; height: 60px; background: gold; }
  .modal { position: fixed; top: 20px; left: 20px; width: 600px; height: 200px;
           background: #bcd; z-index: 1000; }
</style>

<div class="vitrine">
  <div class="rv-widget">
    <div class="balao" id="balao">balão comum, z-index: 9999</div>
    <div class="balao" id="balao-popover" popover>balão no popover, sem z-index</div>
  </div>
</div>
<div class="modal" id="modal">modal, z-index: 1000</div>
js
await page.evaluate(() => document.getElementById('balao-popover').showPopover());
const r = await page.evaluate(() => ({
  comum: document.elementsFromPoint(100, 60).map((e) => e.id || e.tagName.toLowerCase()),
  popover: document.elementsFromPoint(380, 60).map((e) => e.id || e.tagName.toLowerCase()),
}));
console.log('sobre o balão comum   (100,60):', r.comum.join(' > '));
console.log('sobre o balão popover (380,60):', r.popover.join(' > '));

// controle: sem o transform e sem o opacity, o 9999 volta a valer
await page.addStyleTag({ content: '.vitrine { transform: none } .rv-widget { opacity: 1 }' });
const controle = await page.evaluate(() =>
  document.elementsFromPoint(100, 60).map((e) => e.id || e.tagName.toLowerCase()),
);
console.log('controle, sem os dois  (100,60):', controle.join(' > '));
sobre o balão comum (100,60): modal > balao > body > html sobre o balão popover (380,60): balao-popover > modal > body > html controle, sem os dois (100,60): balao > modal > body > html

O balão comum, com 9999, fica atrás do modal. O balão com popover, sem nenhum z-index, fica na frente. E a linha de controle fecha a prova: removendo o transform da vitrine e o opacity do widget, o balão comum volta a vencer — confirmando que o problema eram os dois contextos, e não o número.

O <dialog> aberto com showModal() faz o mesmo, e a medição deixa claro que não é z-index disfarçado. Troquei o balão do popover por um <dialog> no mesmo lugar da árvore, dentro dos dois contextos, e li o valor computado dele:

js
const pilhaEm = (x, y) =>
  page.evaluate(
    ([x, y]) =>
      document.elementsFromPoint(x, y).map((e) => e.id || e.tagName.toLowerCase()).join(' > '),
    [x, y],
  );

console.log('dialog fechado        (380,60):', await pilhaEm(380, 60));
await page.evaluate(() => document.getElementById('balao-dialog').showModal());
console.log('dialog.showModal()    (380,60):', await pilhaEm(380, 60));
console.log(
  'z-index computado do dialog:',
  await page.evaluate(() => getComputedStyle(document.getElementById('balao-dialog')).zIndex),
);
dialog fechado (380,60): modal > body > html dialog.showModal() (380,60): balao-dialog > html z-index computado do dialog: auto

Fechado, o dialog nem aparece na pilha. Aberto com showModal(), ele passa a ser o primeiro elemento do ponto, à frente do modal de z-index: 1000 — com o z-index computado em auto, ou seja, sem número nenhum. É esse o sentido de “fora da árvore”: a top layer não disputa, ela é pintada depois de todo mundo.

Sobra um quarto caminho, que é preventivo em vez de corretivo: isolation: isolate. Ele cria um contexto de propósito na raiz de cada componente, para que o z-index interno do componente seja um assunto local e nunca escape para o resto da página. Custa uma camada e devolve previsibilidade — o oposto do z-index: 9999 que só empurra o problema para o próximo componente.

O que vem depois

O caminho natural daqui é entender bem os valores de position, já que fixed e sticky criam contexto sozinhos e relative não — position no CSS trata cada um deles com medição. Se o que trouxe você até aqui foi um elemento que não sobe, o roteiro de diagnóstico está em z-index não funciona, com as cinco causas separadas por sintoma.

E se você está montando a base de CSS por inteiro, o guia de CSS mostra a ordem de estudo, e a trilha de CSS traz as lições na sequência. Deixe o script de varredura salvo como snippet no DevTools: no dia em que algo não subir, ele responde em um segundo o que a leitura do CSS levaria uma tarde para achar.

  • css
  • z-index
  • stacking context
  • opacity
  • transform

Perguntas frequentes

Existe um z-index máximo no CSS?
Na prática sim, porque o navegador guarda o valor num inteiro de 32 bits. Escrevi 9999999999 no teste e o Chromium 151 devolveu 2147483647 no valor computado. Mas o teto não resolve disputa nenhuma fora do próprio contexto — na varredura deste artigo um elemento com esse valor fica atrás de um overlay com z-index 999.
z-index negativo funciona em qualquer elemento?
Em elemento posicionado, e também em item de flex ou de grid, que obedecem ao z-index mesmo com position static. O efeito é ficar atrás do conteúdo do contexto pai, mas ainda na frente do fundo dele. Se o pai não criar contexto, o elemento afunda mais ainda e some atrás do fundo do avô.
Como sei, no DevTools, se um elemento cria contexto?
No painel Elements do Chrome, a aba Computed marca com o rótulo "creates stacking context" quando você passa o mouse sobre a propriedade responsável. O script de varredura deste artigo faz a mesma checagem na página inteira de uma vez.
Um contexto de empilhamento atrapalha a performance?
Ao contrário: contexto é a unidade que o compositor usa para agrupar o que pode ser pintado junto. O problema nunca é ter contextos, é ter contextos criados sem intenção, no meio do caminho de um elemento que precisava subir.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Chromium 151.0.7922.34 (Playwright 1.62.1, Node 24.16.0), e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Stacking context — developer.mozilla.org
  2. CSS 2.2 — Appendix E: Elaborate description of Stacking Contexts — w3.org
  3. CSS Positioned Layout Level 3 — Painting order — w3.org
  4. MDN — will-change — developer.mozilla.org

Continue por aqui