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

Tabelas em HTML: table, thead, tbody, colspan e rowspan

Montar tabela de dados com cabeçalho, mesclar células com colspan e rowspan e marcar th com scope para a tabela fazer sentido sem enxergar.

Rodolfo Mori13 min de leitura

Tabela em HTML é uma grade de duas dimensões: <table> abre, <tr> cria a linha, <td> cria a célula e <th> cria a célula que manda nas outras. O resto — caption, thead, tbody, tfoot, scope, colspan, rowspan — existe para dizer o que cada pedaço significa. Nenhum deles serve para desenhar.

Todos os exemplos aqui saem do estoque da Livraria Vagalume, a mesma livraria de bairro de listas em HTML, a lição anterior desta trilha. A diferença entre as duas cabe numa frase: lista tem um eixo, tabela tem dois.

Uma tabela é um mapa com duas coordenadas

Numa lista de compras, basta descer para encontrar o próximo item. Numa planilha de estoque, você precisa saber a linha e a coluna: “Teclado” cruza com “Quantidade” e produz 12. É por isso que tabela é dado em duas dimensões. A tag <tr> cria a linha; <th> identifica o eixo; <td> guarda o valor no cruzamento.

Não use a analogia da grade para escolher o visual. Um conjunto de cards pode parecer uma grade e ainda não relacionar linhas com colunas; nesse caso não é <table>. Antes de escrever a primeira tag, tente ler uma célula em voz alta: “12 é a quantidade de teclados”. Se você não consegue nomear os dois cabeçalhos que dão sentido ao valor, provavelmente está tentando fazer layout, não representar dados tabulares.

A tabela mínima, e a tag que você não escreveu

Duas linhas com título e preço, do jeito mais direto possível:

html
<table>
  <tr><td>Grande sertão: veredas</td><td>89,90</td></tr>
  <tr><td>A hora da estrela</td><td>54,90</td></tr>
</table>

Isso funciona. Mas não é isso que fica na página. Vou passar exatamente esse texto pelo mesmo parser de HTML que o navegador usa — o jsdom, que implementa o algoritmo da especificação — e imprimir o que sobrou:

js
import { JSDOM } from 'jsdom';

const digitado = `<table>
  <tr><td>Grande sertão: veredas</td><td>89,90</td></tr>
  <tr><td>A hora da estrela</td><td>54,90</td></tr>
</table>`;

const dom = new JSDOM(`<!doctype html><body>${digitado}`);
console.log(dom.window.document.querySelector('table').outerHTML);
<table> <tbody><tr><td>Grande sertão: veredas</td><td>89,90</td></tr> <tr><td>A hora da estrela</td><td>54,90</td></tr> </tbody></table>

Apareceu um <tbody> que ninguém digitou. O modelo de tabela do HTML não aceita linha solta dentro de table: toda tr mora num grupo de linhas, que é thead, tbody ou tfoot. Se você não abre o grupo, o parser abre por você.

Isso não é curiosidade. Tem duas consequências práticas que derrubam gente:

  • O seletor table > tr não casa com nada no HTML que você escreveu, porque o parser pôs um tbody no meio do caminho. O certo é table tr ou tbody > tr.
  • document.querySelector('table').children devolve o tbody, não as linhas.

E existe uma armadilha que só aparece quando o JavaScript entra: o parser insere o tbody, o appendChild não. Quem monta a tabela a partir de um array não passa pelo parser, e a linha fica pendurada direto na table:

js
const tabela = document.querySelector('table');   // <table></table>, vazia

const linha = document.createElement('tr');
linha.innerHTML = '<td>89,90</td>';
tabela.appendChild(linha);                        // não passa pelo parser

console.log('pai da linha        :', linha.parentElement.tagName);
console.log('table > tr casa com :', document.querySelectorAll('table > tr').length);
console.log('tbody > tr casa com :', document.querySelectorAll('tbody > tr').length);
console.log(tabela.outerHTML);
pai da linha : TABLE table > tr casa com : 1 tbody > tr casa com : 0 <table><tr><td>89,90</td></tr></table>

Os dois seletores trocaram de lado. A linha escrita à mão mora em tbody > tr; a linha criada no JavaScript mora em table > tr. Se o seu CSS mira só um dos dois, metade da tabela sai sem estilo — e o bug aparece só depois que os dados chegam. Escrever o tbody no HTML e dar o appendChild nele, e não na table, fecha os dois casos de uma vez.

