position no CSS: relative, absolute, fixed e sticky
Em relação a quem cada posição se ancora, por que o absolute às vezes sobe até o body e as três condições que fazem o sticky realmente grudar.
A propriedade position responde uma pergunta só: em relação a quem este
elemento se desloca. static não se desloca. relative se desloca em relação
ao lugar que ele mesmo ocuparia. absolute, em relação ao ancestral posicionado
mais próximo. fixed, em relação à viewport. sticky, em relação ao contêiner
de rolagem — enquanto o pai deixar.
Todo exemplo daqui é o painel de uma clínica veterinária, a Pata Firme: a agenda do dia, o cartão do paciente com um selo no canto, a barra do topo e a coluna de filtros. É o mesmo painel do começo ao fim, e é sempre um pedaço dele que quebra.
Todo número deste artigo saiu de uma régua
Discussão sobre position costuma virar troca de palpite. Aqui não: cada
resultado abaixo veio de um Chromium de verdade, com a página carregada, rolada
programaticamente e medida com getBoundingClientRect(). O roteiro é este:
import { chromium } from 'playwright-core';
const html = `
<style>
body { margin: 0; font: 16px system-ui; }
.layout { display: flex; gap: 24px; padding: 24px; }
.filtros { position: sticky; top: 16px; align-self: start; width: 200px; }
.lista { flex: 1; height: 2000px; }
</style>
<div class="layout">
<aside class="filtros" id="filtros"><h2>Filtrar</h2><p>Cães</p><p>Gatos</p><p>Aves</p></aside>
<section class="lista">agenda da semana</section>
</div>
`;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
await page.setContent(html);
await page.evaluate(() => window.scrollTo(0, 400));
const caixa = await page.$eval('#filtros', (el) => {
const r = el.getBoundingClientRect();
return { top: Math.round(r.top), left: Math.round(r.left), altura: Math.round(r.height) };
});
console.log(caixa);
await browser.close();top é a distância entre o topo do elemento e o topo da janela naquele
instante. É a única medida que interessa: se o elemento “gruda”, esse número
para de mudar quando a página rola. Se ele “escapa”, o número desce junto com o
scroll. Máquina: MacBook, Chromium 151.0.7922.34 pelo Playwright 1.62.1,
viewport de 900×600.
static: o padrão que ignora top, left e z-index
Todo elemento nasce static. Nesse estado ele fica onde o fluxo normal colocou
e — a parte que surpreende — as propriedades de deslocamento não fazem nada.
A agenda do dia:
<ul class="agenda">
<li class="consulta" id="c1">08:00 — Thor, vacina antirrábica</li>
<li class="consulta" id="c2">09:00 — Nina, retorno pós-cirúrgico</li>
<li class="consulta" id="c3">10:00 — Bolt, banho e tosa</li>
</ul>.agenda { list-style: none; margin: 0; padding: 0; }
.consulta { height: 60px; border: 1px solid #ccc; box-sizing: border-box; }Cada li tem 60px de altura, então as três linhas começam em 0, 60 e 120. Agora
peço para a consulta da Nina descer 24px e andar 40px para a direita, sem
declarar position:
#c2 { top: 24px; left: 40px; }Nada aconteceu. top e left só existem para elemento posicionado, e static
não é posicionado. O mesmo vale para z-index. Coloquei um aviso de feriado
antes do cartão da consulta e puxei o cartão 60px para cima, para os dois se
sobreporem. O aviso levou z-index: 999 e nenhum position:
<div class="aviso" id="aviso">Clínica fechada no feriado</div>
<div class="cartao" id="cartao">Nina, 09:00</div>.aviso { width: 300px; height: 120px; z-index: 999; }
.cartao { position: relative; top: -60px; width: 300px; height: 120px; }Para saber quem está na frente, perguntei ao próprio navegador com
document.elementFromPoint(150, 90):
Repare no detalhe cruel: o z-index computado do aviso é 999 nos dois
casos. O DevTools mostra 999. E mesmo assim ele fica atrás, porque static
descarta o valor na hora de empilhar. Esta é a causa número um de
z-index que não funciona, e o resto do
assunto está em stacking context.
relative: sai do lugar e deixa o buraco
Basta trocar uma linha para o deslocamento passar a valer:
#c2 { position: relative; top: 24px; left: 40px; }A consulta da Nina foi de top=60 para top=84: desceu exatamente os 24px
pedidos. Agora olhe a linha do Bolt: continua em top=120, o mesmo lugar de
antes. O elemento se moveu, mas o espaço dele ficou reservado. Quem usa
relative para “ajustar um pixelzinho” acaba com layouts cheios de buracos
invisíveis.
Por isso o uso honesto de relative quase nunca é mover coisa. É virar âncora:
declarar position: relative num elemento sem nenhum top/left só para que
os filhos absolute tenham de quem se pendurar.
absolute: quem é o ancestral posicionado mais próximo
Aqui está o cartão do paciente com um selo no canto superior direito:
<div class="painel" id="painel">
<article class="cartao" id="cartao">
<h2>Nina, gata, 4 anos</h2>
<span class="selo" id="selo">vacina vencida</span>
</article>
</div>.painel { margin: 200px 24px 0; }
.cartao { width: 320px; height: 180px; border: 1px solid #ccc; box-sizing: border-box; }
.selo { position: absolute; top: 8px; right: 8px; }O .selo mede 8px a partir de alguma borda. De qual? Do primeiro ancestral
posicionado que ele encontrar subindo a árvore. Se não achar nenhum, ele sobe
até o bloco contentor inicial: um retângulo do tamanho da viewport ancorado
no topo do documento. Medi as quatro variações:
Leia com calma, porque são quatro lições nesses números:
- Sem ancestral posicionado, o selo foi parar em
top=8, left=785— no canto da página, a 192px acima do cartão que deveria enfeitar. Este é o bug clássico do “meu badge escapou para o topo do site”. - Com
.cartao { position: relative }, o selo caiu emtop=209. Não 208: o bloco contentor de umabsoluteé a caixa de padding do ancestral, ou seja, por dentro da borda de 1px. - Com
.painel { position: relative }, o selo se ancorou no painel largo (852px) e foi paraleft=761. Ancestral posicionado errado é ancestral posicionado. transform: translateZ(0)no painel produziu exatamente o mesmo resultado deposition: relative. Transform cria bloco contentor para descendenteabsolute— e essa é a mesma armadilha que derruba ofixedna próxima seção.
O absolute também some do fluxo
Um cartão sem height, seguido do rodapé da clínica:
<article class="cartao" id="cartao">
<h2 id="titulo">Bolt, cão, 7 anos</h2>
<p id="obs">Alergia a frango. Retorno em 30 dias.</p>
</article>
<footer class="rodape">Pata Firme</footer>.cartao { position: relative; width: 320px; border: 1px solid #ccc; }
.rodape { height: 40px; }Quem dá altura ao cartão são os dois filhos. Agora os dois viram absolute:
#titulo, #obs { position: absolute; top: 8px; left: 8px; }O cartão desabou de 104px para 2px — sobraram só as duas bordas de 1px. E o
rodapé da clínica subiu junto. Elemento absolute não empurra ninguém e não
segura o pai aberto, então quem depende dele para ter altura precisa declarar
height ou min-height na mão. Se isso está te acontecendo com frequência,
o problema não é position: é layout, e a resposta está em
Flexbox ou Grid.
fixed: preso à viewport, até um ancestral tirar isso dele
A barra do topo da clínica, com position: fixed; top: 0, deveria continuar em
top=0 por mais que a página role:
<div class="app" id="app">
<header class="barra" id="barra">Pata Firme — agenda do dia</header>
<main class="conteudo">consultas...</main>
</div>.barra { position: fixed; top: 0; left: 0; right: 0; height: 56px; }
.conteudo { height: 3000px; padding-top: 56px; }Rolei 320px e medi o top da barra, mudando só uma propriedade do .app — que
nem chega a ser o elemento fixo:
Seis propriedades diferentes, aplicadas num ancestral, transformaram o fixed
num absolute disfarçado: a barra rolou os 320px junto com a página. Nenhuma
delas tem “position” no nome, e é por isso que o bug parece mágica.
O mecanismo é um só: quando um ancestral cria um bloco contentor, ele passa
a ser a referência do fixed no lugar da viewport. transform, filter,
perspective, will-change com um desses valores, contain: paint e
backdrop-filter fazem isso. Repare que filter: none não faz — o valor
importa, não a propriedade.
sticky: as três condições para grudar de verdade
sticky é híbrido: enquanto o limiar não é atingido ele se comporta como
relative, e depois passa a se comportar como fixed dentro do pai. A coluna
de filtros da clínica:
<div class="layout">
<aside class="filtros" id="filtros">
<h2>Filtrar</h2><p>Cães</p><p>Gatos</p><p>Aves</p>
</aside>
<section class="lista">agenda da semana</section>
</div>.layout { display: flex; gap: 24px; padding: 24px; }
.filtros { position: sticky; top: 16px; width: 200px; }
.lista { flex: 1; height: 2000px; }Parece certo, e não gruda. Rolei 400px em três variações:
Só a linha do meio gruda: depois de rolar 400px, o filtro parou em top=16,
que é exatamente o limiar pedido. As outras duas desceram para -376, ou seja,
saíram da tela junto com o scroll. As três condições são:
| condição | o que acontece sem ela | como confirmar |
|---|---|---|
um limiar (top, bottom, left ou right) |
o elemento age como relative para sempre |
getComputedStyle(el).top devolve auto |
| o pai mais alto que o elemento | não sobra trecho para deslizar | compare el.offsetHeight com el.parentElement.offsetHeight |
nenhum ancestral com overflow de rolagem |
o contêiner de rolagem vira o ancestral, que nunca rola | ver a próxima seção |
A primeira linha da medição mostra a segunda condição no pior formato possível:
como .filtros é filho direto de um flex container, ele foi esticado para
2000px de altura — a altura que o .lista impôs à caixa de conteúdo do pai
(o .layout mede 2048px com o padding de 24px). Um elemento do tamanho do pai
não tem para onde deslizar. align-self: start devolve os 170px de conteúdo e
o sticky volta a funcionar — a mesma lógica vale em grid com
align-self: start.
overflow em qualquer ancestral: o assassino silencioso
A terceira condição merece seção própria, porque quebra sem aviso nenhum e o
culpado costuma estar cinco níveis acima. Envolvi o layout que já funcionava
num <div class="pagina"> e variei só o overflow dela:
.pagina { overflow: hidden; }Três achados que valem guardar:
overflow: hiddenmata o sticky. O ancestral vira o contêiner de rolagem do elemento; como esse ancestral nunca rola por dentro, o sticky nunca recebe um deslocamento e simplesmente viaja com a página.overflow-x: hiddenmata também, e este é o mais traiçoeiro. Olhe a coluna do computado: pedi sóhiddenno eixo X e o navegador transformou o eixo Y emauto. A especificação manda fazer isso quando os dois eixos divergem. Ou seja, a linha que você escreveu para matar a rolagem horizontal do celular derrubou todos osstickyda página.overflow: clippreserva o sticky. Ele corta o que passa da borda sem criar caixa de rolagem, e a medição confirma:top=16depois de rolar 400px, igual ao padrão. Quando você quer só cortar o transbordo,clipé a escolha certa — o resto das diferenças está em overflow no CSS.
inset e a centralização com inset: 0 + margin: auto
inset é atalho para top/right/bottom/left na mesma ordem do margin.
Ele brilha num truque específico: centralizar um modal — o “cancelar a consulta
da Nina?” — sem transform e sem flex. Comparei quatro receitas num modal de
360×200 numa viewport de 900×600, cujo centro fica em (450, 300):
.modal { position: fixed; width: 360px; height: 200px; inset: 0; margin: auto; }A primeira linha é o erro clássico: top: 50% posiciona a borda de cima na
metade da tela, então o modal fica deslocado meia altura para baixo e meia
largura para a direita — centro em (630, 400) em vez de (450, 300). O
translate: -50% -50% corrige puxando o elemento pelo próprio tamanho.
inset: 0 + margin: auto chega ao mesmo lugar por outro caminho: inset: 0
diz “as quatro bordas encostam”, o que sobra vira espaço livre, e margin: auto
divide esse espaço igualmente nos quatro lados. Mas repare na última linha: com
height: auto, o modal foi de 200px para 600px de altura. Sem altura
definida, o inset: 0 estica em vez de centralizar. A receita só funciona com
largura e altura resolvidas.
Quando position é a ferramenta errada
position move um elemento. Ele não organiza uma página. A lista abaixo é o
critério que uso em revisão de código:
| você quer | ferramenta certa | por quê |
|---|---|---|
| duas colunas lado a lado | Flexbox ou Grid | absolute tira do fluxo e o pai desaba |
| um selo no canto de um cartão | relative no cartão + absolute no selo |
é justamente para isso |
| centralizar conteúdo numa caixa | display: grid + place-items: center |
funciona com altura automática |
| cabeçalho que acompanha a rolagem | sticky |
continua no fluxo, não precisa de compensação |
| barra sempre visível na tela | fixed |
com padding no conteúdo do mesmo tamanho |
| empurrar um ícone 2px para cima | relative ou ajuste de alinhamento |
relative deixa buraco; prefira acertar o alinhamento |
A linha da centralização diz grid de propósito, e isso vale medir: place-items
é atalho de align-items + justify-items, e justify-items não faz nada em
flex container. Coloquei uma caixa de 400×300 encostada no canto (0, 0) da
página, com um miolo de 100×50 dentro — o centro certo é (200, 150):
.caixa { display: grid; place-items: center; width: 400px; height: 300px;
border: 1px solid #ccc; box-sizing: border-box; }
.miolo { width: 100px; height: 50px; }Em flex, place-items: center centraliza só na vertical, e place-content: center só na horizontal — align-content não vale em flex de uma linha só, e
por isso a versão com flex-wrap: wrap acerta. O par que sempre funciona em flex
é justify-content: center com align-items: center. Se você quer resolver numa
declaração só, o caminho é display: grid com place-items: center.
Duas regras que economizam bastante retrabalho. A primeira: absolute só é
seguro quando o pai tem tamanho garantido, porque o filho não sustenta mais
ninguém. A segunda: sempre que precisar de absolute, declare position: relative no pai na mesma hora — mesmo sem nenhum deslocamento. É uma linha
que impede o selo de subir para o topo do site seis meses depois, quando alguém
mudar a estrutura acima dele.
Vale lembrar também que position só existe para caixa gerada. Elemento com
display: none não é posicionado coisa nenhuma, e a interação entre os dois
está em display no CSS.
O que treinar agora
Abra o painel que você está construindo e faça três verificações. Uma: procure
todo position: absolute do projeto e confirme que existe um ancestral com
position: relative declarado de propósito. Duas: rode uma busca por
overflow-x: hidden e cheque se ela não está matando algum sticky. Três:
selecione um elemento fixo no DevTools e suba a árvore procurando transform e
filter nos ancestrais.
A próxima parada da trilha de CSS é montar layout de
verdade, com Flexbox e Grid — que é onde position deixa de ser a única
ferramenta e volta ao papel dele: mover um elemento, não sustentar uma página.
Perguntas frequentes
Por que meu z-index não faz efeito nenhum?
absolute e fixed continuam ocupando espaço na página?
Qual a diferença entre sticky e um header fixo feito com fixed?
Posso usar sticky dentro de uma tabela?
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 (Playwright 1.62.1), e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — position — developer.mozilla.org
- MDN — Containing block (bloco contentor) — developer.mozilla.org
- CSS Positioned Layout Module Level 3 — w3.org


