Listas em HTML: ul, ol, li e lista de definição
Lista com marcador, numerada e de definição: qual usar em cada caso, como aninhar sem quebrar e o que o navegador entrega ao leitor de tela.
Lista em HTML é um conjunto de itens que o navegador anuncia como conjunto: ele
sabe quantos são e em que nível cada um está. ul é para itens sem ordem
obrigatória, ol é para sequência, dl é para pares de termo e descrição — e
li é o item, que só significa alguma coisa com uma lista em volta.
Todos os exemplos aqui são do site da Livraria Vagalume, uma livraria de bairro com sebo nos fundos. É o mesmo domínio de imagens em HTML, a lição anterior desta trilha.
Lista é uma estrutura semântica de coleção. Em palavras simples, o navegador passa a saber onde o conjunto começa, quantos itens possui e se a ordem faz parte do significado; a bolinha ou o número é só a apresentação padrão.
O conjunto que o navegador consegue contar
Uma lista de compras continua útil se leite e pão trocarem de posição; já uma
receita muda se assar vier antes de misturar. ul representa a primeira
situação e ol, a segunda. O li identifica cada unidade do conjunto, e o
navegador usa essa estrutura para anunciar contagem e níveis a quem navega com
tecnologia assistiva.
Antes de abrir o primeiro exemplo, diga em voz alta o que deveria ser anunciado:
tipo da lista, quantidade e posição do item. Depois confira na árvore de
acessibilidade. Troque apenas ul por ol no DevTools e observe qual informação
semântica muda mesmo sem tocar no texto.
As seções da livraria não têm ordem: romance não vem antes de poesia por algum motivo. Já a retirada de uma reserva tem: você não retira antes de reservar.
<ul>
<li>Romance</li>
<li>Poesia</li>
<li>Quadrinhos</li>
</ul>
<ol>
<li>Escolher o exemplar no site</li>
<li>Reservar com o seu CPF</li>
<li>Retirar no balcão em até 48 horas</li>
</ol>A diferença que você vê é a bolinha virar número. A diferença que importa é outra, e para enxergá-la eu preciso perguntar ao navegador o que ele entendeu.
O auditor que pergunta ao navegador
O Chrome monta, ao lado do DOM, uma segunda árvore: a árvore de acessibilidade. É dela que o leitor de tela lê. Dá para pedir essa árvore pelo protocolo de depuração do Chrome e imprimir o resultado:
import { chromium } from 'playwright-core';
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PULAR = ['RootWebArea', 'none', 'generic', 'StaticText', 'InlineTextBox', 'ListMarker'];
export async function auditar(corpo) {
const navegador = await chromium.launch({ executablePath: CHROME, headless: true });
const pagina = await navegador.newPage();
const doc = `<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
<title>Livraria Vagalume</title></head><body>${corpo}</body></html>`;
await pagina.goto('data:text/html,' + encodeURIComponent(doc));
const cdp = await pagina.context().newCDPSession(pagina);
await cdp.send('Accessibility.enable');
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
await navegador.close();
const porId = new Map(nodes.map((n) => [n.nodeId, n]));
const filhos = (no, papel) =>
(no.childIds ?? []).map((id) => porId.get(id)).filter((f) => f?.role?.value === papel);
const texto = (no) =>
(no.childIds ?? [])
.flatMap((id) => {
const f = porId.get(id);
if (!f) return [];
return f.role?.value === 'StaticText' ? [f.name.value.trim()] : texto(f);
})
.filter(Boolean);
const linhas = [];
const anda = (id, recuo) => {
const no = porId.get(id);
const papel = no?.role?.value;
let proximo = recuo;
if (no && !no.ignored && papel && !PULAR.includes(papel)) {
let linha = ' '.repeat(recuo) + papel;
if (papel === 'list') linha += ` itens=${filhos(no, 'listitem').length}`;
else if (papel === 'listitem') {
const marcador = filhos(no, 'ListMarker')[0]?.name.value.trim() ?? '';
linha += ` marcador="${marcador}" "${texto(no).filter((t) => t !== marcador).join(' ')}"`;
} else linha += ` "${no.name?.value || texto(no).join(' ')}"`;
linhas.push(linha);
proximo = recuo + 1;
}
for (const filho of no?.childIds ?? []) anda(filho, proximo);
};
anda(nodes[0].nodeId, 0);
return linhas.join('\n');
}Passando as duas listas acima para ele, sai isto:
Repare no itens=3. O navegador conta os itens e entrega essa contagem
pronta. É por isso que um leitor de tela consegue avisar “lista com 3 itens”
antes de ler o primeiro: a informação já estava lá. Uma sequência de div com
bolinha desenhada no CSS não produz esse número.
| lista | o conteúdo é | o que sai da árvore |
|---|---|---|
ul |
um conjunto em que a ordem não muda nada | list com contagem, marcador • |
ol |
uma sequência em que trocar a ordem muda o resultado | list com contagem, marcador numerado |
dl |
pares de termo e descrição | DescriptionList, com term e definition |
Numeração sob controle: start, reversed, type e value
A ol não começa obrigatoriamente no 1. O atributo start desloca a lista
inteira — útil quando um passo a passo é interrompido por uma imagem e continua
depois:
<ol start="4">
<li>Conferir o estado do exemplar</li>
<li>Pagar no caixa</li>
<li>Levar para casa</li>
</ol>reversed conta para trás. É o atributo do ranking — os mais vendidos da
semana, do terceiro lugar para o primeiro:
<ol reversed>
<li>Dom Casmurro</li>
<li>A hora da estrela</li>
<li>Grande Sertão: Veredas</li>
</ol>type troca o formato do número (1, a, A, i, I), e value num li
força o valor daquele item — os seguintes continuam a partir dele:
<ol type="a">
<li>Ficção</li>
<li>Não ficção</li>
</ol>
<ol>
<li>Primeira caixa do dia</li>
<li value="10">Décima caixa</li>
<li>Caixa seguinte</li>
</ol>O item marcado com value="10" puxou o próximo para 11. Nenhum desses valores
foi escrito à mão no HTML: quem calculou foi o navegador.
Lista dentro de lista: a sublista mora dentro do li
Literatura brasileira tem subseções por autor. A sublista é conteúdo do item
“Literatura brasileira”, então ela vai dentro do li, antes de fechar:
<ul>
<li>Romance</li>
<li>Literatura brasileira
<ul>
<li>Machado de Assis</li>
<li>Clarice Lispector</li>
</ul>
</li>
<li>Quadrinhos</li>
<li>Infantojuvenil</li>
</ul>Quatro itens no primeiro nível, e a lista de dois pendurada no segundo item.
Note também o marcador: o navegador trocou • por ◦ sozinho no nível 2. Você
não escreveu CSS nenhum para isso.
Agora a versão que quase todo mundo escreve por engano, com a sublista fora do
li:
<ul>
<li>Romance</li>
<li>Literatura brasileira</li>
<ul>
<li>Machado de Assis</li>
<li>Clarice Lispector</li>
</ul>
<li>Quadrinhos</li>
<li>Infantojuvenil</li>
</ul>Compare os dois recuos. Na primeira versão a sublista está dentro do item “Literatura brasileira”. Na segunda ela é irmã dos itens: aparece solta no meio da lista, sem dono. Machado de Assis deixou de ser subitem de coisa nenhuma — virou uma listinha que apareceu ali no meio.
Três markups, os mesmos pixels, três significados
Existe ainda uma terceira versão, a mais comum de todas: nenhuma sublista, só uma classe que empurra os itens para a direita.
<ul>
<li>Romance</li>
<li>Literatura brasileira</li>
<li class="sub">Machado de Assis</li>
<li class="sub">Clarice Lispector</li>
<li>Quadrinhos</li>
<li>Infantojuvenil</li>
</ul>body { font: 16px/1.5 system-ui; margin: 0; padding: 16px; }
ul { margin: 0; padding-left: 24px; }
.sub { margin-left: 24px; }As três versões parecem iguais na tela. E “parecem” aqui não é impressão: dá
para medir a posição de cada item renderizado. O versoes.mjs importado abaixo
não tem mistério — é só um objeto com as três marcações desta seção, nas chaves
A · sublista dentro do li, B · sublista fora do li e
C · margem no CSS, sem sublista.
import { chromium } from 'playwright-core';
import { versoes } from './versoes.mjs';
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const CSS =
'body{font:16px/1.5 system-ui;margin:0;padding:16px}ul{margin:0;padding-left:24px}.sub{margin-left:24px}';
const navegador = await chromium.launch({ executablePath: CHROME, headless: true });
const pagina = await navegador.newPage({ viewport: { width: 420, height: 400 } });
for (const [nome, corpo] of Object.entries(versoes)) {
await pagina.setContent(corpo);
await pagina.addStyleTag({ content: CSS });
const caixas = await pagina.evaluate(() =>
[...document.querySelectorAll('li')].map((li) => {
const r = li.getBoundingClientRect();
return `x=${Math.round(r.left)} y=${Math.round(r.top)} ${li.firstChild.textContent.trim()}`;
}),
);
console.log(nome + '\n' + caixas.join('\n') + '\n');
}
await navegador.close();B · sublista fora do li x=40 y=16 Romance x=40 y=40 Literatura brasileira x=64 y=64 Machado de Assis x=64 y=88 Clarice Lispector x=40 y=112 Quadrinhos x=40 y=136 Infantojuvenil
C · margem no CSS, sem sublista x=40 y=16 Romance x=40 y=40 Literatura brasileira x=64 y=64 Machado de Assis x=64 y=88 Clarice Lispector x=40 y=112 Quadrinhos x=40 y=136 Infantojuvenil
Pixel por pixel, as três são a mesma imagem. Nenhum item está um ponto fora do lugar. Mas a versão C, passada pelo auditor, diz outra coisa:
Seis itens, todos no mesmo nível. A versão A entregava quatro, com uma sublista de dois no nível de baixo. Quem enxerga a tela vê hierarquia; quem ouve a página recebe uma lista chapada de seis seções, na qual “Machado de Assis” é uma seção da livraria igual a “Quadrinhos”.
Esse é o resumo de por que aninhamento é HTML e não CSS: o recuo você desenha, o nível você declara. Mais sobre isso na lição de acessibilidade em HTML.
dl, dt e dd: a lista de termo e descrição
A dl é a lista esquecida, e ela resolve dois problemas que aparecem toda hora:
glossário e ficha técnica. dt é o termo, dd é a descrição — e um termo pode
ter mais de uma descrição:
<dl>
<dt>Sebo</dt>
<dd>Livraria que compra e revende exemplares usados.</dd>
<dt>Encadernação</dt>
<dd>Brochura: capa mole, colada.</dd>
<dd>Capa dura: costurada, com sobrecapa.</dd>
</dl>Os papéis mudam de nome: DescriptionList, term e definition. Não é uma
list com listitem — é outra estrutura, com o par explícito. Se você já
escreveu <p><strong>Autor:</strong> Machado de Assis</p> repetido cinco vezes
numa ficha de livro, era uma dl.
Duas regras que pegam gente: dt e dd só podem ser filhos da dl (ou de uma
div dentro dela, que é a única exceção da especificação), e o par é
posicional — o dd descreve o dt que veio antes dele, não existe atributo
ligando os dois.
Menu de navegação é lista, e o motivo não é a bolinha
Todo menu é uma lista de destinos. Com ul dentro de nav:
<nav aria-label="Principal">
<ul>
<li><a href="/acervo">Acervo</a></li>
<li><a href="/sebo">Sebo</a></li>
<li><a href="/clube">Clube de leitura</a></li>
<li><a href="/contato">Contato</a></li>
</ul>
</nav>Sem a lista, só os links soltos:
<nav aria-label="Principal">
<a href="/acervo">Acervo</a>
<a href="/sebo">Sebo</a>
<a href="/clube">Clube de leitura</a>
<a href="/contato">Contato</a>
</nav>Os dois funcionam. A diferença é o itens=4: na primeira versão a pessoa sabe
o tamanho do menu antes de entrar nele e pode decidir pular. Na segunda, ela
descobre andando. É pouco numa barra de quatro itens e é muito num rodapé de
trinta. Sobre a tag nav em si, veja
HTML semântico; sobre o href,
links em HTML.
Existe ainda um terceiro elemento que aceita li como filho: o menu. Ele não
é o menu de contexto que o nome sugere — no HTML de hoje, menu é uma ul com
outro nome, pensada para lista de comandos e ações. O navegador trata os dois
igual:
<menu>
<li>Acervo</li>
<li>Sebo</li>
</menu>Mesmo papel list, mesma contagem, mesma bolinha. Como não há ganho nenhum
sobre a ul e muita gente lê menu como se fosse outra coisa, na prática
continue usando ul — inclusive para menu.
O que o parser conserta — e o que ele deixa passar
O navegador tem uma tolerância enorme a HTML torto, mas ela não é uniforme.
Fechar o li é opcional pela especificação, e o parser resolve:
import { JSDOM } from 'jsdom';
const html = `<ul>
<li>Romance
<li>Poesia
<li>Quadrinhos
</ul>`;
const { document } = new JSDOM(html).window;
console.log(document.querySelector('ul').outerHTML);
console.log('itens:', document.querySelectorAll('li').length);Três itens, cada um com o fechamento que você não escreveu. Agora compare com o que ele faz numa tabela e numa lista com sublista mal colocada:
import { JSDOM } from 'jsdom';
const tabela = new JSDOM('<table><tr><td>Dom Casmurro</td></tr></table>');
console.log('tabela:', tabela.window.document.querySelector('table').outerHTML);
const lista = new JSDOM('<ul><li>Literatura brasileira</li><ul><li>Machado de Assis</li></ul></ul>');
console.log('lista: ', lista.window.document.querySelector('ul').outerHTML);Na tabela o parser inventou um tbody que não estava no seu código. Na
lista ele não mexeu em nada: o ul dentro do ul ficou exatamente onde você
o pôs, mesmo sendo markup inválido. Essa assimetria é a razão de o erro de
aninhamento sobreviver tanto tempo num projeto — o navegador nunca reclama.
Se você quer entender de onde vêm essas correções, a leitura é
como o navegador monta a árvore DOM.
O mesmo vale para uma div embrulhando itens, coisa que aparece quando alguém
quer estilizar um grupo:
<ul>
<li>Romance</li>
<div class="destaque">
<li>Literatura brasileira</li>
<li>Poesia</li>
</div>
<li>Quadrinhos</li>
</ul>Quatro itens aparecem, mas a lista só reconhece dois como filhos diretos:
os outros dois passaram a pendurar na div. Dentro de ul e ol só entram
li — se precisa de um grupo, a div vai dentro do li, ou você estiliza o
próprio li.
E o li fora de qualquer lista:
<li>Romance</li>
<li>Poesia</li>Os itens existem, com bolinha e tudo — e não há nenhuma linha list acima
deles. Sem ul, ol ou menu em volta não existe lista, e portanto não existe
contagem. Visualmente ninguém percebe; é o mesmo engano da versão C, com outra
roupa.
O próximo passo
Toda vez que você escrever uma lista daqui em diante, faça uma pergunta antes
de escolher a tag: se eu embaralhar os itens, alguma coisa quebra? Se
quebra, é ol. Se não quebra, é ul. Se cada item é um nome seguido de uma
explicação, é dl. E se cada item tem duas ou mais informações que se comparam
entre si, você já saiu do território da lista — é
tabela, a próxima lição da
trilha de HTML. O
guia completo de HTML mostra onde as duas entram no mapa.
Perguntas frequentes
Posso trocar a bolinha do ul por um traço ou por um ícone?
Preciso fechar a tag li?
Dá para colocar um parágrafo ou uma imagem dentro do li?
Uma ol dentro de outra ol numera 1.1 e 1.2 sozinha?
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 <ul> — developer.mozilla.org
- MDN — O elemento <dl> — developer.mozilla.org
- HTML Standard — Grouping content: ul, ol, li, dl — html.spec.whatwg.org
- Scott O'Hara — "Fixing" Lists — scottohara.me


