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

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.

Rodolfo Mori10 min de leitura

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.

html
<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:

js
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:

list itens=3 listitem marcador="•" "Romance" listitem marcador="•" "Poesia" listitem marcador="•" "Quadrinhos" list itens=3 listitem marcador="1." "Escolher o exemplar no site" listitem marcador="2." "Reservar com o seu CPF" listitem marcador="3." "Retirar no balcão em até 48 horas"

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:

html
<ol start="4">
  <li>Conferir o estado do exemplar</li>
  <li>Pagar no caixa</li>
  <li>Levar para casa</li>
</ol>
list itens=3 listitem marcador="4." "Conferir o estado do exemplar" listitem marcador="5." "Pagar no caixa" listitem marcador="6." "Levar para casa"

reversed conta para trás. É o atributo do ranking — os mais vendidos da semana, do terceiro lugar para o primeiro:

html
<ol reversed>
  <li>Dom Casmurro</li>
  <li>A hora da estrela</li>
  <li>Grande Sertão: Veredas</li>
</ol>
list itens=3 listitem marcador="3." "Dom Casmurro" listitem marcador="2." "A hora da estrela" listitem marcador="1." "Grande Sertão: Veredas"

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:

html
<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>
list itens=2 listitem marcador="a." "Ficção" listitem marcador="b." "Não ficção" list itens=3 listitem marcador="1." "Primeira caixa do dia" listitem marcador="10." "Décima caixa" listitem marcador="11." "Caixa seguinte"

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:

html
<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>
list itens=4 listitem marcador="•" "Romance" listitem marcador="•" "Literatura brasileira Machado de Assis Clarice Lispector" list itens=2 listitem marcador="◦" "Machado de Assis" listitem marcador="◦" "Clarice Lispector" listitem marcador="•" "Quadrinhos" listitem marcador="•" "Infantojuvenil"

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:

html
<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>
list itens=4 listitem marcador="•" "Romance" listitem marcador="•" "Literatura brasileira" list itens=2 listitem marcador="◦" "Machado de Assis" listitem marcador="◦" "Clarice Lispector" listitem marcador="•" "Quadrinhos" listitem marcador="•" "Infantojuvenil"

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.

html
<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>
css
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.

js
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();
A · sublista dentro 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

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:

list itens=6 listitem marcador="•" "Romance" listitem marcador="•" "Literatura brasileira" listitem marcador="•" "Machado de Assis" listitem marcador="•" "Clarice Lispector" listitem marcador="•" "Quadrinhos" listitem marcador="•" "Infantojuvenil"

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:

html
<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>
DescriptionList "Sebo Livraria que compra e revende exemplares usados. Encadernação Brochura: capa mole, colada. Capa dura: costurada, com sobrecapa." term "Sebo" definition "Livraria que compra e revende exemplares usados." term "Encadernação" definition "Brochura: capa mole, colada." definition "Capa dura: costurada, com sobrecapa."

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.

Todo menu é uma lista de destinos. Com ul dentro de nav:

html
<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>
navigation "Principal" list itens=4 listitem marcador="•" "Acervo" link "Acervo" listitem marcador="•" "Sebo" link "Sebo" listitem marcador="•" "Clube de leitura" link "Clube de leitura" listitem marcador="•" "Contato" link "Contato"

Sem a lista, só os links soltos:

html
<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>
navigation "Principal" link "Acervo" link "Sebo" link "Clube de leitura" link "Contato"

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:

html
<menu>
  <li>Acervo</li>
  <li>Sebo</li>
</menu>
list itens=2 listitem marcador="•" "Acervo" listitem marcador="•" "Sebo"

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:

js
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);
<ul> <li>Romance </li><li>Poesia </li><li>Quadrinhos </li></ul> itens: 3

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:

js
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);
tabela: <table><tbody><tr><td>Dom Casmurro</td></tr></tbody></table> lista: <ul><li>Literatura brasileira</li><ul><li>Machado de Assis</li></ul></ul>

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:

html
<ul>
  <li>Romance</li>
  <div class="destaque">
    <li>Literatura brasileira</li>
    <li>Poesia</li>
  </div>
  <li>Quadrinhos</li>
</ul>
list itens=2 listitem marcador="•" "Romance" listitem marcador="•" "Literatura brasileira" listitem marcador="•" "Poesia" listitem marcador="•" "Quadrinhos"

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:

html
<li>Romance</li>
<li>Poesia</li>
listitem marcador="•" "Romance" listitem marcador="•" "Poesia"

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.

  • html
  • listas
  • ul
  • ol
  • dl

Perguntas frequentes

Posso trocar a bolinha do ul por um traço ou por um ícone?
Pode, e isso é trabalho do CSS, não do HTML. A propriedade list-style-type aceita palavras-chave prontas (disc, circle, square, decimal) e também uma string entre aspas: com list-style-type: "— " o marcador passa a ser um traço. O elemento continua sendo ul, então o significado não muda.
Preciso fechar a tag li?
O HTML permite omitir o fechamento, e o navegador fecha por você quando encontra o próximo li ou o fim da lista. Mesmo assim, feche: linter, formatador e quem for ler o seu código depois esperam a tag fechada, e um li sem fechar esconde erro de aninhamento.
Dá para colocar um parágrafo ou uma imagem dentro do li?
Dá. O li aceita conteúdo de fluxo, então parágrafo, imagem, título e até outra lista cabem lá dentro. O que não cabe é conteúdo solto entre um li e outro, dentro do ul.
Uma ol dentro de outra ol numera 1.1 e 1.2 sozinha?
Não. Cada ol abre a própria contagem: no Chrome 151 a lista de dentro volta para 1, e a árvore de acessibilidade mostra marcador "1." nos dois níveis. Numeração hierárquica (1.1, 1.2) é trabalho de CSS, com counter-reset e counter-increment — o HTML não tem atributo para isso.

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 <ul> — developer.mozilla.org
  2. MDN — O elemento <dl> — developer.mozilla.org
  3. HTML Standard — Grouping content: ul, ol, li, dl — html.spec.whatwg.org
  4. Scott O'Hara — "Fixing" Lists — scottohara.me

Continue por aqui