Crie um Sumário a partir de Cabeçalhos em JavaScript
Páginas longas sem navegação frustram os leitores. Eles rolam sem rumo, perdem o lugar e abandonam a página. Um sumário dinâmico resolve isso ao transformar a estrutura de cabeçalhos existente em links âncora clicáveis — automaticamente, sem biblioteca ou framework.
Este artigo mostra como construir um usando JavaScript moderno puro, lidar com casos extremos comuns, adicionar geração acessível de sumário e, opcionalmente, destacar a seção ativa conforme o usuário rola a página.
Principais Conclusões
- Consulte os cabeçalhos com um seletor com escopo definido para evitar incluir cabeçalhos não relacionados de headers, sidebars ou footers.
- Gere IDs amigáveis para URL e livres de colisões fazendo slugify do texto do cabeçalho e rastreando duplicatas com um contador.
- Envolva a lista gerada em um elemento
<nav>com umaria-labelpara criar um marco de navegação acessível. - Use
IntersectionObserverem vez de listeners de scroll para destacar a seção ativa de forma eficiente. - Habilite rolagem suave com uma única regra CSS:
scroll-behavior: smooth.
Como Funciona a Navegação por Cabeçalhos no DOM
A ideia central é simples:
- Consultar todos os elementos de cabeçalho no DOM.
- Gerar um
idseguro para cada cabeçalho. - Construir uma lista de links âncora apontando para esses IDs.
- Injetar a lista em um contêiner designado.
Sem parsing de HTML bruto com regex. Sem jQuery. Apenas querySelectorAll e métodos padrão do DOM.
Selecionando e Preparando Cabeçalhos
const headings = document.querySelectorAll('article h2, article h3, article h4');
Defina o escopo do seu seletor para a área de conteúdo — article, main ou um contêiner específico — para não capturar acidentalmente cabeçalhos do header ou sidebar do seu site.
Gerando IDs Seguros para Âncoras
O texto dos cabeçalhos frequentemente contém espaços, caracteres especiais ou pontuação que tornam os fragmentos de URL difíceis de ler ou desconfortáveis de usar em seletores. Normalize o id de cada cabeçalho antes de usá-lo:
function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '') || 'section';
}
Lide com duplicatas rastreando os slugs utilizados e anexando um contador:
const usedIds = new Map();
function uniqueSlug(text) {
const base = slugify(text);
const count = usedIds.get(base) ?? 0;
usedIds.set(base, count + 1);
return count === 0 ? base : `${base}-${count}`;
}
Ignore cabeçalhos vazios ou contendo apenas espaços em branco com uma verificação simples: if (!heading.textContent.trim()) return;.
Discover how at OpenReplay.com.
Construindo o Sumário em JavaScript
Com os IDs atribuídos, construa a lista do sumário e injete-a na página:
function buildTOC(containerSelector, headingSelector) {
const container = document.querySelector(containerSelector);
const headings = document.querySelectorAll(headingSelector);
if (!container || !headings.length) return;
const nav = document.createElement('nav');
nav.setAttribute('aria-label', 'Table of contents');
const ol = document.createElement('ol');
headings.forEach(heading => {
const text = heading.textContent.trim();
if (!text) return;
const id = heading.id || uniqueSlug(text);
heading.id = id;
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `#${id}`;
a.textContent = text;
li.appendChild(a);
ol.appendChild(li);
});
nav.appendChild(ol);
container.appendChild(nav);
}
document.addEventListener('DOMContentLoaded', () => {
buildTOC('#toc', 'article h2, article h3');
});
Usar DOMContentLoaded garante que os cabeçalhos existam antes do script ser executado.
Geração Acessível de Sumário
Envolver a lista em um elemento <nav> com aria-label="Table of contents" cria um marco de navegação nomeado. Leitores de tela apresentam esses marcos diretamente, permitindo que os usuários saltem para o sumário sem precisar navegar pela página com Tab.
Preserve uma hierarquia lógica de cabeçalhos em seu conteúdo — não pule de h2 direto para h4. O sumário reflete a estrutura do seu documento, então lacunas nos níveis de cabeçalho geram uma navegação confusa para todos os usuários.
Destacando a Seção Ativa com IntersectionObserver
Para destacar qual seção está atualmente visível, use a API IntersectionObserver em vez de listeners de eventos de scroll:
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const id = entry.target.getAttribute('id');
const link = document.querySelector(`nav a[href="#${id}"]`);
if (link) link.classList.toggle('active', entry.isIntersecting);
});
}, { rootMargin: '0px 0px -80% 0px' });
document.querySelectorAll('article h2, article h3').forEach(h => observer.observe(h));
O rootMargin reduz a zona de detecção para que um cabeçalho seja marcado como ativo apenas quando estiver próximo ao topo da viewport. Essa abordagem é mais performática do que handlers de scroll com throttle e não exige cleanup na maioria das páginas estáticas. O IntersectionObserver é suportado em todos os navegadores modernos.
Rolagem Suave
Adicione esta única regra CSS para habilitar o comportamento de rolagem suave em toda a página, sem nenhum JavaScript:
html {
scroll-behavior: smooth;
}
A propriedade scroll-behavior é suportada em todos os navegadores modernos evergreen.
Conclusão
Um sumário em JavaScript funcional precisa de quatro coisas: seleção de cabeçalhos com escopo definido, geração de IDs livres de colisões, um wrapper semântico <nav> para geração acessível de sumário e o timing do DOMContentLoaded. A melhoria com IntersectionObserver é opcional, mas adiciona uma UX significativa com pouquíssimo código.
Esse padrão funciona em qualquer site estático, página de documentação ou blog — sem etapa de build, sem dependências, sem framework necessário.
FAQs
Ambos funcionam, mas a geração no lado do cliente é mais simples e mantém a lógica em um só lugar. A renderização no servidor é preferível para páginas críticas para SEO ou quando o JavaScript está desativado, já que os links âncora e os IDs dos cabeçalhos estarão presentes no HTML inicial. Para a maioria dos blogs e sites de documentação, a geração no cliente com DOMContentLoaded é suficiente.
Verifique a existência de um id antes de gerar um novo. Se um cabeçalho tem um id atribuído manualmente, reutilize-o para que links externos e favoritos continuem funcionando. Adicione uma condição como if heading.id use o valor existente, caso contrário chame uniqueSlug. Isso preserva a intenção do autor e evita quebrar links de entrada para seções específicas.
Eventos de scroll disparam dezenas de vezes por segundo e forçam cálculos de layout a cada chamada, o que prejudica o desempenho, especialmente em dispositivos móveis. O IntersectionObserver observa mudanças de visibilidade de forma assíncrona e só dispara quando a visibilidade realmente muda. Também é mais fácil configurar thresholds e margens de forma declarativa, sem precisar escrever lógica de throttle ou debounce manualmente.
Rastreie o nível atual de cabeçalho durante a iteração. Quando o próximo cabeçalho for mais profundo, crie um ol aninhado dentro do li anterior. Quando for mais raso, retorne pela cadeia de pais. Armazenar uma pilha de elementos de lista indexados pelo nível de cabeçalho mantém a lógica limpa e suporta profundidade arbitrária de aninhamento sem precisar codificar manualmente cada nível.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.