O que o navegador faz com o que não é célula

O parser não só completa o que falta: ele também expulsa o que não deveria estar ali. Três casos que aparecem em código real:

html
<table><div class="aviso">Estoque de 5 de julho</div><tr><td>3</td></tr></table>

<table><td>89,90</td></table>

<table>Estoque de julho<tr><td>3</td></tr></table>

Passei os três pelo Chrome de verdade, com o playwright-core apontando para o Chrome instalado na máquina, e pedi o innerHTML do body depois de o parser terminar:

js
import { chromium } from 'playwright-core';

const casos = {
  'div dentro da table': '<table><div class="aviso">Estoque de 5 de julho</div><tr><td>3</td></tr></table>',
  'td sem tr': '<table><td>89,90</td></table>',
  'texto solto': '<table>Estoque de julho<tr><td>3</td></tr></table>',
};

const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const navegador = await chromium.launch({ executablePath: CHROME });
const pagina = await navegador.newPage();
console.log('Chrome', navegador.version(), '\n');

for (const [rotulo, html] of Object.entries(casos)) {
  await pagina.setContent(`<!doctype html><meta charset="utf-8">${html}`, { waitUntil: 'commit' });
  console.log(`${rotulo}\n  ${await pagina.evaluate(() => document.body.innerHTML.trim())}\n`);
}

await navegador.close();
Chrome 151.0.7922.170

div dentro da table <div class=“aviso”>Estoque de 5 de julho</div><table><tbody><tr><td>3</td></tr></tbody></table>

td sem tr <table><tbody><tr><td>89,90</td></tr></tbody></table>

texto solto Estoque de julho<table><tbody><tr><td>3</td></tr></tbody></table>

Repare no primeiro: a div saiu de dentro da tabela e foi parar antes dela. Esse mecanismo tem nome na especificação — foster parenting, algo como adoção temporária. Tudo que não é linha, grupo de linhas, definição de coluna (colgroup e col) ou legenda é retirado da tabela e inserido imediatamente antes dela. O texto solto do terceiro caso sofre o mesmo destino.

No segundo caso o parser foi generoso duas vezes: criou a tr e o tbody em volta do td órfão.

caption, th e scope: a diferença medida em papéis

Esta é a tabela do estoque escrita por inteiro, com tudo que uma tabela de dados precisa ter:

html
<table>
  <caption>Estoque da Livraria Vagalume — 5 de julho</caption>
  <thead>
    <tr>
      <th scope="col" rowspan="2">Título</th>
      <th scope="colgroup" colspan="2">Preço</th>
      <th scope="col" rowspan="2">Estoque</th>
    </tr>
    <tr>
      <th scope="col">Novo</th>
      <th scope="col">Sebo</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Grande sertão: veredas</th>
      <td>89,90</td><td>42,00</td><td>3</td>
    </tr>
    <tr>
      <th scope="row">A hora da estrela</th>
      <td>54,90</td><td>25,00</td><td>7</td>
    </tr>
    <tr>
      <th scope="row">Vidas secas</th>
      <td>49,90</td><td>—</td><td>0</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row" colspan="3">Exemplares no balcão</th>
      <td>10</td>
    </tr>
  </tfoot>
</table>

Muita gente troca o th por td com negrito, porque o resultado na tela é parecido. Não é parecido para quem não vê a tela. Dá para medir: o Playwright implementa o cálculo de papéis ARIA do navegador, então basta contar quantos elementos de cada papel existem em cada versão.

js
const comTh = `<table>
  <caption>Estoque da Livraria Vagalume</caption>
  <tr><th scope="col">Título</th><th scope="col">Novo</th></tr>
  <tr><th scope="row">A hora da estrela</th><td>54,90</td></tr>
</table>`;

const comNegrito = `<table>
  <tr><td><b>Título</b></td><td><b>Novo</b></td></tr>
  <tr><td><b>A hora da estrela</b></td><td>54,90</td></tr>
</table>`;

for (const [rotulo, html] of [['com th + caption', comTh], ['com td + <b>', comNegrito]]) {
  await pagina.setContent(`<!doctype html><meta charset="utf-8">${html}`);
  console.log(`\n${rotulo}`);
  console.log('  columnheader :', await pagina.getByRole('columnheader').count());
  console.log('  rowheader    :', await pagina.getByRole('rowheader').count());
  console.log('  cell         :', await pagina.getByRole('cell').count());
  const porNome = pagina.getByRole('table', { name: 'Estoque da Livraria Vagalume' });
  console.log('  tabela achada pelo nome:', await porNome.count());
}
Chrome 151.0.7922.170

