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.
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:
<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:
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);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 > trnão casa com nada no HTML que você escreveu, porque o parser pôs umtbodyno meio do caminho. O certo étable troutbody > tr. document.querySelector('table').childrendevolve otbody, 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:
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);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:
<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:
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();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:
<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.
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());
}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:
<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:
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);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ê:
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(' ')}`);
}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.
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:
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');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():
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('');
}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:
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);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:
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.
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.
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(),
});
}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:
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 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:
<div class="tabela-wrap">
<table>…</table>
</div>.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.
Perguntas frequentes
Qual a diferença entre scope e o atributo headers?
O que significa rowspan="0"?
Dá para deixar o cabeçalho fixo enquanto a tabela rola?
Tabela dentro de tabela é errado?
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 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
- MDN — O elemento <table> — developer.mozilla.org
- HTML Standard — Forming a table — html.spec.whatwg.org
- HTML Standard — Foster parenting — html.spec.whatwg.org


