Entidades HTML: , & e os caracteres especiais
Quando você precisa escrever < em vez de <, por que não é um espaço comum e o que muda quando o arquivo já está salvo em UTF-8.
Uma entidade HTML — chamada de referência de caractere na especificação — é
uma sequência que representa um caractere dentro do código-fonte. < produz
o sinal de menor, & produz & e produz um espaço inquebrável.
Pense numa mensagem em que alguns sinais já são comandos. Para escrever o sinal
como conteúdo, você usa um código combinado: o parser vê < e entrega o
sinal de menor como texto, em vez de iniciar uma tag. No HTML, essa proteção é
necessária para
caracteres reservados em certos contextos; acentos, emoji e símbolos comuns
podem ser escritos diretamente quando o documento está em UTF-8.
O ponto importante não é decorar uma tabela. É reconhecer quando o parser pode
confundir conteúdo com sintaxe e quando muda o comportamento de quebra de
linha, não apenas a aparência.
Os exemplos são da Disco Voador, uma loja de vinil: ficha do álbum, preço, busca com filtros e o selo que o cliente cola no próprio site. Toda saída abaixo veio de execução real no Node 24 com jsdom, que implementa o mesmo algoritmo de análise de HTML que os navegadores seguem.
Os dois caracteres que o HTML guarda para si
O analisador lê o arquivo procurando dois sinais. O < abre uma
tag; o & abre uma entidade. Todo o resto é texto comum. São esses dois,
então, que não podem aparecer crus quando você quer que sejam lidos como texto.
Na loja, o caso que aparece toda semana é o e-comercial no nome da dupla:
<h2>Bookends — Simon & Garfunkel</h2>
<h2>Bookends — Simon & Garfunkel</h2>import { JSDOM } from 'jsdom';
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">
<h2 id="certo">Bookends — Simon & Garfunkel</h2>
<h2 id="errado">Bookends — Simon & Garfunkel</h2>`).window;
const certo = document.getElementById('certo');
const errado = document.getElementById('errado');
console.log('texto do certo :', certo.textContent);
console.log('texto do errado:', errado.textContent);
console.log('deu no mesmo? :', certo.textContent === errado.textContent);
console.log('html de volta :', errado.innerHTML);As duas linhas deram no mesmo texto. O analisador é tolerante: quando o que vem
depois do & não forma nome nenhum que ele conheça, ele desiste e devolve o
e-comercial como texto. Repare na última linha — ao gerar o HTML de volta, o
navegador reescreve &. Ele sempre soube qual era a forma correta.
Tolerância não é permissão. A seção seguinte mostra onde ela acaba.
O & solto que engole o resto do link
A busca da loja filtra por gênero, seção e loja física. São três parâmetros
separados por &. O mesmo endereço aparece em dois lugares da página: no
href do link e escrito por extenso, para o cliente copiar.
import { JSDOM } from 'jsdom';
const url = '/busca?genero=jazz§=vinil®=sp';
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">
<a id="link" href="${url}">Jazz em vinil, loja de SP</a>
<code id="texto">${url}</code>`).window;
console.log('escrito :', url);
console.log('dentro do href :', document.getElementById('link').getAttribute('href'));
console.log('dentro do texto :', document.getElementById('texto').textContent);A mesma string, no mesmo arquivo. No atributo ela chegou inteira; no texto
visível, § virou § e ® virou ®. O cliente copia um endereço que
não existe, e ninguém vê erro nenhum no console.
O atributo escapou por causa de uma regra de compatibilidade do padrão: dentro
de um valor de atributo, um nome de entidade sem ponto e vírgula é deixado
quieto quando o caractere seguinte é =, uma letra ou um número — justamente o
desenho de um parâmetro de URL. Fora dessas hipóteses a proteção some. E o ponto
e vírgula, que você aprendeu a sempre escrever, aqui joga contra: ele confirma a
entidade e provoca a troca.
import { JSDOM } from 'jsdom';
const casos = [
'/busca?genero=jazz§=vinil',
'/busca?genero=jazz§2=vinil',
'/busca?genero=jazz§=vinil',
'/busca?genero=jazz§&pagina=2',
'/busca?genero=jazz&pagina=2®',
];
const html = casos.map((h, i) => `<a id="a${i}" href="${h}">x</a>`).join('');
const { document } = new JSDOM(`<!doctype html>${html}`).window;
for (const [i, escrito] of casos.entries()) {
const lido = document.getElementById(`a${i}`).getAttribute('href');
console.log(lido === escrito ? 'intacto' : 'QUEBROU', '|', lido);
}Três dos cinco quebraram. Os dois que passaram só passaram por acaso: o =
depois de §, e o 2 de §2. Nenhum dos dois é uma decisão sua — é o
nome do parâmetro que veio a calhar. Depender de qual caractere vem em seguida é
uma aposta que você não precisa fazer: escreva & entre os parâmetros e os
cinco casos ficam intactos, sem exceção e sem regra para decorar.
A aspa dentro do atributo, que vira quatro atributos
O texto alternativo da capa cita o nome do álbum entre aspas. Quando essas aspas são iguais às que delimitam o atributo, o navegador fecha o atributo onde você não queria:
<img src="/capas/kind-of-blue.webp" alt="Capa de "Kind of Blue", edição japonesa">
<img src="/capas/kind-of-blue.webp" alt="Capa de "Kind of Blue", edição japonesa">import { JSDOM } from 'jsdom';
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">
<img id="quebrado" src="/capas/kind-of-blue.webp" alt="Capa de "Kind of Blue", edição japonesa">
<img id="certo" src="/capas/kind-of-blue.webp" alt="Capa de "Kind of Blue", edição japonesa">`).window;
for (const id of ['quebrado', 'certo']) {
const img = document.getElementById(id);
console.log(id.toUpperCase());
console.log(' alt :', JSON.stringify(img.getAttribute('alt')));
console.log(' atributos:', [...img.attributes].map((a) => a.name).join(', '));
}O primeiro img ficou com um alt cortado em oito caracteres e ganhou cinco
atributos inventados: kind, of, blue",, edição e japonesa". Nenhum
deles significa nada, e quem usa leitor de tela ouve “Capa de” e o silêncio.
Ou você escreve ", ou troca a aspa de fora por apóstrofo. O que colocar
nesse texto está na lição de imagens em HTML.
Mostrar código HTML dentro de uma página HTML
A loja dá um selo para as lojas parceiras colarem no site delas. A página de ajuda precisa exibir esse trecho de HTML como texto. Sem escapar, o trecho não aparece — ele acontece:
<pre id="errado"><code><a href="https://discovoador.com.br">Selo Disco Voador</a></code></pre>
<pre id="certo"><code><a href="https://discovoador.com.br">Selo Disco Voador</a></code></pre>import { JSDOM } from 'jsdom';
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">
<pre id="errado"><code><a href="https://discovoador.com.br">Selo Disco Voador</a></code></pre>
<pre id="certo"><code><a href="https://discovoador.com.br">Selo Disco Voador</a></code></pre>`).window;
for (const id of ['errado', 'certo']) {
const bloco = document.getElementById(id);
console.log(id.toUpperCase());
console.log(' o cliente copia :', JSON.stringify(bloco.textContent));
console.log(' tags dentro :', [...bloco.querySelectorAll('*')].map((e) => e.tagName).join(', '));
}No primeiro caso o <a> virou um link de verdade dentro do <code>: o cliente
seleciona, copia e leva só as palavras “Selo Disco Voador”. No segundo, <
e > fizeram o analisador tratar tudo como texto, e o que ele copia é o
código inteiro.
Essa mesma regra separa um site em pé de um site vandalizado. Se a resenha que
um cliente escreveu for colada na página sem escapar, uma resenha com <script>
dentro vira script executado. Por isso, ao escrever pela mão do JavaScript,
textContent é o padrão e innerHTML é a exceção — a diferença entre os dois
está na lição de innerHTML e textContent.
não é espaço: é o caractere 160
Espaço comum, espaço inquebrável e espaço estreito são idênticos na tela e diferentes na memória. A ficha de preço usa os três:
<span id="comum">R$ 249,90</span>
<span id="nbsp">R$ 249,90</span>
<span id="estreito">R$ 249,90</span>import { JSDOM } from 'jsdom';
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">
<span id="comum">R$ 249,90</span>
<span id="nbsp">R$ 249,90</span>
<span id="estreito">R$ 249,90</span>`).window;
for (const id of ['comum', 'nbsp', 'estreito']) {
const texto = document.getElementById(id).textContent;
const codigo = texto.codePointAt(2);
const hex = 'U+' + codigo.toString(16).toUpperCase().padStart(4, '0');
console.log(id.padEnd(9), '| decimal', String(codigo).padStart(4), '|', hex, '| split(" ") devolve', texto.split(' ').length);
}O 160 é o , e ele serve para uma coisa só: impedir que o navegador
separe duas palavras no fim da linha. R$ sozinho numa linha e 249,90 na
seguinte fica ruim de ler; 33 rpm, Lado B e
Simon & Garfunkel são os outros usos legítimos.
A última coluna é a que morde depois. O script da vitrine ordena por preço partindo o texto no espaço — e o espaço que ele procura não está lá:
import { JSDOM } from 'jsdom';
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">
<span class="preco">R$ 249,90</span>
<span class="preco">R$ 189,90</span>`).window;
function valorNumerico(elemento) {
const [, numero] = elemento.textContent.split(' ');
return Number(numero.replace(',', '.'));
}
const precos = document.querySelectorAll('.preco');
console.log('primeiro disco:', valorNumerico(precos[0]));
console.log('segundo disco :', valorNumerico(precos[1]));O primeiro preço saiu. O segundo estourou porque split(' ') devolveu um array
com um elemento só e numero ficou undefined. Os dois preços apareciam
iguais na tela; o defeito só nasce quando alguém clica em “ordenar por preço”.
Se esse TypeError já te pegou, a página de
Cannot read properties of undefined
destrincha as outras causas dele.
Por que o navegador junta os seus espaços num só
Fora do caractere 160, o HTML é indiferente à sua formatação. Em qualquer elemento comum — parágrafo, título, item de lista —, uma sequência de espaços, tabulações e quebras de linha é desenhada como um espaço:
<p>Entrega em 24h
para toda a capital</p>import { JSDOM } from 'jsdom';
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">
<p id="p">Entrega em 24h
para toda a capital</p>`).window;
const texto = document.getElementById('p').textContent;
const desenhado = texto.replace(/[ \t\n\r]+/g, ' ');
console.log('caracteres no DOM :', texto.length);
console.log('texto cru :', JSON.stringify(texto));
console.log('o que aparece :', JSON.stringify(desenhado));
console.log('caracteres na tela :', desenhado.length);Os 45 caracteres continuam no DOM. Quem descarta os 11 excedentes é a
renderização, não o analisador — e isso importa na prática: um textContent
lido por JavaScript devolve os 45, com os espaços todos, mesmo que a tela mostre
34. Medir texto pelo que você vê é como comparar dois preços pelo tamanho da
etiqueta.
A exceção é o pre, aquele mesmo do selo lá em cima: dentro dele o navegador
preserva espaço, tabulação e quebra de linha exatamente como estão no arquivo.
Fora dele, quem decide é a propriedade white-space do CSS, e o valor padrão
dela é juntar tudo.
O caractere 160 também não entra nessa conta, porque não conta como espaço em
branco para efeito de junção — é por isso que funciona como
recuo, e é por isso que essa gambiarra precisa virar padding no CSS antes de
alguém abrir o seu HTML.
Acento e cedilha: com UTF-8 a entidade virou opcional
ç, ã e é são herança de quando o arquivo não
conseguia guardar esses caracteres. Hoje as duas escritas produzem exatamente a
mesma string:
import { JSDOM } from 'jsdom';
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">
<p id="direto">Edição japonesa — gravado em São Paulo</p>
<p id="entidade">Edição japonesa — gravado em São Paulo</p>`).window;
const a = document.getElementById('direto').textContent;
const b = document.getElementById('entidade').textContent;
console.log('escrito direto :', a);
console.log('com entidade :', b);
console.log('deu no mesmo? :', a === b);
const bytes = Buffer.from('Edição japonesa', 'utf8');
console.log('letras :', 'Edição japonesa'.length);
console.log('bytes em utf-8 :', bytes.length);
console.log('lido como latin1:', bytes.toString('latin1'));Escreva o acento direto: é mais curto, é pesquisável no editor e é o que o
tradutor automático entende. As três últimas linhas explicam o outro sintoma
famoso: 15 letras ocupam 17 bytes, porque ç e ã gastam dois bytes cada, e
quem lê esses bytes como latin1 mostra ç no lugar do ç. A cura não é
voltar para as entidades — é a linha <meta charset="utf-8"> no começo do
<head>, assunto da lição de meta tags no HTML e
do erro character encoding not declared.
Símbolos que ainda valem a pena
Estes você não tem no teclado, e a entidade é mais fácil de digitar e de revisar do que o caractere colado de algum lugar. A ficha técnica do disco usa seis deles:
<ul>
<li>© 2026 Disco Voador</li>
<li>Rotação: 33⅓ rpm</li>
<li>Capa: 31,4 × 31,4 cm</li>
<li>Frete grátis → acima de R$ 199</li>
<li>Guardar a 20 °C</li>
<li>Aguardando reposição…</li>
</ul>
<p id="vezes">× × × ×</p>O último parágrafo escreve o mesmo sinal de multiplicação de quatro maneiras diferentes, de propósito. O script abaixo imprime a ficha e depois compara as quatro formas:
import { JSDOM } from 'jsdom';
const ficha = `<ul>
<li>© 2026 Disco Voador</li>
<li>Rotação: 33⅓ rpm</li>
<li>Capa: 31,4 × 31,4 cm</li>
<li>Frete grátis → acima de R$ 199</li>
<li>Guardar a 20 °C</li>
<li>Aguardando reposição…</li>
</ul>
<p id="vezes">× × × ×</p>`;
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8">${ficha}`).window;
for (const li of document.querySelectorAll('li')) console.log(li.textContent);
const formas = document.getElementById('vezes').textContent.split(' ');
console.log('as quatro formas dão o mesmo caractere?', new Set(formas).size === 1);
console.log('número desse caractere:', formas[0].codePointAt(0));As duas últimas linhas são o ponto: ×, ×, × e o ×
colado direto chegam ao DOM como um único caractere, o de número 215. Todo
caractere tem um número, e o nome é só um atalho para ele.
| forma | como se escreve | quando usar |
|---|---|---|
| nome | × |
o símbolo tem nome e você lembra dele |
| decimal | × |
símbolo sem nome, com o número em mãos |
| hexadecimal | × |
copiando de tabela Unicode, que usa U+00D7 |
| direto | × |
o caractere já está no texto e o arquivo é UTF-8 |
A entidade escrita errado, que aparece como texto na tela
Não existe erro no console para entidade malformada. O sintoma é o próprio código aparecendo na página. Quatro tentativas quase iguais, e só duas delas chegam ao caractere 160:
<li>R$&NBSP;249,90</li>
<li>R$ 249,90</li>
<li>R$&espaco;249,90</li>
<li>R$ 249,90</li>import { JSDOM } from 'jsdom';
const tentativas = ['R$&NBSP;249,90', 'R$ 249,90', 'R$&espaco;249,90', 'R$ 249,90'];
const html = tentativas.map((t, i) => `<li id="i${i}">${t}</li>`).join('');
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8"><ul>${html}</ul>`).window;
for (const [i, escrito] of tentativas.entries()) {
const texto = document.getElementById(`i${i}`).textContent;
const codigos = [...texto].map((c) => c.codePointAt(0)).join(' ');
console.log(escrito.padEnd(16), '| na tela:', JSON.stringify(texto).padEnd(18), '| códigos:', codigos);
}Três leituras dessa saída, e as três contrariam o que a maioria supõe:
- Nome de entidade tem caixa.
&NBSP;não é , e ficou na tela como texto. O38que aparece logo depois doR$naquela linha de códigos é o e-comercial em pessoa, entregue como caractere comum. - O ponto e vírgula é opcional em alguns nomes antigos.
 sem ponto e vírgula produziu o mesmo 160. Isso funciona por compatibilidade com páginas dos anos 90, não por estar certo. Escreva sempre o;. - Nome inventado não avisa.