com th + caption columnheader : 2 rowheader : 1 cell : 1 tabela achada pelo nome: 1

com td + <b> columnheader : 0 rowheader : 0 cell : 4 tabela achada pelo nome: 0

Na primeira versão o navegador entendeu dois cabeçalhos de coluna, um cabeçalho de linha e uma célula de dado. Na segunda, quatro células iguais e nenhum cabeçalho. O leitor de tela que anda pela tabela na primeira versão ouve “Novo, A hora da estrela, 54,90”; na segunda ouve só “54,90”, e quem escuta precisa voltar até o topo para lembrar de que coluna era aquilo.

O caption também não é enfeite: ele vira o nome da tabela. Com ele, uma página com seis tabelas tem seis nomes na lista de tabelas do leitor de tela; sem ele, tem seis linhas escritas “tabela”. Foi por isso que a busca pelo nome achou uma tabela na primeira versão e zero na segunda.

O scope completa o desenho: col significa “eu mando na coluna abaixo de mim”, row significa “eu mando na linha ao meu lado”, e colgroup é o cabeçalho que manda em duas ou mais colunas — no nosso caso, o “Preço” que cobre “Novo” e “Sebo”. Isso e mais um punhado de atributos aparecem em acessibilidade em HTML; aqui basta a regra: toda tabela de dados tem th com scope.

thead, tbody e tfoot: o rodapé que desce sozinho

Os três grupos existem para separar cabeçalho, dados e totais. E eles têm uma propriedade que quase ninguém conhece: a ordem em que você escreve não é a ordem em que aparece. Escrevi o tfoot antes do tbody, de propósito:

html
<table>
  <thead><tr><th>Título</th><th>Estoque</th></tr></thead>
  <tfoot><tr><th>Total</th><td>10</td></tr></tfoot>
  <tbody>
    <tr><th>A hora da estrela</th><td>7</td></tr>
    <tr><th>Vidas secas</th><td>3</td></tr>
  </tbody>
</table>

Depois pedi ao Chrome o índice de cada linha e a posição em pixels do topo dela:

js
const linhas = await pagina.evaluate(() =>
  [...document.querySelector('table').rows].map((linha) => ({
    rowIndex: linha.rowIndex,
    grupo: linha.parentElement.tagName,
    texto: linha.cells[0].textContent,
    topo: Math.round(linha.getBoundingClientRect().top),
  }))
);

console.table(linhas);
┌─────────┬──────────┬─────────┬─────────────────────┬──────┐ │ (index) │ rowIndex │ grupo │ texto │ topo │ ├─────────┼──────────┼─────────┼─────────────────────┼──────┤ │ 0 │ 0 │ 'THEAD' │ 'Título' │ 10 │ │ 1 │ 1 │ 'TBODY' │ 'A hora da estrela' │ 32 │ │ 2 │ 2 │ 'TBODY' │ 'Vidas secas' │ 54 │ │ 3 │ 3 │ 'TFOOT' │ 'Total' │ 76 │ └─────────┴──────────┴─────────┴─────────────────────┴──────┘

O tfoot foi escrito em segundo lugar e terminou em último — rowIndex 3 e topo em 76px, abaixo de todo mundo. Tanto a coleção rows quanto o desenho na tela seguem a ordem lógica cabeçalho, corpo, rodapé, e não a ordem do arquivo.

Vale a tabela de referência dos elementos:

elemento para que serve o parser cria sozinho?
table abre a grade não
caption dá nome à tabela; vem logo depois de table não
thead as linhas de cabeçalho não
tbody as linhas de dado sim, sempre que faltar
tfoot as linhas de total; renderiza por último não
tr uma linha sim, em volta de td órfão
th célula que é cabeçalho, com scope não
td célula de dado não

colspan e rowspan na grade que ninguém escreveu

colspan estica a célula para a direita; rowspan estica para baixo. Os dois contam em células, não em pixels, e é aí que a conta começa a fugir.

Na tabela do estoque, “Preço” usa colspan="2" para cobrir “Novo” e “Sebo”, e “Título” e “Estoque” usam rowspan="2" para descer até a segunda linha do cabeçalho. Resultado: a primeira linha do thead tem três células escritas e ocupa quatro colunas; a segunda tem duas células escritas e também ocupa quatro, porque duas já estavam tomadas de cima.

