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

overflow no CSS: hidden, auto, scroll e clip

O que fazer com o conteúdo que não cabe na caixa: rolagem, corte, reticências no texto e a barra que faz o layout inteiro pular para o lado.

Rodolfo Mori15 min de leitura

overflow decide o que o navegador faz com o conteúdo que não cabe na caixa: deixar vazar por cima do resto (visible), cortar (hidden e clip) ou transformar a caixa numa área rolável (scroll e auto). A escolha não é estética: ela muda a largura útil do conteúdo, cria um contexto de formatação novo e pode matar um position: sticky três níveis abaixo.

Os exemplos são todos do catálogo on-line da Biblioteca Aurora: ficha de livro, prateleira de capas, painel de filtros. Cada número deste artigo saiu de uma medição real, feita num Chromium 151 dirigido pelo Playwright numa janela de 900 × 700 pixels — os scripts estão no texto e você consegue repetir. Toda página de teste começa com body { margin: 0; font: 16px/24px system-ui; }, e é dessa linha que vêm os múltiplos de 24 que aparecem nas medições de altura.

visible, hidden, clip, scroll e auto lado a lado

A ficha do livro tem altura fixa e a sinopse é maior do que ela. Cinco fichas iguais, cinco valores de overflow:

html
<div class="ficha" id="visivel">
  <p>Dom Casmurro, de Machado de Assis. Bento Santiago revisita a infância
     no Rio e a amizade com Capitu.</p>
</div>
<!-- e mais quatro fichas idênticas a esta, com os id
     oculto, cortado, rolagem e auto -->
css
body { margin: 0; font: 16px/24px system-ui; }

.ficha { width: 240px; height: 90px; border: 1px solid; margin: 24px; }

#visivel { overflow: visible; }
#oculto  { overflow: hidden; }
#cortado { overflow: clip; }
#rolagem { overflow: scroll; }
#auto    { overflow: auto; }

Em vez de olhar para a tela, medimos. clientHeight é a altura visível por dentro, scrollHeight é a altura de tudo que existe ali, e scrollTop diz se a caixa aceitou ser rolada:

js
for (const id of ['visivel', 'oculto', 'cortado', 'rolagem', 'auto']) {
  const caixa = document.getElementById(id);
  caixa.scrollTop = 40; // tenta rolar 40px para baixo

  console.log(
    getComputedStyle(caixa).overflow.padEnd(8),
    'clientHeight', caixa.clientHeight,
    '| scrollHeight', caixa.scrollHeight,
    '| scrollTop', caixa.scrollTop
  );
}
visible clientHeight 90 | scrollHeight 112 | scrollTop 0 hidden clientHeight 90 | scrollHeight 128 | scrollTop 38 clip clientHeight 90 | scrollHeight 112 | scrollTop 0 scroll clientHeight 90 | scrollHeight 128 | scrollTop 38 auto clientHeight 90 | scrollHeight 128 | scrollTop 38

Três coisas saltam daí.

A primeira: hidden rola. Ele cortou a sinopse na tela, mas a caixa virou uma área rolável de verdade — o scrollTop foi para 38 (que é 128 - 90, o máximo possível). Quem faz isso não é só o seu JavaScript: o navegador também rola sozinho quando o usuário dá Tab até um campo que está escondido lá embaixo. O resultado é um layout que “pula” sem ninguém ter tocado no mouse.

A segunda: clip não rola. O scrollTop continuou 0. clip corta e encerra o assunto; é o valor certo quando você só quer impedir que um enfeite vaze. Ele é mais novo que os outros e existe justamente porque hidden carregava esse efeito colateral.

A terceira: repare que scrollHeight muda entre 112 e 128 dependendo do valor. Numa caixa que rola, a margem inferior do parágrafo entra na conta da área rolável; numa caixa visible, não entra. Não é bug — é a diferença entre “conteúdo que transborda” e “área que rola”.

valor corta? cria área rolável? mostra barra
visible não, vaza por cima não nunca
hidden sim sim, só sem barra nunca
clip sim não nunca
scroll sim sim sempre, mesmo sem precisar
auto sim sim só quando o conteúdo passa

