Media queries no CSS: responsivo que não quebra
Como escrever breakpoints que nascem do conteúdo, a sintaxe de intervalo com width >= 48rem e as consultas que não têm nada a ver com largura.
Media query é a regra que diz ao navegador quando um pedaço de CSS vale. Você escreve uma condição — normalmente sobre a largura da tela — e o bloco só entra em vigor enquanto ela for verdadeira. Fora disso, é como se aquele CSS não existisse.
A largura que ela avalia é a da viewport, ou seja, a área visível da página. Ela muda quando a pessoa gira o celular, redimensiona a janela, encosta o DevTools na lateral ou aumenta o zoom — e o navegador reavalia todas as consultas na hora, sem recarregar nada.
Todos os exemplos aqui são da mesma livraria online, a Beco dos Livros: um cabeçalho com menu, uma barra de filtros e um catálogo de capas em grade. E todos os números desta página saíram de medição real — um script de Playwright 1.62.1 dirigindo um Chromium 151 headless num MacBook, mudando a largura da janela e lendo o CSS que o navegador de fato aplicou.
Sem a meta viewport, o celular mente a largura para o CSS
Antes de qualquer @media, uma linha no <head>. Sem ela, o celular finge ser
um desktop estreito: ele renderiza a página numa tela virtual de 980px e depois
encolhe tudo. As suas media queries recebem 980, não 390.
Esse comportamento é herança dos primeiros smartphones, que precisavam exibir sites feitos só para desktop sem destruir o layout. A meta viewport é a forma de avisar o navegador que a sua página sabe se virar sozinha e não precisa dessa muleta.
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Beco dos Livros</title>
<link rel="stylesheet" href="livraria.css" />
</head>Coloquei a mesma página num contexto de celular (390 × 844, com toque), uma vez
com a linha e uma vez sem, e li window.innerWidth e o resultado da consulta:
Sem a meta, o celular ativou o layout de três colunas — o de desktop. É por isso que um site “responsivo” às vezes aparece minúsculo no telefone: as media queries estão certas, mas ninguém contou ao navegador qual é a largura real. O resto do que mora nessa tag está em meta tags no HTML.
Mobile-first ou desktop-first: medi os dois
Mobile-first quer dizer escrever primeiro o layout do celular, sem media query
nenhuma, e depois acrescentar com min-width. Desktop-first é o contrário:
começar largo e subtrair com max-width.
Este é o CSS da livraria, mobile-first — o arquivo mobile-first.css que os
testes desta seção carregam:
.catalogo { display: grid; gap: 1rem; grid-template-columns: 1fr; }
.filtros { display: none; }
@media (min-width: 30rem) {
.catalogo { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 48rem) {
.catalogo { grid-template-columns: repeat(3, 1fr); }
.filtros { display: block; }
}
@media (min-width: 64rem) {
.catalogo { grid-template-columns: repeat(4, 1fr); }
}Para saber em que largura exata cada regra entra, subi a página no navegador e varri de 320px a 1600px, de 8 em 8 pixels, imprimindo só as trocas:
import { chromium } from 'playwright-core';
import { readFileSync } from 'node:fs';
const html = `<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${readFileSync('./mobile-first.css', 'utf8')}</style>
<aside class="filtros">filtros</aside>
<div class="catalogo"><a>1</a><a>2</a><a>3</a><a>4</a></div>`;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html);
let anterior = null;
let medidas = 0;
for (let largura = 320; largura <= 1600; largura += 8) {
await page.setViewportSize({ width: largura, height: 800 });
medidas++;
const estado = await page.evaluate(() => {
const colunas = getComputedStyle(document.querySelector('.catalogo'))
.gridTemplateColumns.split(' ').length;
const filtros = getComputedStyle(document.querySelector('.filtros')).display;
return `catalogo em ${colunas} coluna(s), filtros: ${filtros}`;
});
if (estado !== anterior) {
console.log(`a partir de ${String(largura).padStart(4)}px -> ${estado}`);
anterior = estado;
}
}
console.log(`${medidas} larguras medidas entre 320px e 1600px`);
await browser.close();Repare que 30rem virou 480px, 48rem virou 768px e 64rem virou 1024px, e
que o limite é inclusivo: em 768px exatos a regra já vale.
Agora o mesmo layout escrito de trás para a frente, no arquivo
desktop-first.css — mesma indentação, mesmas quebras de linha, para a
comparação ser justa:
.catalogo { display: grid; gap: 1rem; grid-template-columns: repeat(4, 1fr); }
.filtros { display: block; }
@media (max-width: 63.99rem) {
.catalogo { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 47.99rem) {
.catalogo { grid-template-columns: repeat(2, 1fr); }
.filtros { display: none; }
}
@media (max-width: 29.99rem) {
.catalogo { grid-template-columns: 1fr; }
}Coloquei os dois na mesma página e comparei o número de colunas e o estado da
barra de filtros em cada uma das 1281 larguras inteiras entre 320px e 1600px,
medindo os arquivos com wc -c no fim:
Nove bytes de diferença. Então esqueça o argumento de que mobile-first “gera menos CSS” — não gera. O motivo de preferir mobile-first é outro, e é mais importante: a camada base, a que fica fora de qualquer media query, é a única que sempre se aplica. Se uma folha não carregar, se o navegador for antigo, se o conteúdo for lido por um leitor de tela, é essa camada que sobra. Colocar nela o layout de uma coluna é escolher o cenário mais seguro como padrão.
E repare no preço escondido do desktop-first: os .99rem. Eles existem só para
o max-width não se sobrepor ao próximo min-width. Guarde essa dívida — ela
volta na próxima seção.
A sintaxe de intervalo e o buraco entre 767 e 768
O CSS moderno aceita comparação direta. (min-width: 48rem) pode ser escrito
(width >= 48rem), e o max-width vira o operador de menor ou igual. Melhor
ainda: dá para escrever os dois lados de uma faixa numa expressão só, com a
mesma leitura de uma desigualdade matemática.
/* forma antiga */
@media (min-width: 48rem) and (max-width: 63.99rem) { }
/* sintaxe de intervalo — a mesma faixa, sem o remendo do 63.99 */
@media (48rem <= width < 64rem) { }Aquele .99 não é frescura de quem gosta de número redondo: é remendo. Em
telas com escala fracionária — zoom do navegador em 110%, um monitor com fator
de escala 1,1 — a largura da viewport não é um número inteiro. E aí o par
max-width: 767px / min-width: 768px deixa um vão.
Para provar, abri o Chromium com fator de escala 1,1 e janela de 767 pixels de largura. Isso produz uma viewport de largura fracionária:
html { --faixa: "nenhuma regra pegou"; }
@media (max-width: 767px) { html { --faixa: "celular"; } }
@media (min-width: 768px) { html { --faixa: "desktop"; } }
/* as duas últimas linhas da saída não estão nesta folha: são o remendo
(max-width: 767.98px) e a sintaxe de intervalo (width < 48rem), lidos
com matchMedia na mesma página, para comparar */Nesse quarto de pixel, a livraria ficou sem layout de celular e sem layout de desktop. Nem a regra de baixo nem a de cima pegaram, e o navegador aplicou os valores padrão. É o bug que faz alguém jurar que o site “só quebra no computador do cliente” — e é impossível de reproduzir com a janela em números redondos.
A sintaxe de intervalo elimina a categoria inteira do problema. As duas metades da mesma comparação cobrem a reta toda, sem sobra e sem sobreposição, em qualquer largura fracionária:
@media (width < 48rem) { /* tudo que for menor que 768px */ }
@media (width >= 48rem) { /* 768px e daí para cima */ }O breakpoint sai do conteúdo: o meu era 719px
A lista de larguras de aparelho não serve para escolher breakpoint. Existem centenas de tamanhos de tela, eles mudam todo ano, e nenhum deles sabe quanto espaço o seu menu precisa. Quem sabe é o menu.
O cabeçalho da Beco dos Livros tem a marca e cinco links: Lançamentos, Mais vendidos, Infantil, Clube de leitura e Minha conta. Em vez de chutar 768px, perguntei ao navegador a partir de qual largura essa linha para de estourar:
import { chromium } from 'playwright-core';
const cabecalho = `<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { margin: 0; box-sizing: border-box; }
body { font: 1rem/1.5 system-ui, sans-serif; }
.cabecalho { display: flex; align-items: center; gap: 2rem; padding: 1rem; }
.marca { font-weight: 700; white-space: nowrap; }
.menu { display: flex; gap: 1.5rem; list-style: none; padding: 0; }
.menu a { white-space: nowrap; text-decoration: none; }
</style>
<header class="cabecalho">
<span class="marca">Beco dos Livros</span>
<ul class="menu">
<li><a href="#">Lançamentos</a></li>
<li><a href="#">Mais vendidos</a></li>
<li><a href="#">Infantil</a></li>
<li><a href="#">Clube de leitura</a></li>
<li><a href="#">Minha conta</a></li>
</ul>
</header>`;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(cabecalho);
let primeiroQueCabe = null;
for (let largura = 320; largura <= 1600; largura++) {
await page.setViewportSize({ width: largura, height: 400 });
const cabe = await page.evaluate(() =>
document.documentElement.scrollWidth <= document.documentElement.clientWidth);
if (cabe) { primeiroQueCabe = largura; break; }
}
console.log(`menu horizontal deixa de estourar em: ${primeiroQueCabe}px`);
console.log(`em rem (base 16px): ${(primeiroQueCabe / 16).toFixed(2)}rem`);
await browser.close();719px — na fonte que este Mac entrega para system-ui. Rode no seu projeto e o
número vai ser outro, e é exatamente esse o ponto: o valor certo é o do seu
menu, não o do framework. Se eu tivesse copiado o 768px, entre 719px e 767px o
menu caberia perfeitamente e mesmo assim continuaria escondido atrás do botão de
sanduíche — uma faixa de 48 pixels de largura em que a tela sobra e o menu não
aparece, bem no tamanho de tablet em retrato.
Com o número medido, o breakpoint vira uma decisão e não um chute. Arredondo para cima com uma folga pequena, porque a fonte pode não carregar e o texto pode crescer em outro idioma:
.menu { display: none; }
.botao-sanduiche { display: block; }
@media (width >= 46rem) { /* 736px: os 719px medidos, com folga */
.menu { display: flex; gap: 1.5rem; }
.botao-sanduiche { display: none; }
}O nome disso é breakpoint de conteúdo. Cada componente tem o seu, e eles não precisam coincidir: o catálogo pode virar em 480px e o menu em 736px. Layout não tem obrigação de trocar tudo ao mesmo tempo.
Isso costuma assustar quem aprendeu a decorar três números. Mas a alternativa é pior: alinhar tudo num breakpoint só significa que um componente vai trocar antes da hora e outro vai trocar depois. Se os números medidos ficarem próximos, aí sim vale unificar — porque a proximidade foi medida, e não presumida.
O reflexo seguinte é guardar esse número numa variável CSS. Não funciona, e o teste é rápido:
:root { --bp-menu: 46rem; }
@media (min-width: var(--bp-menu)) { .x { --v: "pegou com var()"; } }
@media (min-width: 46rem) { .x { --v: "pegou com o valor literal"; } }A regra com var() nunca casou. Custom properties são resolvidas por elemento,
e a condição de uma media query é avaliada antes de existir elemento nenhum —
por isso ela só aceita valor literal. Quem quer breakpoint com nome usa uma
variável do pré-processador ou do bundler, que substitui o número antes de
virar CSS.
rem no breakpoint respeita quem aumentou a letra
Aqui mora a parte que quase ninguém sabe, e ela tem duas metades.
Primeira metade: dentro de uma media query, rem não olha para o
font-size que você definiu no html. Ele usa o tamanho de fonte inicial do
navegador. Testei com a raiz forçada em 10px, num viewport de 600px:
html { font-size: 10px; }Se rem valesse os 10px da raiz, 48rem seria 480px e a consulta daria true
em 600px. Deu false — porque na media query 48rem continua sendo 48 × 16 =
768px. A comparação entre unidades está em
unidades no CSS; o que muda aqui é a referência.
Segunda metade, que é a útil: esse tamanho inicial muda quando a pessoa aumenta a fonte padrão nas configurações do navegador — coisa comum entre quem já não enxerga de perto como antes, e essencial para quem tem baixa visão. Simulei a configuração em 24px, num viewport de 900px, lendo também quantas colunas o catálogo desenhou:
Com a fonte grande, 48rem passou a valer 1152px e a livraria não entrou nas
três colunas: manteve duas, com mais espaço para um texto que agora é maior. O
breakpoint em 768px ignorou isso e espremeu o texto grande em três colunas.
Essa é a regra que eu assino: breakpoint em rem, sempre. O custo é zero e
o ganho é um layout que se adapta a quem já pediu ajuda ao navegador.
Tema e movimento: as consultas que não olham para a largura
Media query não é só sobre tamanho. Duas delas leem preferências do sistema operacional e valem mais que muito breakpoint.
.capa {
background: #fdfdfc;
color: #1b1b18;
transition: transform 0.3s ease;
}
@media (prefers-color-scheme: dark) {
.capa { background: #17170f; color: #f4f4ee; }
}
@media (prefers-reduced-motion: reduce) {
.capa { transition-duration: 0s; }
}Emulei as três combinações e li o estilo computado da capa do livro:
prefers-color-scheme é o modo escuro sem botão nenhum: quem configurou o
sistema em escuro já recebe o site escuro na primeira visita. Se você quiser um
botão de tema além disso, ele precisa poder vencer a preferência do sistema nas
duas direções — o que costuma exigir um atributo no elemento raiz, e não apenas
a media query.
prefers-reduced-motion é acessibilidade, não preferência estética. Para quem
tem enxaqueca vestibular ou desconforto com movimento, uma capa que desliza a
cada rolagem provoca náusea de verdade. Quem ligou a opção pediu para o
movimento parar — e a sua animação, feita com
transition e transform, obedece com duas
linhas.
hover e pointer: o :hover que não existe no dedo
hover pergunta se o ponteiro principal consegue pairar sobre um elemento.
pointer pergunta se ele é preciso (mouse, fine) ou grosso (dedo, coarse).
Rodei a mesma página num contexto de notebook e num de celular com toque:
No celular, hover: hover é false. Isso importa porque um submenu que só abre
no :hover fica inalcançável no toque — ou, pior, abre no primeiro toque e
engole o clique que deveria navegar.
Note que largura de tela não responde essa pergunta. Um tablet de 1024px é
touch; um notebook de 1280px não é; e existe celular ligado a um monitor grande.
Perguntar max-width: 48rem para adivinhar “isto é um celular” acerta na maioria
e erra justamente nos casos difíceis. Quando a dúvida é sobre o dedo, pergunte
sobre o dedo. As
pseudo-classes continuam válidas;
o que muda é onde você confia nelas.
/* alvo de toque grande onde o ponteiro é o dedo */
@media (pointer: coarse) {
.menu a { padding-block: 0.75rem; }
}
/* a prévia da sinopse só existe onde dá para pairar */
@media (hover: hover) and (pointer: fine) {
.capa:hover .sinopse { opacity: 1; }
}and, vírgula e not: a lógica que erra em silêncio
Três combinadores, e cada um com uma armadilha.
/* E — as duas condições ao mesmo tempo */
@media (width >= 48rem) and (hover: hover) { }
/* OU — a vírgula separa consultas independentes */
@media (width <= 30rem), (width >= 64rem) { }
/* NÃO — nega a consulta inteira, e vem sozinho */
@media not (width >= 48rem) { }A armadilha do and é achar que faixas fechadas se encaixam sozinhas. Elas não
se encaixam: os dois extremos são inclusivos, então a faixa “tablet” e a faixa
“desktop” se sobrepõem exatamente em 1024px. Escrevi o mesmo par de regras duas
vezes, mudando só a ordem no arquivo:
/* ordem A: a faixa larga vem depois */
@media (min-width: 768px) and (max-width: 1024px) { .a { --faixa: "tablet"; } }
@media (min-width: 1024px) { .a { --faixa: "desktop"; } }
/* ordem B: a faixa larga vem antes */
@media (min-width: 1024px) { .b { --faixa: "desktop"; } }
@media (min-width: 768px) and (max-width: 1024px) { .b { --faixa: "tablet"; } }Em 1024px o mesmo CSS dá dois resultados diferentes. Media query não tem especificidade própria: quando duas casam, decide quem veio por último no arquivo. Ou seja, reordenar blocos — algo que um formatador automático ou um merge mal resolvido faz sem avisar — muda o layout. Escrita em sintaxe de intervalo, a faixa fica fechada de um lado e aberta do outro, a sobreposição desaparece e a ordem para de importar.
A armadilha da vírgula é o oposto: ela é tão independente que esconde erro.
Numa lista separada por vírgula, o navegador descarta só a consulta inválida e
mantém as outras; num and, um pedaço inválido derruba a expressão inteira.
Coloquei o mesmo erro de digitação — o 768 sem unidade — nas duas formas, num
viewport de 1200px:
/* vírgula: a metade boa sobrevive */
@media (min-width: 768), (min-width: 48rem) { }
/* and: a metade boa vai junto */
@media (min-width: 768) and (min-width: 48rem) { }Repare em quem se dá pior. Com and, o bloco morre inteiro e você percebe logo.
Com vírgula, ele continua funcionando — só que por metade das razões que você
imaginava, e o dia em que a outra metade for a que importa você vai procurar o
problema em qualquer lugar menos ali.
A armadilha do not é achar que dá para emendar um and depois dele. Não dá:
not (…) é a consulta inteira, e a gramática não aceita nada colado na
sequência. Escrever not (width >= 48rem) and (hover: hover) querendo dizer
“estreito e com mouse” produz uma consulta inválida — e consulta inválida não
some do arquivo nem grita no console: ela simplesmente nunca vale. Medi as quatro
formas num viewport de 390px, num contexto com mouse (hover: hover verdadeiro):
@media not (width >= 48rem) { } /* nega e para por aí */
@media not (width >= 48rem) and (hover: hover) { } /* inválida: nunca vale */
@media (not (width >= 48rem)) and (hover: hover) { } /* o que você queria */
@media not screen and (width >= 48rem) { } /* nega o conjunto */A segunda linha é a que quase todo mundo escreve, e ela não disse nada: o
navegador guardou o bloco e nunca o ativou, num viewport de 390px que é estreito
e tem mouse. Um par de parênteses em volta da negação resolve — é a terceira
linha. A quarta é a versão antiga da mesma confusão, a que vale para tipo de
mídia: not screen and (width >= 48rem) nega o conjunto inteiro, ou seja, “não é
o caso de ser tela larga” — e por isso pegou numa tela de 390px.
A regra prática: só use not sozinho, sobre uma condição só. Precisou misturar
com outra condição, ponha parênteses explícitos em volta da negação e leia a
expressão em voz alta antes de confiar nela.
O erro que não aparece no console
Este é o erro mais comum com media query, e o mais cruel: ele não gera aviso nenhum. Falta a unidade no número.
.catalogo { display: grid; gap: 1rem; grid-template-columns: 1fr; }
@media (min-width: 768) {
.catalogo { grid-template-columns: repeat(3, 1fr); }
}Rodei isso num viewport de 1200px, contando as regras que o navegador guardou e as mensagens que ele emitiu:
Olhe os quatro fatos juntos. A regra está na folha de estilo — ela aparece
normalmente no DevTools, o que faz você jurar que está tudo certo. O layout
aplicado é o de uma coluna, em 1200px de largura. A consulta responde false. E
o console está limpo: zero mensagens.
Uma media query inválida não é ignorada como se não existisse; ela vira
“nunca verdadeira”. Nenhuma largura de tela ativa (min-width: 768), nem 320px
nem 1600px. O único comprimento que o CSS aceita sem unidade é o zero:
(min-width: 0) é válido, (min-width: 768) não.
O diagnóstico rápido: se um bloco @media inteiro parece morto, abra o console
e execute matchMedia('(min-width: 768)').matches. Se der false numa tela que
claramente é larga, o problema está na consulta, não no seletor lá dentro. Essa
mesma função é o que a varredura deste artigo usa por baixo — ela devolve
exatamente o que o navegador decidiu, sem intermediário.
Antes de sair procurando culpado no espaçamento, veja o que não derruba uma media query. Testei quatro variações de escrita da mesma consulta, num viewport de 1200px, ao lado do caso sem unidade:
@media(min-width: 48rem) { } /* sem espaço depois de @media */
@media (min-width:48rem) { } /* sem espaço depois dos dois-pontos */
@media (width>=48rem) { } /* sem espaço em volta do operador */
@media (min-width: 48REM) { } /* unidade em maiúsculas */
@media (min-width: 768) { } /* sem unidade */Espaço em branco e caixa da unidade são irrelevantes: as quatro primeiras
funcionam. A única que morre é a que perdeu o px. Isso reduz muito a busca
quando algo não aplica — vá direto conferir se todo número tem unidade.
Onde a media query já não é a resposta
Media query pergunta pela janela. Boa parte dos problemas de layout é sobre o espaço que o componente recebeu — e são coisas diferentes. Um card de livro na coluna principal e o mesmo card na barra lateral vivem na mesma janela e têm larguras completamente diferentes.
Comparei o catálogo com três breakpoints contra o mesmo catálogo sem media query
nenhuma, usando repeat(auto-fit, minmax(15rem, 1fr)):
/* com media query: quatro regras */
.com-mq { display: grid; gap: 1rem; grid-template-columns: 1fr; }
@media (width >= 30rem) { .com-mq { grid-template-columns: repeat(2, 1fr); } }
@media (width >= 48rem) { .com-mq { grid-template-columns: repeat(3, 1fr); } }
@media (width >= 64rem) { .com-mq { grid-template-columns: repeat(4, 1fr); } }
/* sem media query: uma linha */
.sem-mq { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); }Praticamente o mesmo comportamento com uma regra em vez de quatro — e a versão sem media query continua distribuindo em 1280px e 1600px, onde a outra travou em quatro colunas. Esse padrão está detalhado em auto-fit e minmax.
Três casos em que a media query é a ferramenta errada:
- Número de colunas de uma grade: use
auto-fitcomminmax. - Tamanho de fonte que cresce com a tela: use
clamp(), que interpola de forma contínua em vez de dar saltos. - Componente que precisa reagir ao próprio espaço: use container queries, que perguntam pela largura do pai e não pela da janela.
Sobra para a media query o que ela faz melhor: mudanças de layout da página inteira — a barra lateral que aparece, o menu que deixa de ser sanduíche — e as preferências do sistema, que não têm substituto.
O que treinar agora
Abra um projeto seu, escolha o componente que mais quebra e faça a medição desta
lição: descubra em que largura exata ele estoura, converta para rem e troque o
breakpoint copiado pelo breakpoint medido. Depois passe as faixas fechadas para
a sintaxe de intervalo e apague os .99.
O próximo passo da trilha de CSS é fazer o componente responder ao espaço que ele recebeu, e não à janela. Para ver onde a responsividade entra na ordem de estudo, o guia completo de CSS mostra o mapa inteiro.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Quantos breakpoints um site precisa ter?
Dá para escrever media query dentro de um seletor?
Qual a diferença entre "@media screen" e "@media all"?
Ainda vale usar o atributo media na tag link?
Como testar sem ter os aparelhos?
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 (headless) via Playwright 1.62.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — Usando media queries — developer.mozilla.org
- W3C — Media Queries Level 4 — w3.org
- MDN — prefers-reduced-motion — developer.mozilla.org