O DOM não esconde isso — ele simplesmente não conta para você:

js
const tabela = document.querySelector('table');

for (const linha of tabela.rows) {
  const celulas = [...linha.cells].map((c) => `${c.tagName}:${c.cellIndex}`);
  console.log(`rowIndex ${linha.rowIndex} -> ${celulas.join(' ')}`);
}
rowIndex 0 -> TH:0 TH:1 TH:2 rowIndex 1 -> TH:0 TH:1 rowIndex 2 -> TH:0 TD:1 TD:2 TD:3 rowIndex 3 -> TH:0 TD:1 TD:2 TD:3 rowIndex 4 -> TH:0 TD:1 TD:2 TD:3 rowIndex 5 -> TH:0 TD:1

cellIndex é a posição da célula na lista de irmãs, não na grade. Na linha 1, o “Novo” tem cellIndex 0 — e desenha na coluna 1, porque a coluna 0 está ocupada pelo “Título” que desceu. Ninguém no DOM te entrega a coluna real.

Então vamos construir essa grade. O algoritmo é o mesmo da especificação, em vinte linhas: para cada linha, ande da esquerda para a direita pulando o que já está ocupado, e marque todos os quadradinhos que a célula cobre.

js
export function mapearGrade(tabela) {
  const linhas = [...tabela.rows];
  const grade = [];

  linhas.forEach((linha, l) => {
    let coluna = 0;
    for (const celula of linha.cells) {
      while (grade[l]?.[coluna] !== undefined) coluna++;
      const largura = celula.colSpan;
      const altura = celula.rowSpan === 0 ? linhas.length - l : celula.rowSpan;
      for (let dl = 0; dl < altura; dl++) {
        for (let dc = 0; dc < largura; dc++) {
          (grade[l + dl] ??= [])[coluna + dc] = celula;
        }
      }
      coluna += largura;
    }
  });

  const colunas = Math.max(...grade.map((linha) => linha.length));
  const buracos = [];
  grade.forEach((linha, l) => {
    for (let c = 0; c < colunas; c++) {
      if (linha[c] === undefined) buracos.push([l, c]);
    }
  });

  return { grade, colunas, buracos, linhasEscritas: linhas.length };
}

A linha do rowSpan === 0 trata o caso de rowspan="0", que no HTML significa “até o fim do grupo de linhas”. Com um desenhar() em volta que imprime cada quadradinho, a tabela do estoque fica assim:

js
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { desenhar } from './grade.mjs';

const html = readFileSync(new URL('./estoque.html', import.meta.url), 'utf8');
const { document } = new JSDOM(`<!doctype html><body>${html}`).window;
desenhar(document.querySelector('table'), 'estoque');
estoque: 4 colunas, 6 linhas escritas, 6 na grade 0 | Título | Preço | Preço | Estoque 1 | Título | Novo | Sebo | Estoque 2 | Grande s | 89,90 | 42,00 | 3 3 | A hora d | 54,90 | 25,00 | 7 4 | Vidas se | 49,90 | — | 0 5 | Exemplar | Exemplar | Exemplar | 10 buracos: nenhum

Agora dá para ver o que estava invisível: “Título” ocupando as linhas 0 e 1, “Preço” ocupando as colunas 1 e 2, e o rodapé com uma célula só cobrindo três colunas. Quatro colunas em todas as linhas, nenhum buraco. A tabela fecha.

Três formas de estourar a grade

Toda tabela quebrada é uma dessas três, e as três somem numa revisão visual rápida. Passei cada uma pelo mesmo desenhar():

js
const faltaCelula = `<table>
  <thead><tr><th>Título</th><th>Novo</th><th>Sebo</th><th>Estoque</th></tr></thead>
  <tbody>
    <tr><th>Grande sertão: veredas</th><td>89,90</td><td>42,00</td><td>3</td></tr>
    <tr><th>A hora da estrela</th><td>54,90</td><td>7</td></tr>
  </tbody>
</table>`;

const colspanErrado = `<table>
  <thead><tr><th>Título</th><th>Novo</th><th>Sebo</th><th>Estoque</th></tr></thead>
  <tfoot><tr><th colspan="4">Exemplares no balcão</th><td>10</td></tr></tfoot>
</table>`;

