Títulos e parágrafos em HTML: h1 a h6, p, br e hr
Como estruturar o texto da página com h1 a h6 e a tag p, quando usar br ou hr, e por que pular do h1 para o h4 atrapalha leitor e buscador.
Título é h1 a h6 e parágrafo é p. O número do título não escolhe o tamanho
da letra: ele escolhe a posição do assunto na hierarquia da página. Quem
pinta o tamanho é o CSS, e ele muda sem mexer em nada dessa estrutura.
Todos os exemplos aqui são a página de uma clínica veterinária de bairro, a Pata Firme: serviços, vacinas, endereço. É a mesma página que continua em tags e atributos HTML, onde a anatomia de um elemento foi apresentada.
Pense no sumário de um livro
O título do livro apresenta o assunto principal. Os capítulos dividem esse
assunto e os subtítulos dividem cada capítulo. h1, h2 e h3 fazem essa
mesma árvore na página. O número indica nível de hierarquia, não volume da
voz nem tamanho da letra. O CSS pode deixar um h2 visualmente maior sem
transformá-lo no assunto principal.
A comparação com o sumário tem um limite: HTML permite de h1 a h6, mas você
não precisa usar todos nem criar níveis só para “ficar bonito”. Leia os títulos
da página da clínica sem os parágrafos. Eles ainda contam uma história coerente
— clínica, serviços, vacinação, endereço? Esse é um teste melhor que olhar o
tamanho na tela. Se a sequência pula de assunto sem pai, corrija a estrutura
antes de abrir o CSS.
h1 a h6 são hierarquia, não tamanho de fonte
Seis níveis, do mais geral ao mais específico. O h1 é o assunto da página
inteira; cada h2 abre um bloco desse assunto; cada h3 abre um pedaço do h2
acima dele. É uma árvore, escrita numa lista plana.
<h1>Clínica Veterinária Pata Firme</h1>
<h2>Serviços</h2>
<h3>Consulta de rotina</h3>
<h3>Vacinação</h3>
<h4>Cães</h4>
<h4>Gatos</h4>
<h3>Castração</h3>
<h2>Horário de atendimento</h2>
<h2>Como chegar</h2>Essa árvore não é uma metáfora: ela existe de verdade no documento, e dá para imprimir. O script abaixo lê um arquivo HTML, pega todos os títulos na ordem em que aparecem e recua cada um conforme o nível.
// sumario.mjs — imprime o sumário de títulos de um arquivo HTML
import { readFile } from 'node:fs/promises';
import { JSDOM } from 'jsdom';
const arquivo = process.argv[2];
const { document } = new JSDOM(await readFile(arquivo, 'utf8')).window;
const titulos = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
console.log(`${arquivo} — ${titulos.length} títulos`);
let anterior = 0;
for (const titulo of titulos) {
const nivel = Number(titulo.tagName[1]);
const recuo = ' '.repeat(nivel - 1);
const pulo = anterior && nivel > anterior + 1 ? ` <-- pulou h${anterior} para h${nivel}` : '';
console.log(`${recuo}h${nivel} ${titulo.textContent.trim()}${pulo}`);
anterior = nivel;
}Salve a marcação acima como clinica.html e rode node sumario.mjs clinica.html:
Esse desenho é o sumário da página. Ele nasce só do número da tag — nenhum CSS participou.
E o tamanho? Cada título já vem com um tamanho padrão do navegador, mas é só um padrão. O script abaixo mede a fonte de cada tag com e sem uma folha de estilo que inverte a lógica de propósito:
// tamanho.mjs — o tamanho padrão de cada título, e o mesmo depois do CSS
import { JSDOM } from 'jsdom';
const marcacao = '<h1>Pata Firme</h1><h2>Serviços</h2><h3>Vacinação</h3><h6>Cães</h6><p>Texto</p>';
const css = 'h1 { font-size: 20px; } h6 { font-size: 44px; }';
function medir(html) {
const { window } = new JSDOM(html);
return ['h1', 'h2', 'h3', 'h6', 'p'].map((tag) => {
const el = window.document.querySelector(tag);
return `${tag}: ${window.getComputedStyle(el).fontSize}`;
});
}
console.log('sem CSS ', medir(marcacao).join(' '));
console.log('com CSS ', medir(`<style>${css}</style>${marcacao}`).join(' '));Os números da primeira linha não são arbitrários. A folha de estilo padrão que
todo navegador traz define os títulos em múltiplos de em: h1 2em, h2 1.5em,
h3 1.17em, h4 1em, h5 0.83em e h6 0.67em. Sobre a base de 16px é
exatamente o que saiu — e repare que o h4 tem o mesmo tamanho de um parágrafo
comum. Ele continua sendo um título, o que já mostra que tamanho e nível são
duas coisas separadas antes mesmo de você escrever uma linha de CSS.
Com três linhas de CSS, o h6 ficou mais que o dobro do h1. O sumário da
página continuou idêntico. É essa independência que responde a dúvida de todo
iniciante: você não escolhe o h3 porque “o h2 ficou grande demais”. Você
escolhe o h3 porque aquele assunto está um degrau abaixo do anterior, e depois
pede o tamanho que quiser ao CSS.
Um h1 por página — e o que acontece quando são cinco
A recomendação de usar um h1 só por página costuma ser repetida sem
explicação. A explicação aparece quando você imprime o sumário. Esta é a mesma
clínica com um h1 em cada bloco, salva como clinica-cinco-h1.html e passada
pelo mesmo sumario.mjs:
<h1>Clínica Veterinária Pata Firme</h1>
<h1>Serviços</h1>
<h1>Vacinação</h1>
<h1>Horário de atendimento</h1>
<h1>Como chegar</h1>Sem recuo nenhum. A página tem cinco assuntos de mesma importância e nenhum deles é o assunto principal. “Vacinação” deixou de ser um pedaço de “Serviços” e virou um tópico solto, no mesmo patamar do nome da clínica.
O problema não é o buscador dar um puxão de orelha — é que a informação de que vacinação faz parte dos serviços simplesmente não está mais escrita em lugar nenhum. Quem usa a página com os olhos deduz pela posição na tela. Quem usa por áudio, não.
O parágrafo e os espaços em branco que somem só na tela
Bloco de texto é p. Cada parágrafo é um p, e o navegador já coloca o
espaçamento entre eles. O que confunde no começo é a quebra de linha que você
digita no editor e não aparece na tela:
<p>Atendemos de segunda a sábado.
Emergência
24 horas.</p>Quatro espaços seguidos, duas quebras de linha e vários recuos. Na tela, sai tudo numa frase só. Mas cuidado com a conclusão fácil: o navegador não apaga esses espaços do documento. Ele guarda tudo e apenas renderiza de forma colapsada.
// espacos.mjs — o que o DOM guarda e o que a tela mostra
import { JSDOM } from 'jsdom';
const html = `<p>Atendemos de segunda a sábado.
Emergência
24 horas.</p>`;
const { document } = new JSDOM(html).window;
const paragrafo = document.querySelector('p');
const bruto = paragrafo.textContent;
console.log('no DOM :', JSON.stringify(bruto));
console.log('caracteres :', bruto.length);
console.log('na tela :', JSON.stringify(bruto.replace(/\s+/g, ' ').trim()));
console.log('caracteres :', bruto.replace(/\s+/g, ' ').trim().length);Dezessete caracteres de diferença. Eles continuam lá, no texto do elemento —
quem colapsa sequências de espaço em um espaço só é a renderização, e isso é
uma regra de CSS (white-space: normal), não de HTML. Por isso <pre> mostra o
texto exatamente como está no arquivo: ele muda essa regra.
Isso importa no dia em que você for comparar o conteúdo de um parágrafo com
JavaScript. O texto que você vê na tela e o texto que o textContent devolve
não são a mesma string.
br quebra linha, e só isso
A tag br força uma quebra de linha dentro de um mesmo bloco de texto. Ela tem
um uso legítimo e estreito: quando a quebra faz parte do conteúdo. Endereço,
verso de poema, linha de um formulário impresso.
<p>Rua das Acácias, 120<br>Vila Mariana<br>São Paulo — SP</p>
<p>Levamos o pet para a sala de espera.<br><br>A consulta dura 30 minutos.<br><br>A vacina fica para o fim.</p>O primeiro p está certo: o endereço é uma coisa só, escrita em três linhas. O
segundo é a gambiarra clássica — três parágrafos separados por <br><br> para
“dar um espacinho”. Os dois parecem iguais na tela. No documento, não:
// br.mjs — br de verdade x br usado como parágrafo
import { JSDOM } from 'jsdom';
const comBr = `<p>Rua das Acácias, 120<br>Vila Mariana<br>São Paulo — SP</p>
<p>Levamos o pet para a sala de espera.<br><br>A consulta dura 30 minutos.<br><br>A vacina fica para o fim.</p>`;
const semBr = `<p>Rua das Acácias, 120<br>Vila Mariana<br>São Paulo — SP</p>
<p>Levamos o pet para a sala de espera.</p>
<p>A consulta dura 30 minutos.</p>
<p>A vacina fica para o fim.</p>`;
for (const [nome, html] of [['com br', comBr], ['com p', semBr]]) {
const { document } = new JSDOM(html).window;
const paragrafos = [...document.querySelectorAll('p')];
console.log(`${nome}: ${paragrafos.length} elementos <p>, ${document.querySelectorAll('br').length} <br>`);
paragrafos.forEach((p, i) => console.log(` p[${i}] ${p.textContent.length} caracteres`));
}Na versão com <br><br>, os três parágrafos viraram um bloco de 88 caracteres.
Consequências práticas: para o CSS existe um bloco só, então não há como dar uma
margin diferente ao primeiro nem alcançar os três com p:nth-of-type; o leitor
de tela lê tudo como um parágrafo só; e o espaçamento entre eles vira refém da
altura da linha em vez de vir de uma margin que você controla.
A regra é curta: se o espaço em branco é conteúdo, br; se é layout, p mais
CSS.
hr é separação de assunto, não risquinho
A tag hr marca uma mudança de assunto dentro da mesma seção — a virada entre
duas partes de um texto. O navegador desenha uma linha porque é a representação
padrão dela, e não porque ela seja um elemento de desenho. Precisa de uma linha
decorativa? Isso é border no CSS.
hr também é um elemento vazio: não tem conteúdo e não tem fechamento. Se
você tentar colocar texto dentro dele, o parser não reclama — ele reorganiza.
E, no caminho, faz uma coisa que pega muita gente: um hr encerra o parágrafo
que estiver aberto.
// hr.mjs — hr é vazio, e ele fecha o parágrafo aberto
import { JSDOM } from 'jsdom';
const html = `<p>Consulta de rotina: R$ 180<hr>Vacina V10: R$ 95</p>
<hr>Este texto tentou morar dentro do hr</hr>`;
const { document } = new JSDOM(html).window;
console.log(document.body.innerHTML);
console.log('---');
console.log('filhos do primeiro hr:', document.querySelector('hr').childNodes.length);Leia o que saiu com calma, porque nada disso foi escrito por você. O p foi
fechado antes do hr. O preço da vacina ficou solto no body, fora de qualquer
parágrafo. O </p> que sobrou virou um <p></p> vazio. E o </hr> da última
linha simplesmente desapareceu, porque tag de fechamento de elemento vazio não
existe.
Esse conserto silencioso do parser é o mesmo mecanismo descrito em como o navegador monta a árvore DOM: o HTML nunca “dá erro de sintaxe”, ele te entrega um documento diferente do que você escreveu.
O sumário que o leitor de tela monta com os seus títulos
Quem navega por leitor de tela raramente lê a página de cima a baixo. O atalho
mais usado é pedir a lista de títulos e pular direto para o trecho que interessa
— é a mesma lista que o sumario.mjs imprimiu lá em cima.
Duas coisas seguem daí. A primeira: título é a tag, não a aparência. Este
pedaço da clínica, salvo como pagina.html, tem um “Consulta de rotina” em
negrito, dentro de um parágrafo, com toda a cara de título:
<h1>Clínica Veterinária Pata Firme</h1>
<p>Atendimento clínico e cirúrgico para cães e gatos na Vila Mariana.</p>
<h2>Serviços</h2>
<p class="titulo-falso"><strong>Consulta de rotina</strong></p>
<p>Avaliação completa em 30 minutos, com peso, temperatura e exame físico.</p>
<h3>Vacinação</h3>
<p>Aplicamos V10, antirrábica e giárdia, com carteirinha atualizada na hora.</p>
<h2>Como chegar</h2>
<p>Rua das Acácias, 120<br>Vila Mariana<br>São Paulo — SP</p>“Consulta de rotina” não está no sumário. Para o olho, é um título; para a
navegação, não existe. A tag strong marca importância dentro da frase, e isso é
assunto de formatação de texto em HTML — não é
hierarquia, e não ocupa o lugar de um título.
A segunda: pular nível quebra o recuo. Este é o conteúdo do primeiro exemplo
outra vez, com as tags escolhidas pelo tamanho da letra em vez do nível — a
clínica sai do h1 direto para h4, e h6 e h3 se misturam no meio:
<h1>Clínica Veterinária Pata Firme</h1>
<h4>Serviços</h4>
<h6>Consulta de rotina</h6>
<h6>Vacinação</h6>
<h3>Cães</h3>
<h3>Gatos</h3>
<h6>Castração</h6>
<h4>Horário de atendimento</h4>
<h4>Como chegar</h4>Repare em “Cães” e “Gatos”. Eles deviam estar abaixo de “Vacinação”, e
apareceram acima, mais próximos da margem. Como estão em h3 e a vacinação está
em h6, o documento afirma que os tipos de vacina são um assunto mais amplo do
que a vacinação em si. Nenhum ser humano escreveria isso de propósito — mas é
exatamente o que sai quando a tag é escolhida pelo tamanho da letra.
Mais sobre esse tipo de decisão em acessibilidade em HTML, e sobre quais tags agrupam essas seções em HTML semântico.
O erro clássico: uma div dentro de um p
Este aqui aparece no primeiro projeto de quase todo mundo. O parágrafo tem um preço no meio, e você quer estilizar esse preço:
// div-no-p.mjs — o que o parser faz com uma div dentro de um p
import { JSDOM } from 'jsdom';
const escrito = `<p>A vacina V10 protege contra sete doenças.
<div class="preco">R$ 95</div>
Aplicação em 10 minutos.</p>`;
const { document } = new JSDOM(escrito).window;
console.log(document.body.innerHTML);
console.log('---');
console.log('elementos <p>:', document.querySelectorAll('p').length);
console.log('a div é filha do p?', document.querySelector('.preco').parentElement.tagName);Você escreveu um parágrafo. O documento ficou com dois — e a div não é filha
de nenhum dos dois: o pai dela é o body. A frase “Aplicação em 10 minutos”
ficou solta, sem parágrafo. Nenhuma mensagem de erro apareceu.
O motivo: um p só aceita conteúdo inline dentro dele. Quando o parser
encontra a tag de abertura de um elemento de bloco, ele fecha o parágrafo naquele
ponto exato — repare no </p> que saiu colado no <div>, com a quebra de linha
e os dois espaços do recuo ainda guardados dentro do parágrafo. A mesma coisa
acontece com um p dentro de outro p:
import { JSDOM } from 'jsdom';
const { document } = new JSDOM('<p>Horário de segunda a sexta: <p>8h às 19h</p></p>').window;
console.log(document.body.innerHTML);A correção é trocar a div por um elemento inline. span é o genérico, e
existe justamente para isso:
// div-no-p-corrigido.mjs — span é inline e continua dentro do parágrafo
import { JSDOM } from 'jsdom';
const escrito = `<p>A vacina V10 protege contra sete doenças.
<span class="preco">R$ 95</span>
Aplicação em 10 minutos.</p>`;
const { document } = new JSDOM(escrito).window;
console.log(document.body.innerHTML);
console.log('---');
console.log('elementos <p>:', document.querySelectorAll('p').length);
console.log('o span é filho do p?', document.querySelector('.preco').parentElement.tagName);Um parágrafo, o span dentro dele, nada solto no body. Se o preço realmente
precisa ocupar uma linha inteira, o caminho não é a div: é o span com
display: block no CSS, ou tirar o preço do parágrafo de vez.
O que vem depois
Você já monta a espinha de texto de uma página: hierarquia com h1 a h6,
blocos com p, quebra de conteúdo com br e virada de assunto com hr. Isso
se encaixa dentro do body que aparece na
estrutura de uma página HTML.
O próximo passo da trilha de HTML é dar ênfase e semântica ao texto dentro do parágrafo — negrito, itálico, código e citação. Para ver onde essa lição entra no percurso todo, o guia completo de HTML mostra a ordem de estudo.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
O h1 precisa ter o mesmo texto da tag title?
Colocar o título dentro de section ou article reinicia a contagem de níveis?
Preciso mesmo fechar a tag p?
Dá para usar dois h2 seguidos, sem nenhum h3 entre eles?
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 as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — Elementos de título (h1–h6) — developer.mozilla.org
- MDN — O elemento p — developer.mozilla.org
- HTML Standard — Optional tags — html.spec.whatwg.org
- W3C WAI — Headings — w3.org



