Back

Crear una tabla de contenidos a partir de encabezados en JavaScript

Crear una tabla de contenidos a partir de encabezados en JavaScript

Las páginas largas sin navegación frustran a los lectores. Se desplazan sin rumbo, pierden el hilo y abandonan. Una tabla de contenidos dinámica resuelve esto convirtiendo la estructura de encabezados existente en enlaces ancla clicables, de forma automática y sin necesidad de librerías o frameworks.

Este artículo te muestra cómo construir una usando JavaScript moderno y puro, manejar los casos límite habituales, agregar una generación accesible de la TOC y, opcionalmente, resaltar la sección activa a medida que el usuario se desplaza.

Puntos clave

  • Consulta los encabezados con un selector acotado para evitar incluir encabezados no relacionados de cabeceras, barras laterales o pies de página.
  • Genera IDs seguros y amigables para URLs convirtiendo el texto de los encabezados en slugs y rastreando duplicados con un contador.
  • Envuelve la lista generada en un elemento <nav> con un aria-label para crear un landmark de navegación accesible.
  • Usa IntersectionObserver en lugar de listeners de scroll para resaltar la sección activa de manera eficiente.
  • Habilita el desplazamiento suave con una sola regla CSS: scroll-behavior: smooth.

Cómo funciona la navegación por encabezados del DOM

La idea central es sencilla:

  1. Consultar todos los elementos de encabezado del DOM.
  2. Generar un id seguro para cada encabezado.
  3. Construir una lista de enlaces ancla que apunten a esos IDs.
  4. Inyectar la lista en un contenedor designado.

Sin parseo de HTML crudo con expresiones regulares. Sin jQuery. Solo querySelectorAll y métodos estándar del DOM.

Seleccionar y preparar los encabezados

const headings = document.querySelectorAll('article h2, article h3, article h4');

Acota tu selector al área de contenido —article, main o un contenedor específico— para no incluir accidentalmente encabezados del header o la barra lateral del sitio.

Generar IDs de anclaje seguros

El texto de los encabezados suele contener espacios, caracteres especiales o signos de puntuación que dificultan la lectura de los fragmentos de URL o que resultan incómodos al usarse en selectores. Normaliza el id de cada encabezado antes de utilizarlo:

function slugify(text) {
  return text
    .toLowerCase()
    .trim()
    .replace(/\s+/g, '-')
    .replace(/[^\w-]/g, '') || 'section';
}

Maneja los duplicados rastreando los slugs ya usados y añadiendo un 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}`;
}

Omite los encabezados vacíos o que solo contengan espacios en blanco con una simple comprobación: if (!heading.textContent.trim()) return;.

Construir la tabla de contenidos en JavaScript

Con los IDs asignados, construye la lista de la TOC e inyéctala en la 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 garantiza que los encabezados existan antes de que se ejecute el script.

Generación accesible de la TOC

Envolver la lista en un elemento <nav> con aria-label="Table of contents" crea un landmark de navegación con nombre. Los lectores de pantalla exponen estos landmarks directamente, permitiendo a los usuarios saltar a la TOC sin tener que tabular por toda la página.

Mantén una jerarquía lógica de encabezados en tu contenido: no saltes de h2 a h4. La TOC refleja la estructura de tu documento, por lo que los saltos entre niveles de encabezado producen una navegación confusa para todos los usuarios.

Resaltar la sección activa con IntersectionObserver

Para resaltar qué sección está actualmente a la vista, usa la API IntersectionObserver en lugar 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));

El rootMargin reduce la zona de detección, de modo que un encabezado solo se marca como activo cuando está cerca de la parte superior del viewport. Este enfoque es más eficiente que los manejadores de scroll con throttling y no requiere limpieza en la mayoría de las páginas estáticas. IntersectionObserver es compatible con todos los navegadores modernos.

Desplazamiento suave

Agrega esta única regla CSS para habilitar el comportamiento de desplazamiento suave en toda la página sin necesidad de JavaScript:

html {
  scroll-behavior: smooth;
}

La propiedad scroll-behavior es compatible con todos los navegadores modernos evergreen.

Conclusión

Una tabla de contenidos funcional en JavaScript necesita cuatro cosas: selección acotada de encabezados, generación de IDs seguros frente a colisiones, un envoltorio semántico <nav> para una generación accesible de la TOC y la sincronización con DOMContentLoaded. La mejora con IntersectionObserver es opcional, pero añade una UX significativa con muy poco código.

Este patrón funciona en cualquier sitio estático, página de documentación o blog, sin paso de build, sin dependencias y sin necesidad de frameworks.

Preguntas frecuentes

Ambas opciones funcionan, pero la generación del lado del cliente es más simple y mantiene la lógica en un solo lugar. El renderizado del lado del servidor es preferible para páginas críticas para SEO o cuando JavaScript está deshabilitado, ya que los enlaces ancla y los IDs de los encabezados estarán presentes en el HTML inicial. Para la mayoría de los blogs y sitios de documentación, la generación del lado del cliente con DOMContentLoaded es suficiente.

Verifica si existe un id antes de generar uno nuevo. Si un encabezado tiene un id asignado manualmente, reutilízalo para que los enlaces externos y los marcadores sigan funcionando. Agrega una condición como if heading.id usa el valor existente, de lo contrario llama a uniqueSlug. Esto preserva la intención del autor y evita romper los enlaces entrantes a secciones específicas.

Los eventos de scroll se disparan decenas de veces por segundo y fuerzan cálculos de layout en cada llamada, lo que perjudica el rendimiento especialmente en móviles. IntersectionObserver observa los cambios de visibilidad de forma asíncrona y solo se dispara cuando la visibilidad realmente cambia. También es más fácil configurar umbrales y márgenes de forma declarativa, sin escribir tu propia lógica de throttle o debounce.

Rastrea el nivel del encabezado actual mientras iteras. Cuando el siguiente encabezado es más profundo, crea un ol anidado dentro del li anterior. Cuando es más superficial, retrocede por la cadena de padres. Almacenar una pila de elementos de lista indexada por nivel de encabezado mantiene la lógica limpia y soporta una profundidad de anidamiento arbitraria sin tener que codificar cada nivel a mano.

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.

OpenReplay