const rowspanLongo = `<table>
  <tbody>
    <tr><th rowspan="4">Clarice Lispector</th><td>A hora da estrela</td></tr>
    <tr><td>Água viva</td></tr>
  </tbody>
</table>`;

for (const [rotulo, html] of [
  ['falta uma célula', faltaCelula],
  ['colspan errado', colspanErrado],
  ['rowspan longo demais', rowspanLongo],
]) {
  const { document } = new JSDOM(`<!doctype html><body>${html}`).window;
  desenhar(document.querySelector('table'), rotulo);
  console.log('');
}
falta uma célula: 4 colunas, 3 linhas escritas, 3 na grade 0 | Título | Novo | Sebo | Estoque 1 | Grande s | 89,90 | 42,00 | 3 2 | A hora d | 54,90 | 7 | ·VAZIO· buracos: [[2,3]]

colspan errado: 5 colunas, 2 linhas escritas, 2 na grade 0 | Título | Novo | Sebo | Estoque | ·VAZIO· 1 | Exemplar | Exemplar | Exemplar | Exemplar | 10
buracos: [[0,4]]

rowspan longo demais: 2 colunas, 2 linhas escritas, 4 na grade 0 | Clarice | A hora d 1 | Clarice | Água viv 2 | Clarice | ·VAZIO· 3 | Clarice | ·VAZIO· buracos: [[2,1],[3,1]]

O primeiro caso é o mais cruel. Faltou o <td> do preço de sebo — e o buraco não aparece onde faltou. O “7”, que era estoque, escorregou para a coluna “Sebo”, e o vazio ficou no fim da linha. Quem olha a tela vê um estoque de 7 exemplares anunciado como preço de sebo, e nada pisca em vermelho.

O segundo é a conta errada: colspan="4" numa tabela de quatro colunas, com uma célula ainda depois dele. A grade inteira ganhou uma quinta coluna que só existe na linha do rodapé.

O terceiro pede quatro linhas para uma célula num grupo que só tem duas. Aqui o navegador é mais esperto que o meu mapa: ele recorta. Medi no Chrome a altura da célula e a da tabela — com borda e padding declarados, para a altura ter de onde sair:

js
const html = `<!doctype html><meta charset="utf-8">
<style>table{border-collapse:collapse}th,td{border:1px solid #333;padding:4px;font:14px sans-serif}</style>
<table>
  <tbody>
    <tr><th id="autor" rowspan="4">Clarice Lispector</th><td>A hora da estrela</td></tr>
    <tr><td>Água viva</td></tr>
  </tbody>
</table>`;

await pagina.setContent(html);

const medidas = await pagina.evaluate(() => {
  const tabela = document.querySelector('table');
  const autor = document.getElementById('autor');
  return {
    alturaTabela: Math.round(tabela.getBoundingClientRect().height),
    alturaCelula: Math.round(autor.getBoundingClientRect().height),
    linhasRenderizadas: tabela.rows.length,
    rowSpanPedido: autor.rowSpan,
  };
});

console.log(medidas);
Chrome 151.0.7922.170 { alturaTabela: 51, alturaCelula: 50, linhasRenderizadas: 2, rowSpanPedido: 4 }

Pedi 4 linhas, existem 2, e a célula parou nos 50px da tabela. Nada quebra na tela — e é exatamente por isso que o erro sobrevive ao deploy: o dia em que o back-end mandar quatro livros dessa autora, o layout muda sozinho.

O auditor de tabela para colar no DevTools

O mesmo algoritmo cabe numa função sem import nenhum, para você colar no console do navegador e auditar a página que estiver aberta:

js
const auditarTabelas = () => {
  for (const tabela of document.querySelectorAll('table')) {
    const grade = [];
    const linhas = [...tabela.rows];
    linhas.forEach((linha, l) => {
      let c = 0;
      for (const celula of linha.cells) {
        while (grade[l]?.[c] !== undefined) c++;
        for (let dl = 0; dl < celula.rowSpan; dl++) {
          for (let dc = 0; dc < celula.colSpan; dc++) {
            (grade[l + dl] ??= [])[c + dc] = celula;
          }
        }
        c += celula.colSpan;
      }
    });
    const largura = Math.max(...grade.map((l) => l.length));
    const buracos = [];
    grade.forEach((linha, l) => {
      for (let c = 0; c < largura; c++) if (linha[c] === undefined) buracos.push(`${l},${c}`);
    });
    console.log({
      legenda: tabela.caption?.textContent ?? '(sem caption)',
      colunas: largura,
      linhasEscritas: linhas.length,
      linhasNaGrade: grade.length,
      buracos: buracos.join(' ') || 'nenhum',
    });
  }
};