&espaco;saiu inteiro na página. É assim que o problema chega ao cliente: um preço com texto estranho no meio e nenhuma linha vermelha no console.
A primeira leitura tem uma armadilha embutida. Existe um punhado de nomes antigos que o padrão registrou duas vezes, um em minúsculo e outro em maiúsculo — e quem for conferir a regra da caixa justamente com um deles conclui o contrário do que acabou de ler:
import { JSDOM } from 'jsdom';
const nomes = ['©', '©', '&Copy;', '×', '&TIMES;', ' ', '&NBSP;'];
const html = nomes.map((n, i) => `<li id="n${i}">${n}</li>`).join('');
const { document } = new JSDOM(`<!doctype html><meta charset="utf-8"><ul>${html}</ul>`).window;
for (const [i, nome] of nomes.entries()) {
const texto = document.getElementById(`n${i}`).textContent;
console.log(nome.padEnd(8), '->', JSON.stringify(texto), texto === nome ? '(ficou como texto)' : '(virou caractere)');
}© funciona e &TIMES; não. Isso não é incoerência do navegador, e também
não derruba a leitura de cima: o que o padrão tem não é uma regra de maiúsculas
e minúsculas, e sim uma lista fechada de nomes literais. Nessa lista,
© e © são duas entradas separadas, cadastradas assim desde o HTML
dos anos 90, enquanto × só entrou em minúsculo. &Copy;, que não está
lá em forma nenhuma, morre como qualquer nome inventado.
Por isso, quando aparecer &alguma-coisa; escrito na tela do seu site, a
checagem é sempre a mesma, nessa ordem: a caixa das letras, o ponto e vírgula, e
se aquele nome exato existe na lista de referências do padrão — que está linkada
nas fontes desta página.
O que vem depois
Você já sabe escrever qualquer caractere dentro do HTML. O passo seguinte é dar forma a esse texto — negrito, ênfase, citação e código — na lição de formatar texto em HTML. E se quiser ver onde esta lição encaixa no caminho inteiro, o guia de HTML mostra a ordem de estudo do começo ao fim.
Faça uma página com três linhas: uma expressão escrita como 3 < 5, um link cuja query tem
dois parâmetros e um preço R$ 249,90 que não pode quebrar entre moeda e valor.
Use <, & no atributo e apenas no último caso. Inspecione
textContent, innerHTML e o href final para confirmar o caractere exibido e
a sintaxe preservada.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Preciso escrever > ou posso deixar o sinal de maior solto no texto?
Qual a diferença entre   e  ?
Posso usar para afastar dois elementos na página?
Emoji e símbolo de moeda precisam de entidade?
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 — Entidade HTML — developer.mozilla.org
- HTML Standard — Named character references — html.spec.whatwg.org