Na prática do dia a dia: auto para painéis que às vezes têm muito conteúdo, clip para cortar enfeite, e hidden só quando você quer mesmo a área rolável sem barra. scroll serve para reservar a barra e evitar o layout pulando — o que hoje se resolve melhor com scrollbar-gutter, daqui a duas seções. Nos dois casos vale a mesma ressalva, e ela vem medida mais adiante: onde a barra é sobreposta, ela não ocupa layout, não existe pulo para evitar, e nem scroll nem scrollbar-gutter reservam coisa alguma.

overflow-x e overflow-y não são tão independentes quanto parecem

Você escreve overflow-x: hidden e deixa o eixo vertical quieto. O navegador não deixa quieto. Quatro estantes, quatro combinações:

css
body { margin: 0; font: 16px/24px system-ui; }

/* quatro <div class="estante"> vazias, com os id a, b, c e d */
.estante { width: 300px; height: 120px; }

#a { overflow-x: hidden; overflow-y: visible; }
#b { overflow-x: hidden; overflow-y: clip; }
#c { overflow-x: scroll; overflow-y: visible; }
#d { overflow-x: clip;   overflow-y: visible; }
js
for (const id of ['a', 'b', 'c', 'd']) {
  const { overflowX, overflowY } = getComputedStyle(document.getElementById(id));
  console.log(`#${id} computado ->  overflow-x: ${overflowX} | overflow-y: ${overflowY}`);
}
#a computado -> overflow-x: hidden | overflow-y: auto #b computado -> overflow-x: hidden | overflow-y: hidden #c computado -> overflow-x: scroll | overflow-y: auto #d computado -> overflow-x: clip | overflow-y: visible

O visible que você escreveu virou auto em #a e em #c. A regra da especificação é essa: visible num eixo é incompatível com um valor de rolagem no outro, e quem perde é o visible. A caixa inteira vira contêiner de rolagem, nos dois eixos.

#b mostra o mesmo acontecendo com clip, que foi rebaixado a hidden. E #d é a única linha em que o que você escreveu sobreviveu inteiro: clip é o único valor que convive com visible no outro eixo.

A barra de rolagem come largura do conteúdo

Antes de medir, vale separar os três números que o DOM oferece. Eles quase sempre são confundidos, e é neles que a barra de rolagem aparece:

clientWidth offsetWidth clientHeight scrollHeight o que não coube barra

offsetWidth vai de borda a borda. clientWidth para antes da barra de rolagem. A diferença entre os dois é exatamente o que a barra tirou do seu conteúdo. Se isso ainda soa estranho, a lição de box model no CSS mostra de onde saem essas caixas.

O painel de resultados da biblioteca tem 545px e uma grade de capas que se ajusta sozinha:

css
body { margin: 0; font: 16px/24px system-ui; }

.painel { width: 545px; height: 260px; overflow-y: auto; }
.classica::-webkit-scrollbar { width: 15px; }   /* barra que ocupa espaço */
.reserva { scrollbar-gutter: stable; }

