margin-top não funciona: a margem que colapsa no CSS
Por que o espaço que você pediu some, dobra ou empurra o pai inteiro: as quatro situações de colapso de margem, cada uma medida em pixels e corrigida.
O margin-top funciona. O que muda é a conta: quando duas margens verticais se
encostam, o navegador não soma as duas — ele fica com uma só, do tamanho da
maior. Isso se chama colapso de margem, está na especificação do CSS desde 1998,
e explica quase todo espaço que “sumiu” na sua página.
Os exemplos aqui são da página de serviços de uma clínica veterinária, a Pata
Amiga: cartões de banho e tosa, vacinação e consulta. Cada número deste artigo
saiu de getBoundingClientRect() e getComputedStyle() rodando no Chromium
151.0.7922.34, dirigido por Playwright. Nada foi estimado no olho.
O sintoma: 70px pedidos, 40px entregues
Dois cartões, um embaixo do outro, dentro de uma seção:
<section class="servicos">
<article class="servico" id="banho">
<h3>Banho e tosa</h3>
<p>Agende pelo WhatsApp da clínica.</p>
</article>
<article class="servico" id="vacina">
<h3>Vacinação anual</h3>
<p>Carteirinha digital inclusa.</p>
</article>
</section>E o CSS, com a margem declarada nos dois sentidos — o jeito que quase todo mundo escreve na primeira vez:
body { margin: 0; }
.servicos { background: #f2f2f2; }
.servico {
background: #fff;
padding: 16px;
margin-top: 40px;
margin-bottom: 30px;
}No papel, entre o primeiro e o segundo cartão existem 30px de margin-bottom
mais 40px de margin-top. Setenta pixels. Vamos medir:
const banho = document.querySelector('#banho');
const vacina = document.querySelector('#vacina');
const servicos = document.querySelector('.servicos');
const espaco =
vacina.getBoundingClientRect().top - banho.getBoundingClientRect().bottom;
console.log(`espaco real entre os cartoes: ${espaco}px`);
console.log(`topo do .servicos: ${servicos.getBoundingClientRect().top}px`);Dois defeitos numa página de dez linhas. O espaço entre os cartões virou 40px em
vez de 70px, e a .servicos — que não tem margem nenhuma declarada — nasceu 40px
abaixo do topo da janela. São duas situações diferentes de colapso acontecendo
ao mesmo tempo. Vamos separar as quatro.
Caso 1: as margens de dois irmãos viram uma só
O primeiro caso é o do exemplo acima: a margem de baixo de um elemento encosta na margem de cima do irmão seguinte. Em vez de somar, o navegador fica com a maior das duas.
Rodei o mesmo par de cartões com sete combinações de valores, incluindo margem negativa, para ver a regra inteira:
import { chromium } from 'playwright-core';
const navegador = await chromium.launch();
const pagina = await navegador.newPage();
console.log('Chromium', navegador.version(), '\n');
const paginaComMargens = (cima, baixo) => `<style>
body { margin: 0; }
.servicos { background: #f2f2f2; }
.servico { background: #fff; padding: 16px; margin-top: ${cima}px; margin-bottom: ${baixo}px; }
</style>
<section class="servicos">
<article class="servico" id="banho"><h3>Banho e tosa</h3></article>
<article class="servico" id="vacina"><h3>Vacinação anual</h3></article>
</section>`;
console.log('margin-bottom | margin-top | soma | distância real');
for (const [baixo, cima] of [[30, 40], [40, 30], [10, 10], [0, 40], [60, 0], [-20, 40], [-20, -30]]) {
await pagina.setContent(paginaComMargens(cima, baixo));
const distancia = await pagina.evaluate(() => {
const a = document.querySelector('#banho').getBoundingClientRect();
const b = document.querySelector('#vacina').getBoundingClientRect();
return b.top - a.bottom;
});
console.log(
`${(baixo + 'px').padStart(13)} | ${(cima + 'px').padStart(10)} |` +
` ${(baixo + cima + 'px').padStart(5)} | ${(distancia + 'px').padStart(14)}`,
);
}
await navegador.close();margin-bottom | margin-top | soma | distância real 30px | 40px | 70px | 40px 40px | 30px | 70px | 40px 10px | 10px | 20px | 10px 0px | 40px | 40px | 40px 60px | 0px | 60px | 60px -20px | 40px | 20px | 20px -20px | -30px | -50px | -30px
Três regras saem dessa tabela, e são as três que a especificação descreve:
- Duas margens positivas: vale a maior.
30com40dá40, não70. - Uma positiva e uma negativa: aí sim o navegador soma.
40com-20dá20— é assim que margem negativa puxa um bloco para cima do outro. - Duas negativas: vale a mais negativa.
-20com-30dá-30.
Repare na linha do 10px com 10px: o resultado é 10px. Se você espaçou uma
lista inteira de horários com margin: 10px 0 e achou o espaçamento apertado
demais, era isso.
Caso 2: a margem do filho vaza e empurra o pai
Este é o que assusta mais, porque o elemento que se move não tem margem nenhuma no CSS. Um cartão com um título dentro, e nada além disso:
<div class="painel">
<div class="cartao">
<h3>Consulta de rotina</h3>
</div>
</div>body { margin: 0; }
.painel { background: #eef; }
.cartao { background: #fff; }
.cartao h3 {
margin-top: 32px;
margin-bottom: 0;
}A intenção era afastar o título do topo do cartão em 32px. Medindo:
const b = (s) => document.querySelector(s).getBoundingClientRect();
console.log(`topo do .painel: ${b('.painel').top}px`);
console.log(`topo do .cartao: ${b('.cartao').top}px`);
console.log(`do topo do .cartao ate o topo do titulo: ${b('h3').top - b('.cartao').top}px`);
console.log(`altura do .cartao: ${b('.cartao').height}px`);
console.log(`altura do titulo: ${b('h3').height}px`);Leia com calma: dentro do cartão o título está a 0px do topo. Os 32px foram
parar fora, empurrando o .cartao, o .painel e tudo o que estava acima —
e nenhum dos dois tem uma linha de margem no CSS. O cartão, por sua vez, tem
exatamente a altura do título.
Quando não existe nada separando a borda de cima do pai da borda de cima do
primeiro filho — nem padding, nem border, nem uma linha de texto — as duas
margens se encostam e colapsam. A margem resultante fica por fora do pai.
Embaixo acontece o mesmo com o margin-bottom do último filho, com uma
condição a mais: o pai precisa ter altura automática. Troquei o exemplo para
margin-bottom: 24px no título e dei height: 60px ao .painel: a base do
título passou a ficar 38px acima da base do painel, ou seja, a margem ficou
presa lá dentro. Altura fixa (ou min-height) impede que uma borda encoste na
outra, e sem encostar não existe colapso.
Caso 3: um elemento vazio funde as próprias margens
Um <div> separador entre os horários de atendimento, sem conteúdo nenhum
dentro:
<div class="horario" id="manha">Segunda a sexta, 8h as 12h</div>
<div class="divisoria" id="div"></div>
<div class="horario" id="tarde">Segunda a sexta, 14h as 18h</div>.horario { background: #fff; padding: 8px; margin: 0; }
.divisoria {
margin-top: 40px;
margin-bottom: 24px;
}Você declarou 64px de espaço total. Mas a .divisoria não tem altura, nem
padding, nem border — então a margem de cima dela encosta na de baixo, por
dentro do próprio elemento, e as duas colapsam:
const b = (s) => document.querySelector(s).getBoundingClientRect();
console.log(`altura da .divisoria: ${b('#div').height}px`);
console.log(`distancia entre os dois horarios: ${b('#tarde').top - b('#manha').bottom}px`);Sessenta e quatro declarados, quarenta na tela: a maior das duas margens do
próprio elemento, e nada além dela. Esse comportamento tem nome na
especificação, self-collapsing block, e vale para qualquer elemento que não
tenha altura, conteúdo, padding nem border — e que também não crie um
contexto de formatação próprio, detalhe que volta na dobra do flow-root.
Caso 4: o colapso atravessa o elemento vazio e pega os vizinhos
Pior: uma vez que o elemento vazio colapsou consigo mesmo, a margem que sobrou
continua encostada nos dois vizinhos. Dei margem aos horários também —
margin-bottom: 20px no de manhã, margin-top: 16px no da tarde — e testei
três variações da divisória:
// mesma `pagina` do Playwright aberta na dobra do caso 1
const comDivisoria = (extra) => `<style>
body { margin: 0; }
.horario { background: #fff; padding: 8px; }
#manha { margin-bottom: 20px; }
#tarde { margin-top: 16px; }
.divisoria { margin-top: 40px; margin-bottom: 24px; ${extra} }
</style>
<div class="horario" id="manha">Segunda a sexta, 8h as 12h</div>
<div class="divisoria" id="div"></div>
<div class="horario" id="tarde">Segunda a sexta, 14h as 18h</div>`;
for (const [rotulo, extra] of [
['.divisoria vazia', ''],
['.divisoria com height: 1px', 'height: 1px;'],
['.divisoria com padding: 1px 0', 'padding: 1px 0;'],
]) {
await pagina.setContent(comDivisoria(extra));
const medida = await pagina.evaluate(() => {
const b = (s) => document.querySelector(s).getBoundingClientRect();
return { altura: b('#div').height, distancia: b('#tarde').top - b('#manha').bottom };
});
console.log(`vizinhos com 20px e 16px, ${rotulo}`);
console.log(` soma das margens declaradas: ${20 + 40 + 24 + 16}px`);
console.log(` altura da .divisoria: ${medida.altura}px`);
console.log(` distância entre os dois horários: ${medida.distancia}px\n`);
}vizinhos com 20px e 16px, .divisoria vazia soma das margens declaradas: 100px altura da .divisoria: 0px distância entre os dois horários: 40px
vizinhos com 20px e 16px, .divisoria com height: 1px soma das margens declaradas: 100px altura da .divisoria: 1px distância entre os dois horários: 65px
vizinhos com 20px e 16px, .divisoria com padding: 1px 0 soma das margens declaradas: 100px altura da .divisoria: 2px distância entre os dois horários: 66px
Cem pixels declarados, quarenta na tela. E olhe o que um pixel faz: dar
height: 1px à divisória sobe o total para 65px, porque agora existe corpo
separando as duas margens dela — os 40px de cima param de encostar nos 24px de
baixo. Com padding: 1px 0, o elemento passa a ter 2px e a distância vira 66px.
Esse é o caso que mais consome tempo de depuração, porque o culpado é um
elemento invisível. Se você tem um <div> de layout que não desenha nada e o
espaçamento da página está estranho, comece por ele.
Só o eixo vertical colapsa
Uma verificação que economiza confusão: margem esquerda e direita nunca colapsam. Coloquei margem nos dois eixos no mesmo cartão para comparar:
.painel { background: #eef; width: 400px; }
.cartao { background: #fff; margin-left: 20px; }
.cartao h3 {
margin-left: 32px;
margin-top: 32px;
margin-bottom: 24px;
}Na horizontal, 20 + 32 = 52: as duas margens se somam, como qualquer pessoa
esperaria. Na vertical, o cartão continua com a altura exata do título e a base
do painel coincide com a base do título — os 24px de margin-bottom do título
saíram inteiros para fora dos dois. Mesma propriedade, mesmos valores, dois
comportamentos. Vale a pena reler o
box model do CSS com isso em mente: a margem é a
única das quatro camadas da caixa que se comporta assim.
O que bloqueia o colapso: padding, border, overflow, flex e grid
O caso 2 é o mais fácil de corrigir, porque qualquer coisa que separe a borda do
pai do primeiro filho já resolve. Testei nove variantes do mesmo cartão — o
original, sem nada, mais as oito linhas abaixo, uma por vez —, medindo onde o
.painel foi parar e onde o título ficou dentro do .cartao:
/* o .cartao do caso 2, com uma destas linhas por vez */
.cartao { background: #fff; padding-top: 1px; }
.cartao { background: #fff; border-top: 1px solid #ccc; }
.cartao { background: #fff; overflow: hidden; }
.cartao { background: #fff; overflow: auto; }
.cartao { background: #fff; display: flex; flex-direction: column; }
.cartao { background: #fff; display: grid; }
.cartao { background: #fff; display: inline-block; }
.cartao { background: #fff; display: flow-root; }regra aplicada no .cartao | topo do .painel | h3 dentro do .cartao (nada) | 32px | 0px padding-top: 1px | 0px | 33px border-top: 1px solid | 0px | 33px overflow: hidden | 0px | 32px overflow: auto | 0px | 32px display: flex | 0px | 32px display: grid | 0px | 32px display: inline-block | 0px | 32px display: flow-root | 0px | 32px
Todas as nove funcionam, e nenhuma é igual à outra:
| correção | o que ela também faz | quando eu usaria |
|---|---|---|
padding-top: 1px |
vira 33px, não 32 — o pixel entra na conta | nunca; é o remendo que sempre reaparece no code review |
border-top: 1px solid |
mesma soma indevida, mais uma linha visível | só se você já quisesse a borda |
overflow: hidden |
corta qualquer coisa que saia da caixa | perigoso: mata sombra, tooltip e menu suspenso |
overflow: auto |
pode criar barra de rolagem | só quando o conteúdo rola de verdade |
display: flex / grid |
muda o layout inteiro dos filhos | quando você ia usar Flexbox ali de qualquer jeito |
display: inline-block |
o elemento para de ocupar a linha toda | quase nunca, num container |
display: flow-root |
nada além de conter as margens | sempre que a intenção for só essa |
O overflow: hidden é a correção que mais aparece em resposta de fórum, e é a
que mais volta como bug depois. Ela funciona porque cria um contexto de
formatação de bloco, mas leva junto o corte de conteúdo — o preço está descrito
em overflow no CSS.
display: flow-root conserta o vazamento, não o irmão
flow-root foi criado exatamente para isso: cria o contexto de formatação e não
faz mais nada. É o overflow: hidden sem o efeito colateral, e é a resposta
certa quando a sua intenção é apenas conter as margens dos filhos.
O que quase nenhuma resposta de fórum diz é até onde ele vai. Apliquei
flow-root na .servicos da página da clínica e medi as duas coisas ao mesmo
tempo: onde a seção nasce e qual a distância entre os cartões.
/* aplicado no .servicos, uma opção por vez */
.servicos { display: flow-root; }
.servicos { overflow: hidden; }
.servicos { display: flex; flex-direction: column; }
.servicos { display: flex; flex-direction: column; gap: 24px; }regra no .servicos | topo do .servicos | entre os cartões (nada — o CSS original) | 40px | 40px display: flow-root | 0px | 40px overflow: hidden | 0px | 40px display: flex; flex-direction: column | 0px | 70px flex + gap: 24px, sem margem no filho | 0px | 24px
Leia a coluna da direita. Com flow-root, a seção volta para o topo da página —
o caso 2 morreu — mas os cartões continuam a 40px um do outro: o caso 1 passou
ileso. Quem devolve os 70px é o display: flex, porque item de container flex
não colapsa margem com ninguém. Ali 30 + 40 volta a ser 70.
E na divisória vazia? Testei flow-root nela também, com os horários vizinhos
carregando margin-bottom: 20px e margin-top: 16px:
/* aplicado na .divisoria do caso 4, uma opção por vez */
.divisoria { display: flow-root; }
.divisoria { overflow: hidden; }
.divisoria { min-height: 1px; }sem nada altura 0px distancia 40px display: flow-root altura 0px distancia 64px overflow: hidden altura 0px distancia 64px min-height: 1px altura 1px distancia 65px
Sobe de 40px para 64px. Ou seja: flow-root impede a divisória de fundir as
próprias margens (o caso 3), mas ela continua colapsando com os vizinhos
(o caso 4) — por isso 64px, e não os 100px declarados. Um min-height: 1px
chega ao mesmo lugar, mais um pixel de altura.
| caso | o que acontece | o que corrige de fato |
|---|---|---|
| 1 — irmãos adjacentes | vale a maior das duas margens | gap, margem num sentido só, ou pai flex/grid |
| 2 — filho empurra o pai | a margem sai por fora do pai | display: flow-root no pai |
| 3 — elemento vazio se funde | as duas margens dele viram uma | flow-root ou min-height no elemento vazio |
| 4 — colapso atravessa | quatro margens viram uma | apagar o elemento vazio, ou pôr gap no container |
A correção que eu uso primeiro: gap no lugar de margin
A última linha da medição é a que interessa: flex com gap: 24px e zero
margem no filho dá exatamente 24px entre os cartões. Sempre. Não tem maior,
não tem soma, não tem vazamento para o pai.
.servicos {
display: flex;
flex-direction: column;
gap: 24px;
}
.servico {
background: #fff;
padding: 16px;
/* nenhuma margem aqui */
}O motivo é simples: gap é uma propriedade do container, não do filho.
Quem manda no espaço entre os itens é quem os organiza. Isso resolve o problema
na raiz em vez de bloquear o colapso, e funciona igual em
Flexbox e em Grid.
O comentário /* nenhuma margem aqui */ não é enfeite. gap soma com a
margem que sobrou no filho, não substitui a margem — e dentro de um container
flex não existe colapso para segurar a conta:
// mesma `pagina` do Playwright aberta na dobra do caso 1
const comGap = (margem) => `<style>
body { margin: 0; }
.servicos { display: flex; flex-direction: column; gap: 24px; }
.servico { background: #fff; padding: 16px; ${margem} }
</style>
<section class="servicos">
<article class="servico" id="banho"><h3>Banho e tosa</h3></article>
<article class="servico" id="vacina"><h3>Vacinação anual</h3></article>
</section>`;
for (const [rotulo, margem] of [
['filho sem margem nenhuma', ''],
['filho ainda com as margens antigas', 'margin-top: 40px; margin-bottom: 30px;'],
]) {
await pagina.setContent(comGap(margem));
const distancia = await pagina.evaluate(() => {
const b = (s) => document.querySelector(s).getBoundingClientRect();
return b('#vacina').top - b('#banho').bottom;
});
console.log(`${rotulo.padEnd(36)} ${distancia}px`);
}Noventa e quatro é 24 + 40 + 30, tudo somado. Quem troca margin por gap e
esquece de apagar a margem antiga sai de um espaçamento que encolhia sozinho
para um que cresce sozinho. Trocar é apagar uma coisa e escrever a outra.
Quando gap não serve — texto corrido, artigo de blog, conteúdo vindo de um CMS
—, a segunda melhor regra é a da margem em um sentido só: escolha
margin-bottom para tudo e nunca declare margin-top. Sem duas margens se
encostando, não existe colapso para debater. Se sobrar um espaço indesejado no
fim do bloco, :last-child { margin-bottom: 0 } resolve.
Confirmando no navegador em dez segundos
Aqui está a parte que atrapalha na hora do diagnóstico: o painel de estilos do
DevTools mostra a margem que você pediu, não a que sobrou. Um elemento que
teve a margem colapsada continua exibindo margin-top: 40px em Computed, e o
diagrama do box model desenha os 40px como se existissem.
Cole isto no console, apontando para os dois elementos que deveriam estar separados:
const a = document.querySelector('#banho');
const b = document.querySelector('#vacina');
const servicos = document.querySelector('.servicos');
console.log(`margin-bottom computado do #banho: ${getComputedStyle(a).marginBottom}`);
console.log(`margin-top computado do #vacina: ${getComputedStyle(b).marginTop}`);
console.log(`espaco real entre os dois: ${b.getBoundingClientRect().top - a.getBoundingClientRect().bottom}px`);
console.log(`margin-top computado do .servicos: ${getComputedStyle(servicos).marginTop}`);
console.log(`topo do .servicos na pagina: ${servicos.getBoundingClientRect().top}px`);As duas últimas linhas são a assinatura do caso 2: margin-top computado igual
a 0px num elemento que mesmo assim começa 40px abaixo. Quando você vir isso,
não procure mais o CSS do elemento — procure o CSS do primeiro filho dele.
O diagnóstico completo em três perguntas, na ordem:
- O espaço entre dois irmãos é menor que a soma? É o caso 1 — troque por
gapou use margem num sentido só. - Um elemento sem margem começa deslocado? É o caso 2 — o culpado é o primeiro
filho; ponha
display: flow-rootno pai ou mova a margem para o filho certo. - Existe um elemento vazio no meio? É o caso 3 ou 4 — apague o elemento, ou dê corpo a ele.
Por onde seguir
O colapso de margem é o primeiro de uma família de comportamentos do CSS que só fazem sentido quando você entende que o navegador tem regras próprias de composição. O parente mais próximo é o display no CSS, que decide se um elemento participa do fluxo normal — e portanto se ele colapsa ou não. O mapa completo da linguagem, com a ordem em que cada assunto entra, está no guia de CSS; a sequência de aulas fica na trilha de CSS.
Antes de fechar a aba: abra o seu projeto atual, escolha um bloco que “está com o espaçamento estranho” e rode o snippet do console acima. Em dez segundos você sabe se é colapso ou se é outra coisa.
Perguntas frequentes
Colapso de margem é um bug do navegador?
Por que ninguém reclama disso em layout com Flexbox?
Adicionar !important no margin-top resolve?
Trocar margin por padding é uma correção válida?
Como isso aparece no React ou no Tailwind?
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 dirigido por Playwright, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — Mastering margin collapsing — developer.mozilla.org
- CSS 2.2 — Collapsing margins (8.3.1) — w3.org
- CSS Display Module Level 3 — flow-root — w3.org