auditarTabelas();

Rodei numa página com duas tabelas: a do estoque, correta, e uma segunda com uma célula faltando e um colspan errado.

{legenda: Estoque — romances, colunas: 4, linhasEscritas: 3, linhasNaGrade: 3, buracos: nenhum} {legenda: (sem caption), colunas: 5, linhasEscritas: 4, linhasNaGrade: 4, buracos: 0,4 1,4 2,3 2,4}

Três sinais para ler de uma vez: buracos diferente de “nenhum” é célula faltando ou colspan sobrando; linhasNaGrade maior que linhasEscritas é rowspan estourado; e (sem caption) é a tabela que não tem nome. É uma verificação de dez segundos antes de qualquer entrega.

Tabela de layout: por que ela morreu

Até o começo dos anos 2000, tabela era o jeito de posicionar coisa na tela: cabeçalho numa linha, menu numa célula, conteúdo na célula ao lado. O CSS foi tomando esse serviço aos poucos — primeiro com float, ainda naquela década, e depois com Flexbox e Grid, que enterraram o assunto. Mas o argumento decisivo nunca foi estético.

js
const layout = `<table>
  <tr><td><img alt="Livraria Vagalume"></td><td><a href="/estoque">Estoque</a></td></tr>
</table>`;

const layoutMarcado = `<table role="presentation">
  <tr><td><img alt="Livraria Vagalume"></td><td><a href="/estoque">Estoque</a></td></tr>
</table>`;

for (const [rotulo, html] of [['sem role', layout], ['role="presentation"', layoutMarcado]]) {
  await pagina.setContent(`<!doctype html><meta charset="utf-8">${html}`);
  console.log(rotulo, {
    table: await pagina.getByRole('table').count(),
    row: await pagina.getByRole('row').count(),
    cell: await pagina.getByRole('cell').count(),
    link: await pagina.getByRole('link').count(),
  });
}
sem role { table: 1, row: 1, cell: 2, link: 1 } role="presentation" { table: 0, row: 0, cell: 0, link: 1 }

Na primeira versão, o navegador anuncia uma tabela de 1 linha e 2 colunas para alguém que só queria o menu do site. Na segunda, role="presentation" apaga a tabela da árvore de acessibilidade e sobra o link — mas repare no que isso significa: você escreveu uma tabela e precisou de um atributo para pedir desculpa por ela.

A regra hoje é simples e não tem exceção na web: se os dados não se comparam em duas dimensões, não é tabela. Layout se resolve com Grid e Flexbox, e a estrutura da página com as tags de HTML semântico. O último lugar onde tabela de layout ainda vive é o HTML de e-mail, porque os clientes de e-mail travaram no suporte a CSS — e isso é uma limitação daquele ambiente, não um argumento.

Tabela larga no celular: 627px dentro de 390px

Uma tabela de estoque completa — título, autor, ano, dois preços, quantidade, prateleira — não cabe num celular. Medi o estrago numa janela de 390px de largura, o tamanho de um iPhone comum:

js
const tabela = `<table>
  <caption>Estoque da Livraria Vagalume</caption>
  <thead><tr><th>Título</th><th>Autor</th><th>Ano</th><th>Novo</th><th>Sebo</th><th>Estoque</th><th>Prateleira</th></tr></thead>
  <tbody>
    <tr><th>Grande sertão: veredas</th><td>Guimarães Rosa</td><td>1956</td><td>89,90</td><td>42,00</td><td>3</td><td>Romance / B4</td></tr>
    <tr><th>A hora da estrela</th><td>Clarice Lispector</td><td>1977</td><td>54,90</td><td>25,00</td><td>7</td><td>Romance / B1</td></tr>
  </tbody>
</table>`;

const base = `<!doctype html><meta charset="utf-8">
<style>
  body{margin:0;font:14px sans-serif}
  table{border-collapse:collapse;width:100%}
  th,td{border:1px solid #333;padding:8px;white-space:nowrap}
</style>`;

const pagina = await navegador.newPage({ viewport: { width: 390, height: 700 } });

