Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

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.

Rodolfo Mori12 min de leitura

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:

js
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: 16, left: 24, altura: 170 }

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:

html
<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>
css
.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:

css
#c2 { top: 24px; left: 40px; }
sem position (static) #c2 (Nina) top=60 left=0 #c3 (Bolt) top=120 left=0

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:

html
<div class="aviso"  id="aviso">Clínica fechada no feriado</div>
<div class="cartao" id="cartao">Nina, 09:00</div>
css
.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):

.aviso sem position (static) z-index do #aviso=999 quem esta na frente em (150,90): cartao (z-index computado: auto) .aviso { position: relative } z-index do #aviso=999 quem esta na frente em (150,90): aviso (z-index computado: 999)

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:

css
#c2 { position: relative; top: 24px; left: 40px; }
position: relative #c2 (Nina) top=84 left=40 #c3 (Bolt) top=120 left=0

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:

html
<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>
css
.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:

nenhum ancestral posicionado .painel top=200 left=24 width=852 .cartao top=200 left=24 width=320 .selo top=8 left=785 width=107 .cartao { position: relative } .painel top=200 left=24 width=852 .cartao top=200 left=24 width=320 .selo top=209 left=228 width=107 .painel { position: relative } .painel top=200 left=24 width=852 .cartao top=200 left=24 width=320 .selo top=208 left=761 width=107 .painel { transform: translateZ(0) } .painel top=200 left=24 width=852 .cartao top=200 left=24 width=320 .selo top=208 left=761 width=107

Leia com calma, porque são quatro lições nesses números:

  1. 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”.
  2. Com .cartao { position: relative }, o selo caiu em top=209. Não 208: o bloco contentor de um absolute é a caixa de padding do ancestral, ou seja, por dentro da borda de 1px.
  3. Com .painel { position: relative }, o selo se ancorou no painel largo (852px) e foi para left=761. Ancestral posicionado errado é ancestral posicionado.
  4. transform: translateZ(0) no painel produziu exatamente o mesmo resultado de position: relative. Transform cria bloco contentor para descendente absolute — e essa é a mesma armadilha que derruba o fixed na próxima seção.
viewport — o dono do position: fixed .painel (static: não serve de âncora) .cartao { position: relative } .selo top: 8px; right: 8px a âncora do selo é esta borda

O absolute também some do fluxo

Um cartão sem height, seguido do rodapé da clínica:

html
<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>
css
.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:

css
#titulo, #obs { position: absolute; top: 8px; left: 8px; }
filhos no fluxo altura do .cartao=104px rodape comeca em top=104 os dois filhos com position: absolute altura do .cartao= 2px rodape comeca em top=2

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:

html
<div class="app" id="app">
  <header class="barra" id="barra">Pata Firme — agenda do dia</header>
  <main class="conteudo">consultas...</main>
</div>
css
.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:

nada no .app antes top= 0 depois de rolar 320px top= 0 .app { transform: translateZ(0) } antes top= 0 depois de rolar 320px top= -320 .app { filter: blur(0px) } antes top= 0 depois de rolar 320px top= -320 .app { filter: none } antes top= 0 depois de rolar 320px top= 0 .app { will-change: transform } antes top= 0 depois de rolar 320px top= -320 .app { perspective: 600px } antes top= 0 depois de rolar 320px top= -320 .app { contain: paint } antes top= 0 depois de rolar 320px top= -320 .app { backdrop-filter: blur(2px) } antes top= 0 depois de rolar 320px top= -320

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:

html
<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>
css
.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:

flex item esticado (padrao) altura=2000px top antes= 24 top apos rolar 400px= -376 align-self: start no filtro altura= 170px top antes= 24 top apos rolar 400px= 16 align-self: start, mas sem top altura= 170px top antes= 24 top apos rolar 400px= -376

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:

css
.pagina { overflow: hidden; }
overflow: visible (padrao) computado=visible/visible top apos rolar 400px= 16 overflow: hidden computado=hidden/hidden top apos rolar 400px= -376 overflow-x: hidden computado=hidden/auto top apos rolar 400px= -376 overflow: auto computado=auto/auto top apos rolar 400px= -376 overflow: clip computado=clip/clip top apos rolar 400px= 16

Três achados que valem guardar:

  • overflow: hidden mata 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: hidden mata também, e este é o mais traiçoeiro. Olhe a coluna do computado: pedi só hidden no eixo X e o navegador transformou o eixo Y em auto. 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 os sticky da página.
  • overflow: clip preserva o sticky. Ele corta o que passa da borda sem criar caixa de rolagem, e a medição confirma: top=16 depois 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):

css
.modal { position: fixed; width: 360px; height: 200px; inset: 0; margin: auto; }
viewport 900x600 — centro em (450, 300) top: 50%; left: 50% top=300 left=450 360x200 centro=(630, 400) top: 50%; left: 50%; translate: -50% -50% top=200 left=270 360x200 centro=(450, 300) inset: 0; margin: auto top=200 left=270 360x200 centro=(450, 300) inset: 0; margin: auto; height: auto top= 0 left=270 360x600 centro=(450, 300)

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):

css
.caixa { display: grid; place-items: center; width: 400px; height: 300px;
         border: 1px solid #ccc; box-sizing: border-box; }
.miolo { width: 100px; height: 50px; }
grid + place-items: center centro do miolo=(200, 150) alvo=(200, 150) flex + place-items: center centro do miolo=(51, 150) alvo=(200, 150) flex + place-content: center centro do miolo=(200, 26) alvo=(200, 150) flex + place-content: center + wrap centro do miolo=(200, 150) alvo=(200, 150) flex + justify-content + align-items centro do miolo=(200, 150) alvo=(200, 150)

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.

  • css
  • position
  • sticky
  • absolute
  • fixed

Perguntas frequentes

Por que meu z-index não faz efeito nenhum?
Quase sempre porque o elemento continua com position: static. Nesse estado o navegador calcula o valor de z-index, mostra ele no DevTools e ignora na hora de pintar. Dê position: relative ao elemento e o mesmo z-index passa a valer. As exceções são filhos de flex e de grid, que aceitam z-index mesmo sem position.
absolute e fixed continuam ocupando espaço na página?
Não. Os dois saem do fluxo, e o espaço que eles ocupavam simplesmente deixa de existir. Se todos os filhos de um cartão viraram absolute, o cartão desaba para a altura das próprias bordas e o que vem depois sobe.
Qual a diferença entre sticky e um header fixo feito com fixed?
O fixed sai do fluxo e fica preso à viewport desde o primeiro pixel — você precisa compensar a altura dele com padding no conteúdo. O sticky continua no fluxo, ocupa o espaço dele normalmente e só começa a grudar quando o limiar é atingido. Para cabeçalho de seção, sticky; para barra que precisa estar sempre visível, fixed.
Posso usar sticky dentro de uma tabela?
Pode, em thead, tr e th, desde que a tabela não esteja dentro de um ancestral com overflow diferente de visible ou clip. O limiar continua obrigatório, e o elemento gruda em relação ao contêiner de rolagem, que normalmente é a própria página.

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), e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — position — developer.mozilla.org
  2. MDN — Containing block (bloco contentor) — developer.mozilla.org
  3. CSS Positioned Layout Module Level 3 — w3.org

Continue por aqui