.grade { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
.capa { height: 120px; background: #ddd; }

/* três painéis, cada um com uma .grade de nove .capa dentro:
   #padrao   = <div class="painel">
   #soGutter = <div class="painel reserva">
   #classica = <div class="painel classica reserva">  */
js
for (const id of ['padrao', 'soGutter', 'classica']) {
  const painel = document.getElementById(id);
  const grade = painel.firstElementChild;
  const colunas = getComputedStyle(grade).gridTemplateColumns.split(' ').length;

  console.log(
    `#${id}`.padEnd(10),
    `offsetWidth ${painel.offsetWidth}`,
    `| clientWidth ${painel.clientWidth}`,
    `| a barra levou ${painel.offsetWidth - painel.clientWidth}px`,
    `| colunas da grade: ${colunas}`
  );
}
#padrao offsetWidth 545 | clientWidth 545 | a barra levou 0px | colunas da grade: 3 #soGutter offsetWidth 545 | clientWidth 545 | a barra levou 0px | colunas da grade: 3 #classica offsetWidth 545 | clientWidth 530 | a barra levou 15px | colunas da grade: 2

Na última linha, quinze pixels custaram uma coluna inteira. A grade pedia minmax(170px, 1fr) com gap: 16px: em 545px cabiam três colunas (3 × 170 + 2 × 16 = 542), em 530px cabem duas. Isso é o auto-fill do Grid fazendo o trabalho dele com a largura que sobrou — e a largura que sobrou depende da barra.

E as duas primeiras linhas explicam por que esses 15px não apareceram sozinhos.

scrollbar-gutter: stable e o layout que para de pular

O painel com barra sobreposta não muda de largura. O painel com barra clássica muda: com três resultados ele não rola e tem a largura cheia; com quarenta ele rola e encolhe 15px. Todo o conteúdo se reacomoda no meio da busca.

scrollbar-gutter: stable reserva o espaço da barra o tempo todo, exista conteúdo para rolar ou não:

css
body { margin: 0; font: 16px/24px system-ui; }

.painel { width: 545px; height: 260px; overflow-y: auto; }
.classica::-webkit-scrollbar { width: 15px; }
.reserva { scrollbar-gutter: stable; }

/* quatro painéis .painel.classica; os dois "Reserva" ganham também .reserva.
   #curto e #curtoReserva têm 3 resultados dentro; #longo e #longoReserva, 40. */
js
const largura = (id) => document.getElementById(id).clientWidth;

console.log('gutter: auto     3 resultados:', largura('curto'), '| 40 resultados:', largura('longo'));
console.log('gutter: stable   3 resultados:', largura('curtoReserva'), '| 40 resultados:', largura('longoReserva'));
gutter: auto 3 resultados: 545 | 40 resultados: 545 gutter: stable 3 resultados: 530 | 40 resultados: 530

As duas linhas estão planas, e por motivos diferentes. A de cima está plana porque a barra deste Mac é sobreposta e nunca ocupa espaço. A de baixo está plana por construção: stable promete 530px agora e daqui a quarenta resultados. Com a barra clássica da seção anterior, a linha de cima leria 545 | 530 — é esse par que faz o conteúdo centralizado andar 7,5px para o lado quando a página cresce.

A regra que eu sigo: scrollbar-gutter: stable no html de qualquer site com páginas de altura variável, e no contêiner de qualquer lista que carrega mais itens depois. Custa uma linha e elimina uma categoria inteira de bug visual.

Reticências: text-overflow, white-space e o line-clamp de várias linhas

text-overflow: ellipsis sozinho não faz nada, e essa é a pergunta que mais aparece. Ele precisa de duas companhias: o texto não pode quebrar linha, e a caixa precisa cortar. Quatro títulos, quatro receitas:

css
body { margin: 0; font: 16px/24px system-ui; }

/* quatro <p class="titulo"> com o mesmo texto de 54 caracteres:
   "Grande Sertão: Veredas, edição comemorativa de 70 anos".
   #a fica só com o que .titulo dá; b, c e d acrescentam uma receita cada. */
.titulo { width: 220px; overflow: hidden; text-overflow: ellipsis; }

#b { white-space: nowrap; }
#c { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
#d { display: block; line-clamp: 2; }
js
for (const id of ['a', 'b', 'c', 'd']) {
  const titulo = document.getElementById(id);
  const cortado = titulo.scrollWidth > titulo.clientWidth
    || titulo.scrollHeight > titulo.clientHeight;

  console.log(
    `#${id}`,
    '| linhas visíveis', titulo.clientHeight / 24,
    '| linhas totais', titulo.scrollHeight / 24,
    '| cortado?', cortado,
    '| textContent', titulo.textContent.length, 'caracteres'
  );
}
#a | linhas visíveis 3 | linhas totais 3 | cortado? false | textContent 54 caracteres #b | linhas visíveis 1 | linhas totais 1 | cortado? true | textContent 54 caracteres #c | linhas visíveis 2 | linhas totais 3 | cortado? true | textContent 54 caracteres #d | linhas visíveis 3 | linhas totais 3 | cortado? false | textContent 54 caracteres

#a tem overflow: hidden e text-overflow: ellipsis, e mesmo assim mostra o título inteiro em três linhas: sem white-space: nowrap, o texto simplesmente quebra e nunca chega a estourar. #b acrescenta o nowrap e aí, sim: uma linha só, scrollWidth maior que clientWidth, reticências na tela.

#c é o corte de várias linhas, com as três declarações -webkit- que precisam andar juntas: duas linhas visíveis de três. E #d é o aviso: a versão padronizada, line-clamp: 2 sem o -webkit-box, ainda não funcionou neste Chromium 151 — mostrou as três linhas. Continue escrevendo o trio antigo por enquanto.

Repare na última coluna: textContent tem 54 caracteres nos quatro casos. O corte é só visual. O texto continua inteiro no DOM, o leitor de tela lê tudo, e um Ctrl+C copia tudo. Isso é bom para acessibilidade e ruim se você achava que tinha resolvido o tamanho do dado no banco.

E é dali que sai o truque prático: scrollWidth > clientWidth é como você descobre, em JavaScript, se um título foi cortado — e só então coloca um title com o nome completo, em vez de espalhar tooltip em elemento que nem precisava.

overflow cria um contexto de formatação — e mata o position: sticky

Qualquer valor de overflow diferente de visible faz a caixa virar um bloco de formatação novo: um mundinho onde os floats ficam presos e as margens não atravessam a borda. Isso é usado há vinte anos como truque, e continua valendo.

css
body { margin: 0; font: 16px/24px system-ui; }

.destaque { width: 400px; background: #eee; }
.capa { float: left; width: 90px; height: 130px; }
.selo { margin-top: 40px; }

#b, #d { overflow: hidden; }
html
<section class="destaque" id="a"><div class="capa"></div></section>
<section class="destaque" id="b"><div class="capa"></div></section>
<section class="destaque" id="c"><p class="selo">Novidade</p></section>
<section class="destaque" id="d"><p class="selo">Novidade</p></section>
js
for (const id of ['a', 'b', 'c', 'd']) {
  const secao = document.getElementById(id);
  const filho = secao.firstElementChild;
  const distancia = filho.getBoundingClientRect().top - secao.getBoundingClientRect().top;

  console.log(
    `#${id}  overflow: ${getComputedStyle(secao).overflow.padEnd(8)}`,
    `| altura da seção: ${secao.offsetHeight}px`,
    `| o filho começa ${distancia}px abaixo do topo dela`
  );
}
#a overflow: visible | altura da seção: 0px | o filho começa 0px abaixo do topo dela #b overflow: hidden | altura da seção: 130px | o filho começa 0px abaixo do topo dela #c overflow: visible | altura da seção: 24px | o filho começa 0px abaixo do topo dela #d overflow: hidden | altura da seção: 80px | o filho começa 40px abaixo do topo dela

#a tem uma capa flutuante de 130px dentro e mede zero de altura: o float não conta. Com overflow: hidden, #b passa a medir os 130px. #c e #d mostram o outro lado: o margin-top: 40px do selo escapou da seção em #c (a altura ficou em 24px, só a linha de texto), e ficou preso dentro dela em #d (40 + 24 + 16 = 80px). É a margem que colapsa, o mesmo fenômeno de quando o margin-top não funciona.

Só que esse mesmo poder tem um custo, e é o bug mais frequente da lista. position: sticky se prende ao contêiner de rolagem mais próximo. Se um ancestral virou contêiner de rolagem sem querer, o sticky passa a se prender a ele — e se esse ancestral não rola, o elemento não gruda em lugar nenhum:

css
body { margin: 0; font: 16px/24px system-ui; }

.catalogo { display: grid; grid-template-columns: 220px 1fr; gap: 24px; }
.filtros { position: sticky; top: 0; height: 120px; }
.resultados { height: 2400px; }

#tapado { overflow-x: hidden; }
html
<main class="catalogo" id="livre">
  <aside class="filtros" id="f1">Filtros</aside>
  <div class="resultados"></div>
</main>

<main class="catalogo" id="tapado">
  <aside class="filtros" id="f2">Filtros</aside>
  <div class="resultados"></div>
</main>
js
const quadro = () => new Promise((ok) => requestAnimationFrame(ok));
const topo = (id) => Math.round(document.getElementById(id).getBoundingClientRect().top);

window.scrollTo(0, 400); // 400px dentro do primeiro catálogo
await quadro();
console.log('.catalogo sem overflow      overflow-y computado:',
  getComputedStyle(document.getElementById('livre')).overflowY,
  '| topo do aside:', topo('f1') + 'px');

window.scrollTo(0, 2800); // 400px dentro do segundo catálogo
await quadro();
console.log('.catalogo overflow-x: hidden  overflow-y computado:',
  getComputedStyle(document.getElementById('tapado')).overflowY,
  '| topo do aside:', topo('f2') + 'px');
.catalogo sem overflow overflow-y computado: visible | topo do aside: 0px .catalogo overflow-x: hidden overflow-y computado: auto | topo do aside: -400px

O painel de filtros do primeiro catálogo ficou colado no topo da janela. O do segundo subiu 400px junto com a página, ou seja, saiu da tela. E a causa está no próprio <main class="catalogo">, o pai imediato do aside: aquele overflow-x: hidden virou overflow-y: auto, pela regra da segunda seção deste artigo. Ninguém escreveu overflow-y em lugar nenhum, e mesmo assim o main virou o contêiner de rolagem mais próximo do sticky — um contêiner que tem altura de sobra e nunca rola.

O detalhe cruel é que o overflow-x: hidden costuma ter sido colocado ali para apagar uma rolagem horizontal — e o preço veio dois meses depois, num sticky que “parou de funcionar sozinho”. Vale reler position no CSS com essa regra na mão.

scroll-behavior, overscroll-behavior e scroll-snap

Quando a caixa vira área rolável, três propriedades passam a valer para ela. A prateleira de lançamentos da biblioteca usa as três:

css
body { margin: 0; font: 16px/24px system-ui; }

/* três prateleiras iguais, com oito <article class="capa"> cada:
   #livre, #encaixa e #suave */
.prateleira { display: flex; gap: 20px; width: 600px; overflow-x: auto; }
.capa { flex: 0 0 160px; height: 220px; }

#encaixa { scroll-snap-type: x mandatory; }
#encaixa .capa { scroll-snap-align: start; }
#suave { scroll-behavior: smooth; }
js
const espera = (ms) => new Promise((ok) => setTimeout(ok, ms));
const livre = document.getElementById('livre');
const encaixa = document.getElementById('encaixa');
const suave = document.getElementById('suave');

console.log('prateleira: clientWidth', livre.clientWidth,
  '| scrollWidth', livre.scrollWidth,
  '| rolagem máxima', livre.scrollWidth - livre.clientWidth, 'px');

livre.scrollLeft = 130;
encaixa.scrollLeft = 130;
await espera(300);
console.log('scrollLeft = 130  ->  sem snap:', livre.scrollLeft, '| com snap:', encaixa.scrollLeft);

encaixa.scrollLeft = 260;
await espera(300);
console.log('scrollLeft = 260  ->  com snap:', encaixa.scrollLeft);

suave.scrollLeft = 540;
console.log('scroll-behavior: smooth  ->  no ato:', suave.scrollLeft);
await espera(600);
console.log('scroll-behavior: smooth  ->  600ms depois:', suave.scrollLeft);
prateleira: clientWidth 600 | scrollWidth 1420 | rolagem máxima 820 px scrollLeft = 130 -> sem snap: 130 | com snap: 180 scrollLeft = 260 -> com snap: 180 scroll-behavior: smooth -> no ato: 0 scroll-behavior: smooth -> 600ms depois: 540

Sem snap, scrollLeft = 130 fica em 130 e a capa aparece cortada pela metade. Com scroll-snap-type: x mandatory, o navegador puxa para 180 — o começo da segunda capa, porque as capas medem 160px com 20px de intervalo. E 260 também virou 180: o snap escolhe o ponto mais próximo, para trás se for o caso.

A terceira dupla de linhas mostra o scroll-behavior: smooth: no instante seguinte à atribuição, scrollLeft ainda era 0; a animação estava começando. Seiscentos milissegundos depois, chegou nos 540. Guarde isso: com smooth, ler scrollLeft logo depois de escrever nele devolve o valor antigo.

Falta a terceira. Quando você chega ao fim de uma lista rolável e continua rolando, o navegador repassa a rolagem para a página atrás — é o scroll chaining. overscroll-behavior: contain interrompe esse repasse:

css
/* a página precisa ter para onde rolar, senão não há encadeamento para observar */
body { margin: 0; font: 16px/24px system-ui; height: 3000px; }

/* duas listas de 800px de conteúdo: <div class="sugestoes" id="solto"> e id="preso" */
.sugestoes { width: 400px; height: 200px; overflow-y: auto; margin: 40px; }
#preso { overscroll-behavior: contain; }

Este é o único teste do artigo que não roda dentro da página: scrollLeft no JavaScript não encadeia rolagem nenhuma, então precisa de uma roda de mouse de verdade. Quem dá a rolada é o Playwright, de fora, com a lista já no fim:

js
import { chromium } from 'playwright-core';
import { pathToFileURL } from 'node:url';

const navegador = await chromium.launch();

for (const [id, y] of [['solto', 140], ['preso', 380]]) {
  const p = await navegador.newPage({ viewport: { width: 900, height: 600 } });
  await p.goto(pathToFileURL('sugestoes.html').href);

  await p.evaluate((alvo) => { document.getElementById(alvo).scrollTop = 9999; }, id);
  await p.mouse.move(200, y);   // ponteiro em cima da lista
  await p.mouse.wheel(0, 400);  // roda de verdade, 400px
  await p.waitForTimeout(500);

  const r = await p.evaluate((alvo) => ({
    modo: getComputedStyle(document.getElementById(alvo)).overscrollBehaviorY,
    lista: document.getElementById(alvo).scrollTop,
    pagina: window.scrollY,
  }), id);

  console.log(`#${id} overscroll-behavior: ${r.modo.padEnd(8)} lista no fim (scrollTop ${r.lista}) -> a página rolou ${r.pagina}px`);
  await p.close();
}

await navegador.close();
#solto overscroll-behavior: auto lista no fim (scrollTop 600) -> a página rolou 400px #preso overscroll-behavior: contain lista no fim (scrollTop 600) -> a página rolou 0px

As duas listas estavam no fim. Na primeira, os 400px da roda vazaram para a página. Na segunda, morreram na lista. É a linha que falta em todo menu de sugestão de busca, todo chat lateral e todo modal com conteúdo comprido.

Achando o elemento culpado pela rolagem horizontal

Agora o problema que ninguém consegue depurar no olho. A página do catálogo rola para o lado e não dá para ver por quê:

html
<style>
  body { margin: 0; font: 16px/24px system-ui; }
  .pagina { max-width: 960px; margin: 0 auto; padding: 0 24px; }
  .faixa { width: 100vw; background: #f2e6c9; padding: 16px 0; }
  .resultados { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); list-style: none; padding: 0; }
  .cartao { border: 1px solid #ccc; padding: 12px; }
  .codigo { font: 14px/20px ui-monospace, monospace; }
</style>
<main class="pagina">
  <section class="faixa">Semana do Livro Infantil na Biblioteca Aurora</section>
  <ul class="resultados">
    <li class="cartao"><h3>Dom Casmurro</h3>
      <p class="codigo">https://catalogo.bibliotecaaurora.org.br/emprestimos/9788535902778</p></li>
    <li class="cartao"><h3>Vidas Secas</h3><p class="codigo">9788501012111</p></li>
    <li class="cartao"><h3>Quarto de Despejo</h3><p class="codigo">9788503012577</p></li>
  </ul>
</main>

Em vez de comentar <div> por <div>, cole este script no console. Ele faz duas varreduras: a primeira acha as caixas que têm conteúdo escondido por dentro, a segunda acha quem literalmente passa da borda direita da janela.

js
const nome = (el) =>
  el.tagName.toLowerCase() +
  (el.id ? `#${el.id}` : '') +
  (el.classList.length ? `.${[...el.classList].join('.')}` : '');

const janela = document.documentElement.clientWidth;
console.log(`janela ${janela}px | estouro horizontal da página: ${document.documentElement.scrollWidth - janela}px`);

for (const el of document.querySelectorAll('*')) {
  if (el.scrollWidth > el.clientWidth) {
    console.log(`  rola por dentro: ${nome(el)} — clientWidth ${el.clientWidth}, scrollWidth ${el.scrollWidth}`);
  }
}

for (const el of document.querySelectorAll('*')) {
  const direita = Math.round(el.getBoundingClientRect().right);
  if (direita > janela) {
    console.log(`  passa da borda:  ${nome(el)} — termina em ${direita}px, ${direita - janela}px além da janela`);
  }
}
janela 900px | estouro horizontal da página: 24px rola por dentro: html — clientWidth 900, scrollWidth 924 rola por dentro: body — clientWidth 900, scrollWidth 924 rola por dentro: main.pagina — clientWidth 900, scrollWidth 924 rola por dentro: li.cartao — clientWidth 271, scrollWidth 567 rola por dentro: p.codigo — clientWidth 247, scrollWidth 555 passa da borda: section.faixa — termina em 924px, 24px além da janela

A lista de cima é a cadeia de contêineres — html, body e main.pagina apenas repetem o mesmo estouro de 24px que veio de baixo. A resposta está na segunda lista, e ela tem um item só: section.faixa, terminando em 924px numa janela de 900. A causa é width: 100vw dentro de um pai com padding: 0 24px. A faixa começa no pixel 24 e mede a janela inteira, então sobra pela direita exatamente o valor do padding. Vale conferir a lição de unidades no CSS: vw mede a janela, % mede o pai — dentro de um pai com padding, as duas nunca coincidem.

E os li.cartao e p.codigo? Aparecem só na primeira lista porque a URL de empréstimo é uma palavra sem espaço, larga demais para o cartão. Ela vaza 308px para fora dele — 555 de scrollWidth contra 247 de clientWidth — e ainda assim termina em 592px, dentro da janela de 900. É um defeito visual real, passa por cima do cartão vizinho, e não é o que faz a página rolar. As duas listas são separadas exatamente para você não confundir os dois.

A correção são duas linhas:

diff
-  .faixa { width: 100vw; background: #f2e6c9; padding: 16px 0; }
+  .faixa { width: 100%; background: #f2e6c9; padding: 16px 0; }
-  .codigo { font: 14px/20px ui-monospace, monospace; }
+  .codigo { font: 14px/20px ui-monospace, monospace; overflow-wrap: anywhere; }
janela 900px | estouro horizontal da página: 0px

As duas varreduras voltaram vazias.

Agora o contraexemplo, que é a razão de tudo isto existir. Em vez de corrigir, alguém escreve body { overflow-x: hidden } e roda o mesmo script:

janela 900px | estouro horizontal da página: 24px rola por dentro: html — clientWidth 900, scrollWidth 924 rola por dentro: body — clientWidth 900, scrollWidth 924 rola por dentro: main.pagina — clientWidth 900, scrollWidth 924 rola por dentro: li.cartao — clientWidth 271, scrollWidth 567 rola por dentro: p.codigo — clientWidth 247, scrollWidth 555 passa da borda: section.faixa — termina em 924px, 24px além da janela

Idêntica, linha por linha. O scrollWidth continua 924. A faixa continua terminando em 924px. Nada foi consertado: a barra sumiu da tela e o resto continua exatamente onde estava.

Falta responder o que esse paliativo custa — e aqui a resposta mais repetida na internet está errada. overflow-x: hidden no body não quebra o position: sticky da página. Existe uma exceção na especificação: quando o html está em visible, o navegador propaga o overflow do body para a viewport, e o body fica valendo visible. Não nasce contêiner de rolagem nenhum. Ponha a mesma linha um nível abaixo, num wrapper qualquer, e aí sim. Duas páginas, diferentes por uma linha só:

html
<style>
  body { margin: 0; font: 16px/24px system-ui; }
  .pagina { max-width: 960px; margin: 0 auto; padding: 0 24px; }
  .faixa { width: 100vw; background: #f2e6c9; padding: 16px 0; }
  .barra { position: sticky; top: 0; height: 60px; background: #ddd; }
  .resultados { height: 2400px; }

  /* cada página tem SÓ UMA destas duas linhas; o data-caso do body diz qual */
  body { overflow-x: hidden; }      /* página A: o paliativo no body */
  .pagina { overflow-x: hidden; }   /* página B: o mesmo hidden no wrapper */
</style>
<body data-caso="body { overflow-x: hidden }">
<main class="pagina">
  <section class="faixa">Semana do Livro Infantil na Biblioteca Aurora</section>
  <div class="barra">Filtros</div>
  <div class="resultados"></div>
</main>
js
const quadro = () => new Promise((ok) => requestAnimationFrame(ok));
const eixos = (el) => `${getComputedStyle(el).overflowX} / ${getComputedStyle(el).overflowY}`;

window.scrollTo(0, 400);
await quadro();

console.log(`página com ${document.body.dataset.caso}`);
console.log('  body         overflow-x / y:', eixos(document.body));
console.log('  main.pagina  overflow-x / y:', eixos(document.querySelector('.pagina')));
console.log('  topo do sticky depois de rolar 400px:',
  Math.round(document.querySelector('.barra').getBoundingClientRect().top) + 'px');
página com body { overflow-x: hidden } body overflow-x / y: hidden / auto main.pagina overflow-x / y: visible / visible topo do sticky depois de rolar 400px: 0px página com .pagina { overflow-x: hidden } body overflow-x / y: visible / visible main.pagina overflow-x / y: hidden / auto topo do sticky depois de rolar 400px: -344px

Na página A o body computa hidden, mas quem recebe esse valor de fato é a viewport — que já era a área rolável da página. Nada mudou de dono, e o sticky ficou colado em 0px. Na página B o mesmo hidden está no main.pagina, que virou contêiner de rolagem de verdade (repare no overflow-y: auto que ninguém escreveu), e a barra de filtros foi parar 344px acima do topo da janela: sumiu da tela.

O problema é que o estouro raramente está no body — está numa faixa lá dentro, e é no wrapper dela que a pessoa acaba colando o overflow-x: hidden. É o caminho mais curto entre “sumi com a barra” e “o sticky parou de funcionar sozinho”.

O que vem depois

overflow é a última peça do jeito que a caixa se comporta. A próxima é o que se desenha nela: fundo, cantos e sombra ficam em background, border-radius e box-shadow. Se você quiser o mapa inteiro antes de continuar, o guia de CSS mostra em que ordem cada assunto entra e o que já está publicado.

E deixe o caçador de rolagem horizontal salvo como snippet no DevTools. É o tipo de script que você usa uma vez por mês e economiza meia hora toda vez.

  • css
  • overflow
  • scroll
  • text-overflow
  • scroll-snap

Perguntas frequentes

Qual a diferença prática entre overflow hidden e clip?
Com hidden a caixa continua sendo uma área rolável — o JavaScript ainda consegue mudar o scrollTop, e o navegador pode rolar sozinho ao focar um campo escondido. Com clip a caixa não rola de jeito nenhum. Para só cortar um enfeite que vaza, clip é mais seguro.
Por que meu position sticky parou de funcionar?
Quase sempre porque algum elemento acima dele na árvore ganhou overflow diferente de visible, muitas vezes um overflow-x hidden colocado para apagar rolagem horizontal. O sticky passa a se prender a esse ancestral, que não rola, e o elemento simplesmente sobe junto com a página.
text-overflow ellipsis não coloca as reticências. O que falta?
Faltam as duas condições que ele exige: white-space nowrap, para o texto não quebrar em várias linhas, e overflow hidden ou clip na mesma caixa. Sem as três declarações juntas, o navegador ignora o ellipsis.
overflow-x hidden resolve rolagem horizontal indesejada?
Não resolve, esconde. O elemento continua fora da tela e a área rolável continua maior que a janela — só a barra some. E o efeito colateral depende de onde você escreve a linha: no body o navegador propaga o overflow para a viewport e o sticky sobrevive; em qualquer wrapper abaixo dele, o hidden cria um contêiner de rolagem e quebra o sticky que estiver dentro.
Como saber por JavaScript se um texto foi cortado?
Compare scrollWidth com clientWidth no mesmo elemento. Se scrollWidth for maior, o conteúdo não coube e as reticências estão aparecendo — é assim que se decide mostrar um title com o texto completo só quando precisa.

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

Fontes consultadas

  1. MDN — overflow — developer.mozilla.org
  2. MDN — scrollbar-gutter — developer.mozilla.org
  3. MDN — text-overflow — developer.mozilla.org
  4. W3C — CSS Overflow Module Level 3 — w3.org
  5. MDN — CSS scroll snap — developer.mozilla.org

Continue por aqui