const versoes = [
  ['sem wrapper', `${base}${tabela}`],
  ['com overflow-x:auto', `${base}<style>.tabela-wrap{overflow-x:auto}</style><div class="tabela-wrap">${tabela}</div>`],
];

for (const [rotulo, html] of versoes) {
  await pagina.setContent(html);
  console.log(rotulo, await pagina.evaluate(() => {
    const doc = document.documentElement;
    const caixa = document.querySelector('.tabela-wrap');
    return {
      paginaVisivel: doc.clientWidth,
      paginaRolagem: doc.scrollWidth,
      tabelaLargura: Math.round(document.querySelector('table').getBoundingClientRect().width),
      caixaVisivel: caixa ? caixa.clientWidth : null,
      caixaRolagem: caixa ? caixa.scrollWidth : null,
    };
  }));
}
sem wrapper { paginaVisivel: 390, paginaRolagem: 627, tabelaLargura: 627, caixaVisivel: null, caixaRolagem: null } com overflow-x:auto { paginaVisivel: 390, paginaRolagem: 390, tabelaLargura: 627, caixaVisivel: 390, caixaRolagem: 627 }

Sem nada em volta, a página inteira passou a ter 627px de rolagem horizontal: o texto dos parágrafos, o menu, o rodapé, tudo escorrega para o lado junto com a tabela. É o famoso “site que treme no celular”.

A correção não é diminuir a fonte nem esconder colunas. É dar à tabela a própria caixa de rolagem:

html
<div class="tabela-wrap">
  <table>…</table>
</div>
css
.tabela-wrap {
  overflow-x: auto;
}

Com a caixa, a página voltou aos 390px de rolagem — zero rolagem horizontal — e os 627px passaram a viver dentro do div, que rola sozinho. Uma linha de CSS. As outras propriedades de overflow e quando usar cada uma estão em trilha de CSS.

O próximo passo

Antes de escrever a próxima tabela, faça duas perguntas. A primeira: os dados se comparam em duas dimensões? Se a resposta é não, era lista ou era layout. A segunda: quantas colunas cada linha ocupa? Some os colspan, desconte os rowspan que descem de cima, e confira que o número é o mesmo em todas — ou deixe o auditor deste artigo fazer a conta por você.

A próxima lição da trilha de HTML é o formulário, onde o HTML deixa de mostrar dado e passa a coletar dado. Se quiser rever de onde vêm colspan, scope e os outros atributos que usamos aqui, o caminho é tags e atributos HTML; e o guia completo de HTML mostra onde cada assunto entra no mapa.

  • html
  • tabelas
  • colspan
  • rowspan
  • dados

Perguntas frequentes

Qual a diferença entre scope e o atributo headers?
scope diz a direção do cabeçalho (a coluna abaixo, a linha ao lado) e resolve a grande maioria das tabelas. headers é o plano B, para tabelas irregulares demais para uma direção: cada td lista, pelo id, os th que mandam nele. É mais preciso e muito mais caro de manter, porque exige um id em cada cabeçalho.
O que significa rowspan="0"?
Significa "até o fim do grupo de linhas". A célula ocupa a linha atual e todas as que ainda vierem dentro daquele tbody, thead ou tfoot. É legal no HTML e raro na prática, porque o número deixa de aparecer no markup e passa a depender de quantas linhas o back-end mandou. colspan="0" não tem esse comportamento.
Dá para deixar o cabeçalho fixo enquanto a tabela rola?
Dá, e é CSS puro. Aplique position sticky com top 0 nos th do thead, e não no thead inteiro: o suporte a sticky em elemento de grupo de linhas chegou tarde e ainda varia. Não precisa de contêiner de rolagem — com a rolagem da própria página o cabeçalho já gruda. O contêiner só entra quando a tabela também precisa rolar na horizontal.
Tabela dentro de tabela é errado?
Não é inválido, mas quase sempre é sintoma. Uma tabela aninhada some da leitura em voz alta como "tabela dentro da célula linha 3 coluna 2", e quem ouve perde a referência. Se a célula precisa de mais de um dado, o caminho é uma coluna a mais, não uma tabela a mais.

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 Node 24.16.0, jsdom 30.0.1 e Chrome 151.0.7922.170, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — O elemento <table> — developer.mozilla.org
  2. HTML Standard — Forming a table — html.spec.whatwg.org
  3. HTML Standard — Foster parenting — html.spec.whatwg.org

Continue por aqui