z-index não funciona: 5 causas e a correção de cada
O elemento continua atrás mesmo com z-index alto: as cinco causas reais, como identificar cada uma no DevTools e a correção testada para todas.
Se o z-index: 9999 não jogou o elemento para a frente, o número quase nunca é
o problema. Ou o z-index não está sendo lido, porque o elemento é
position: static, ou ele está sendo lido dentro de uma caixa fechada — um
contexto de empilhamento criado por algum ancestral, que prende o filho lá
dentro por mais alto que seja o valor.
São cinco causas, e elas se distinguem por dois testes de dez segundos. Este artigo reproduz cada uma num HTML mínimo e mostra a resposta do navegador.
Os exemplos vêm todos do mesmo site: a Cantina Bella Massa, uma pizzaria com delivery. O cabeçalho fixo, o menu de sabores que abre, a barra com o total do pedido, o botão flutuante do WhatsApp, a faixa de cookies e o modal de montar a pizza. São exatamente os elementos que brigam por camada em qualquer projeto real.
O teste que separa “não foi aplicado” de “foi aplicado e perdeu”
O erro parece um só, mas são dois estados bem diferentes, e cada um tem uma correção diferente. Duas perguntas resolvem a triagem.
Primeira: o navegador leu o valor? getComputedStyle responde. Se voltar
auto, o problema é anterior ao empilhamento — a regra perdeu na cascata ou o
valor é inválido.
Segunda: quem está por cima no ponto do conflito?
document.elementFromPoint(x, y) devolve o elemento que receberia o clique
naquela coordenada. É o juiz: ele não opina, ele responde quem venceu.
const botao = document.querySelector('.botao-whatsapp');
const estilo = getComputedStyle(botao);
console.log(estilo.position, estilo.zIndex);
console.log(document.elementFromPoint(450, 500));Todas as saídas deste artigo vieram de um Chromium 151.0.7922.34 dirigido pelo
Playwright 1.62.1, com viewport de 900×600. Cada caso é uma página mínima
carregada por script; para ficar legível, o script imprime a tag e as classes do
elemento que o elementFromPoint devolveu, em vez do nó inteiro. Quando o teste
é de cor, o número sai de um screenshot de 1×1 pixel no ponto medido. As páginas
de teste usam * { box-sizing: border-box } e body { margin: 0 }, e os blocos de
CSS abaixo mostram só o que decide o empilhamento — cor e espaçamento ficaram de
fora para não atrapalhar a leitura.
Causa 1: o elemento é static e o z-index nem chega a ser lido
O botão do WhatsApp fica no fluxo normal da página. A faixa de cookies é
position: fixed, colada embaixo. As duas se sobrepõem, e o botão tem
z-index: 9999.
<a class="botao-whatsapp" href="#">Pedir no WhatsApp</a>
<div class="faixa-cookies">Usamos cookies para lembrar seu endereço.</div>.botao-whatsapp {
position: static; /* o padrão — e depois relative, na segunda rodada */
z-index: 9999;
display: block;
width: 240px;
margin: 470px auto 0;
padding: 16px;
background: #16a34a;
color: #fff;
}
.faixa-cookies {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 120px;
padding: 24px;
background: #1f2937;
color: #fff;
}Rodei a mesma página duas vezes, mudando uma linha só: o position do botão.
Repare na coluna do meio: o z-index computado é 9999 nos dois casos. O
valor chegou, foi calculado e está guardado no elemento. Ele simplesmente não é
usado na hora de pintar, porque z-index só entra na conta de elemento
posicionado. É por isso que o painel Computed do DevTools engana: ele mostra
9999 com toda a confiança e o elemento continua atrás.
A correção é uma palavra: position: relative. Ela não move nada de lugar — o
elemento fica onde estava, sem top nem left — e passa a valer o número que
já estava escrito. Se você ainda não tem firmeza em qual valor de position
usar, a lição de position no CSS trata cada um deles.
A exceção que ninguém lembra: item de flex e de grid
Filho direto de um container flex ou grid respeita z-index sem precisar de
position. Coloquei o mesmo botão dentro de um .rodape, passei a margem para
o pai e troquei só o display dele:
.rodape {
display: flex; /* e depois grid, e depois block */
margin-top: 470px;
}O mesmo CSS no filho, três resultados diferentes por causa do pai. Guarde isso:
quando o z-index funciona sem position e você não entende por quê, olhe o
display do container.
Causa 2: o pai criou um contexto e prendeu o filho lá dentro
Agora o menu de sabores, que abre a partir do cabeçalho e precisa passar por cima da barra de total do pedido.
.cabecalho {
position: relative;
z-index: 1;
height: 64px;
background: #fff;
}
.menu-sabores {
position: absolute;
top: 64px;
left: 24px;
width: 260px;
height: 220px;
z-index: 9999;
margin: 0;
padding: 12px;
list-style: none;
background: #fef3c7;
}
.barra-total {
position: relative;
z-index: 2;
margin-top: 120px;
height: 72px;
background: #111827;
}O menu tem 9999. A barra tem 2. E a barra ganha:
O que mudou entre as três linhas foi o z-index do cabeçalho, não o do
menu. Quando um elemento é posicionado e tem z-index diferente de auto, ele
cria um contexto de empilhamento: uma caixa fechada. Os z-index dos filhos
passam a ser comparados só entre eles, ali dentro. Para o resto da página, o
cabeçalho inteiro é um bloco só, e o número que representa esse bloco é o 1.
Pense num prédio: 9999 é o número do apartamento, e 1 é o andar. O
apartamento 9999 do primeiro andar continua abaixo de qualquer coisa do segundo.
O mecanismo completo do stacking context
tem mais camadas que isso, mas essa imagem já resolve a maior parte dos casos.
Duas correções, e a escolha diz muito sobre o seu CSS:
- tirar o
z-index: 1do cabeçalho — ele deixa de criar contexto e o menu volta a disputar de igual para igual (linha 2 da saída); - subir o cabeçalho para
3— o contexto continua existindo e passa a ganhar como um todo (linha 3).
A segunda é quase sempre a certa. A primeira funciona hoje e volta a quebrar no dia em que alguém precisar do cabeçalho por cima de outra coisa.
Causa 3: opacity, transform ou filter num ancestral
Esta é a que faz gente desistir, porque o ancestral culpado não tem z-index
nenhum — ele nem parece ter a ver com camada. Uma animação de fade que deixou
opacity: 0.99, um transform: translateZ(0) posto para “melhorar a
performance”, um filter de brilho no hover do card.
.lista-cardapio {
position: relative;
height: 300px;
opacity: 0.99; /* sobrou de uma animação de entrada */
}Para saber quais propriedades realmente fazem isso, rodei a mesma página 23
vezes, trocando só a declaração do ancestral. O menu tem z-index: 9999 e o
concorrente tem 2: se o concorrente vencer, é porque o ancestral virou
contexto e prendeu o menu.
Três leituras que valem mais que a lista:
- O valor neutro salva.
opacity: 1,transform: noneefilter: nonenão criam nada.opacity: 0.99efilter: blur(0px)criam — e visualmente são idênticos aos neutros. Umblurde zero pixel muda o empilhamento da página inteira sem mudar um pixel na tela. transform: translateX(0)conta. Não é preciso mover nada. Qualquer valor diferente denonejá basta, e é assim que a lição de transition e transform vira causa de bug de camada num hover.will-changedepende do que você declara.will-change: transformcria contexto;will-change: colornão. Ele promete ao navegador que aquela propriedade vai mudar, e o navegador se prepara — inclusive criando o contexto que a propriedade criaria.
E tem um caso que nem precisa de propriedade extra: o próprio position cria
contexto sozinho em dois dos seus cinco valores.
.lista-cardapio {
position: static; /* e depois relative, absolute, fixed e sticky */
top: 0;
left: 0;
width: 900px;
height: 300px;
}fixed e sticky criam contexto sozinhos, sem z-index nenhum. Como
cabeçalho de site costuma ser um dos dois, esse é o ancestral culpado mais
comum de todos.
Causa 4: os dois elementos não são irmãos no mesmo contexto
Aqui os dois lados estão presos, cada um no seu contexto, e você está comparando
números que nunca se enfrentam. O modal de montar a pizza tem z-index: 100; o
cabeçalho tem 50. O modal deveria ganhar. Só que o modal mora dentro de
.conteudo (z-index: 1) e o cabeçalho mora dentro de .topo (z-index: 10).
<header class="topo"><nav class="cabecalho">Cantina Bella Massa</nav></header>
<main class="conteudo">
<div class="modal-pedido">Monte sua pizza</div>
</main>.topo { position: fixed; inset: 0 0 auto 0; height: 160px; z-index: 10; }
.cabecalho { position: relative; height: 160px; z-index: 50; }
.conteudo { position: relative; padding: 200px 24px; z-index: 1; }
.modal-pedido { position: fixed; inset: 0; z-index: 100; }Subi o z-index do modal quatro vezes, até o maior inteiro que o Chromium
aceita:
2147483647 perdeu para 50. E a última linha resolve o caso mexendo num
número que você nem estava olhando: .conteudo saiu de 1 para 20, e o modal
com o humilde 100 original passou a ganhar.
O diagnóstico desta causa é esse teste: suba o número e veja se muda alguma coisa. Se você multiplicou por mil e o resultado é idêntico, pare de mexer nesse elemento. O número que decide está em outro lugar da árvore.
Causa 5: o valor está certo e o problema é a ordem de pintura
Sem z-index nenhum, dois elementos posicionados ainda se empilham — pela ordem
no HTML. O card da pizza e o selo de promoção, os dois position: absolute,
nenhum com z-index:
.card-pizza {
position: absolute;
top: 60px;
left: 60px;
width: 320px;
height: 200px;
background: #fff;
}
.selo-promo {
position: absolute;
top: 160px;
left: 100px;
width: 120px;
height: 120px;
background: #dc2626;
}Quatro variações da mesma página, todas medidas no mesmo ponto de sobreposição,
(150, 180). Entre uma e outra mudei a ordem dos dois no HTML, o position do
card e o z-index do selo — nada mais. Na quarta o selo vira filho do card, e
por isso o top e o left dele passam a 100px e 40px: é a conta que
mantém o selo no mesmo lugar da tela agora que ele se posiciona a partir do
card, e não mais do documento.
Linha a linha:
- 1 e 2 — trocar a ordem dos dois no HTML inverteu o resultado. Empatados no
z-index, quem vem depois no documento pinta por cima. É por isso que mover umadivtrês linhas para baixo “conserta” o layout de um jeito que parece mágica. - 3 — o selo é posicionado e o card não. Mesmo aparecendo antes no HTML, o
selo ganha: elemento posicionado pinta depois de elemento no fluxo normal,
sempre, mesmo sem
z-index. - 4 —
z-index: -1mandou o selo para trás do fundo do card. Ele não sumiu: foi pintado antes dobackground: #fff. Tire o fundo do card e ele reaparece no mesmo lugar.
A quarta linha tem uma inversão que quase toda explicação de z-index erra, e
ela é fácil de medir. O selo só afunda porque o card não cria contexto de
empilhamento — position: absolute com z-index: auto não cria. Sem contexto
por perto, o negativo sobe até a raiz do documento e é pintado lá no fundo; o
card, que é um elemento posicionado, é pintado bem depois, por cima dele. Basta
dar um contexto ao card para o resultado virar do avesso:
.card-pizza { z-index: 0; } /* agora o card é o dono do contexto */Com o selo em z-index: -1 nas três rodadas, medi a cor do pixel em
(150, 180), onde os dois se cruzam. O selo é #dc2626 e o card é #fff,
então a cor diz quem ficou na frente:
Ou seja: o negativo só desaparece atrás do pai quando o pai não é dono de contexto nenhum. Dentro de um contexto, ele fica logo acima do fundo do dono.
O diagnóstico em três passos no DevTools
Passo 1 — o valor chegou? Selecione o elemento, vá em Computed e filtre por
z-index. Se aparecer auto mesmo com a regra escrita, o problema é cascata ou
valor inválido, e não empilhamento. Testei seis valores e li o computado de
cada um:
/* seis rodadas separadas, uma declaração por vez —
coladas no mesmo arquivo, só a última valeria */
.botao-carrinho { position: relative; z-index: 9999; }
.botao-carrinho { position: relative; z-index: 2147483647; }
.botao-carrinho { position: relative; z-index: 2147483648; }
.botao-carrinho { position: relative; z-index: 99999999999999; }
.botao-carrinho { position: relative; z-index: 9999.5; }
.botao-carrinho { position: relative; z-index: 9999px; }z-index aceita inteiro e nada mais. Decimal ou com unidade é declaração
inválida, o navegador descarta em silêncio e o valor volta para auto. Quando a
regra é válida mas apareceu riscada no painel Styles, aí é a
especificidade do seletor que perdeu.
Passo 2 — quem prende? Cole esta função no console e chame com o elemento teimoso. Ela sobe a árvore e diz qual ancestral cria contexto e por causa de quê:
function contextos(el) {
const motivo = (n) => {
if (n === document.documentElement) return 'raiz do documento';
const s = getComputedStyle(n);
if (s.position === 'fixed' || s.position === 'sticky') return 'position: ' + s.position;
if (s.zIndex !== 'auto' && s.position !== 'static') return 'position: ' + s.position + ' + z-index: ' + s.zIndex;
if (s.opacity !== '1') return 'opacity: ' + s.opacity;
if (s.transform !== 'none') return 'transform';
if (s.filter !== 'none') return 'filter';
if (s.backdropFilter !== 'none') return 'backdrop-filter';
if (s.mixBlendMode !== 'normal') return 'mix-blend-mode: ' + s.mixBlendMode;
if (s.isolation === 'isolate') return 'isolation: isolate';
if (s.willChange !== 'auto') return 'will-change: ' + s.willChange;
if (s.contain.includes('paint') || s.contain.includes('layout')) return 'contain: ' + s.contain;
if (s.perspective !== 'none') return 'perspective';
if (s.clipPath !== 'none') return 'clip-path';
if (s.maskImage !== 'none') return 'mask';
return null;
};
const linhas = [];
for (let n = el; n; n = n.parentElement) {
const nome = n.tagName.toLowerCase() + (n.className ? '.' + n.className.trim().split(/\s+/).join('.') : '');
const r = motivo(n);
linhas.push([nome, r ? 'SIM' : '-', r || '']);
}
const larg = Math.max(...linhas.map((l) => l[0].length), 8);
console.log('elemento'.padEnd(larg) + ' | cria contexto? | por causa de');
for (const [nome, sim, r] of linhas) console.log(nome.padEnd(larg) + ' | ' + sim.padEnd(14) + ' | ' + r);
}
contextos(document.querySelector('.menu-sabores'));Rodando numa página da Cantina em que o main tem z-index: 1 e um
transform: translateZ(0) sobrou no meio do caminho:
O primeiro SIM acima do seu elemento é a fronteira: dali para cima, o 9999
não vale mais nada. Aqui, section.lista-cardapio — e ninguém teria olhado para
um transform.
Passo 3 — compare com o concorrente. Rode a mesma função no elemento que está ganhando. Onde as duas listas se encontram é o ancestral comum, e é lá que a disputa acontece de verdade. Os dois números que importam são os dos filhos diretos desse ancestral.
A correção que quase sempre resolve: tirar o elemento do lugar
Quando o culpado é um transform de um componente de terceiro, ou um sticky
que você não pode remover, não adianta negociar com a árvore: tire o elemento
dela. Historicamente isso é o portal — mover o modal para o fim do <body>
com JavaScript, para que ele nasça fora de qualquer contexto.
Hoje o navegador tem uma solução melhor: a top layer, uma camada acima de
todos os contextos de empilhamento da página. dialog.showModal() e a API de
popover colocam o elemento lá, e z-index deixa de participar da conversa.
<div class="cabecalho-monstro">cabeçalho com z-index máximo</div>
<dialog class="modal-pedido">Monte sua pizza</dialog>
<div class="aviso-entrega" popover>Entregamos em 40 minutos</div>.cabecalho-monstro {
position: fixed;
inset: 0;
z-index: 2147483647;
}
.modal-pedido,
.aviso-entrega {
width: 400px;
height: 240px;
background: #fff;
}// três rodadas separadas: chamar show() e showModal() no mesmo dialog aberto
// lança InvalidStateError
document.querySelector('.modal-pedido').show(); // fluxo normal
document.querySelector('.modal-pedido').showModal(); // top layer
document.querySelector('.aviso-entrega').showPopover();O .cabecalho-monstro é position: fixed com z-index: 2147483647, o teto do
Chromium. O dialog e o popover não têm z-index nenhum:
O mesmo dialog, o mesmo CSS, dois métodos diferentes. Com show() ele entra
no fluxo e perde para o cabeçalho. Com showModal() ele vai para a top layer e
ganha de um z-index de dois bilhões sem declarar nada.
Isso tem preço: showModal() vem com fundo escurecido, foco preso dentro do
diálogo e fechamento pelo Esc. Para um modal é exatamente o que você quer. Para
um menu suspenso, o certo é o atributo popover, que vai para a mesma camada
sem prender o foco.
Por que z-index: 9999 é dívida e não solução
Toda vez que alguém escreve 9999, a próxima pessoa escreve 10000. Depois vem
o 99999, e um dia alguém tenta 99999999999999 — que, como a saída do passo 1
mostrou, o Chromium recorta no teto e transforma em 2147483647. A escada acaba,
e ela nunca respondeu à pergunta certa: qual camada é essa?
A saída é ter poucos valores, com nome, decididos uma vez. Esta é a escala que uso, com variáveis CSS num arquivo só:
:root {
--camada-base: 0;
--camada-cabecalho: 100;
--camada-menu: 200;
--camada-overlay: 300;
--camada-modal: 400;
--camada-aviso: 500;
}
.cabecalho { z-index: var(--camada-cabecalho); }
.menu-sabores { z-index: var(--camada-menu); }| camada | valor | quem mora aqui | por que esse degrau |
|---|---|---|---|
| base | 0 |
cards, seções, o conteúdo | o padrão; quase nada precisa declarar |
| cabeçalho | 100 |
cabeçalho fixo, barra de total | tem que passar pelo conteúdo, e só |
| menu | 200 |
menu de sabores, tooltip, autocomplete | nasce do cabeçalho e precisa cobrir ele |
| overlay | 300 |
fundo escuro do modal | cobre a página inteira, menos o modal |
| modal | 400 |
montar a pizza, confirmar pedido | acima do próprio overlay |
| aviso | 500 |
toast de “pedido enviado” | aparece por cima até do modal |
Os degraus de 100 existem para caber um caso não previsto no meio sem renumerar
tudo. E a regra que sustenta a escala: cada componente fecha o próprio
empilhamento com isolation: isolate. Com o componente isolado, os z-index
de dentro dele não vazam para a página, e você pode usar 1, 2 e 3 à
vontade lá dentro.
O que fazer agora
Abra o projeto onde o z-index está quebrado e rode os dois testes na ordem:
getComputedStyle primeiro, para saber se o valor existe, e a função
contextos() depois, para achar o ancestral que prende. Em cinco minutos você
sabe qual das cinco causas é a sua — e cada uma tem uma correção diferente,
nenhuma delas sendo aumentar o número.
Se o assunto ainda parece nebuloso, vale estudar o stacking context por dentro antes de voltar aqui. E se você está montando sua base de CSS agora, o guia completo de CSS mostra em que ordem cada peça entra.
Perguntas frequentes
O meu menu some quando o pai tem overflow hidden. É o mesmo problema?
O z-index aparece riscado no painel Styles. É a mesma causa?
Para que serve isolation isolate, então?
z-index negativo tem algum uso legítimo?
Por que o mesmo layout só quebra no celular?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Chromium 151.0.7922.34 via Playwright 1.62.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — z-index — developer.mozilla.org
- MDN — Stacking context — developer.mozilla.org
- CSS 2.2 — Elaborate description of Stacking Contexts (Apêndice E) — w3.org
- MDN — Top layer — developer.mozilla.org


