Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

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

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.

Rodolfo Mori16 min de leitura

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.

html
<!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 meta viewport: innerWidth=980px (min-width:48rem)=true com meta viewport: innerWidth=390px (min-width:48rem)=false

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:

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

js
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();
a partir de 320px -> catalogo em 1 coluna(s), filtros: none a partir de 480px -> catalogo em 2 coluna(s), filtros: none a partir de 768px -> catalogo em 3 coluna(s), filtros: block a partir de 1024px -> catalogo em 4 coluna(s), filtros: block 161 larguras medidas entre 320px e 1600px

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:

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

1281 larguras comparadas, 0 divergências mobile-first.css: 385 bytes desktop-first.css: 394 bytes

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.

css
/* 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.

max-width: 767px min-width: 768px 767 768 767,27px vão sem regra nenhuma

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:

css
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 */
largura real da viewport: 767.272705078125px (max-width: 767px) -> false (min-width: 768px) -> false (max-width: 767.98px) -> true (width < 48rem) -> true layout aplicado: "nenhuma regra pegou"

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:

css
@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:

js
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();
menu horizontal deixa de estourar em: 719px em rem (base 16px): 44.94rem

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:

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

css
: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"; } }
viewport 1200px -> "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:

css
html { font-size: 10px; }
font-size do html: 10px viewport: 600px (min-width: 30rem) -> true (seria 300px se rem valesse 10px; vale 480px) (min-width: 48rem) -> false (seria 480px se rem valesse 10px; vale 768px)

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:

fonte padrão 16px | viewport 900px | (min-width:768px)=true | (min-width:48rem)=true | colunas=3 fonte padrão 24px | viewport 900px | (min-width:768px)=true | (min-width:48rem)=false | colunas=2

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.

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

claro + animação normal fundo rgb(253, 253, 252) | texto rgb(27, 27, 24) | transition 0.3s escuro + animação normal fundo rgb(23, 23, 15) | texto rgb(244, 244, 238) | transition 0.3s escuro + movimento reduzido fundo rgb(23, 23, 15) | texto rgb(244, 244, 238) | transition 0s

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:

notebook (mouse) (hover: hover)=true (hover: none)=false (pointer: fine)=true (pointer: coarse)=false (any-hover: hover)=true celular (dedo) (hover: hover)=false (hover: none)=true (pointer: fine)=false (pointer: coarse)=true (any-hover: hover)=false

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.

css
/* 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.

css
/* 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:

css
/* 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"; } }
1023px | ordem A: "tablet" | ordem B: "tablet" 1024px | ordem A: "desktop" | ordem B: "tablet" 1025px | ordem A: "desktop" | ordem B: "desktop"

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:

css
/* 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) { }
viewport 1200px @media (min-width: 768), (min-width: 48rem) -> "pegou" @media (min-width: 768) and (min-width: 48rem) -> "nao pegou"

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

css
@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 */
viewport 390px | (hover: hover)=true @media not (width >= 48rem) -> "pegou" @media not (width >= 48rem) and (hover: hover) -> "nao pegou" @media (not (width >= 48rem)) and (hover: hover) -> "pegou" @media not screen and (width >= 48rem) -> "pegou"

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.

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

viewport: 1200px regras que o navegador guardou na folha: 2 colunas que o catálogo desenhou: 1 matchMedia('(min-width: 768)').matches: false mensagens no console do navegador: 0

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:

css
@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 */
@media(min-width: 48rem) em 1200px -> "pegou" @media (min-width:48rem) em 1200px -> "pegou" @media (width>=48rem) em 1200px -> "pegou" @media (min-width: 48REM) em 1200px -> "pegou" @media (min-width: 768) em 1200px -> "nao pegou"

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

css
/* 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)); }
largura | com media query | com auto-fit minmax 360px | 1 | 1 480px | 2 | 1 640px | 2 | 2 768px | 3 | 3 900px | 3 | 3 1024px | 4 | 4 1280px | 4 | 5 1600px | 4 | 6

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-fit com minmax.
  • 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.

Ver todos os vídeos do canal
  • css
  • media queries
  • responsivo
  • breakpoint
  • mobile first

Perguntas frequentes

Quantos breakpoints um site precisa ter?
Não existe número certo, existe número medido. Na prática, dois ou três dão conta de um site institucional; um painel com tabela e barra lateral costuma pedir quatro. Se você chegou a sete, provavelmente está corrigindo no breakpoint um problema que era de largura máxima do texto.
Dá para escrever media query dentro de um seletor?
Dá, com o aninhamento nativo do CSS. Você escreve o seletor, abre as chaves e coloca a arroba media lá dentro. O resultado é idêntico ao da forma tradicional; muda só onde a regra fica no arquivo. Todos os navegadores atuais entendem, mas versões antigas ignoram o bloco inteiro.
Qual a diferença entre "@media screen" e "@media all"?
O tipo de mídia diz para que dispositivo a regra vale. Hoje sobraram três úteis - screen, print e all - e o padrão, quando você não escreve nenhum, já é all. Só use screen quando quiser mesmo excluir a impressão.
Ainda vale usar o atributo media na tag link?
Vale como otimização de carregamento. Uma folha declarada com media de impressão é baixada com prioridade baixa e não bloqueia a primeira pintura. Para o CSS principal, prefira a media query dentro do arquivo - um link por breakpoint multiplica as requisições.
Como testar sem ter os aparelhos?
O modo responsivo do DevTools resolve 90 por cento. O que ele não mostra sozinho é a largura exata em que o seu layout quebra, e é isso que uma varredura automatizada entrega em segundos.

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

Fontes consultadas

  1. MDN — Usando media queries — developer.mozilla.org
  2. W3C — Media Queries Level 4 — w3.org
  3. MDN — prefers-reduced-motion — developer.mozilla.org

Continue por aqui