notes
14 sept 2025 · 1 min read

Una tabla de contenidos sin un framework

Las entradas más largas de este sitio empezaban a necesitar algún tipo de navegación dentro de la página —una lista lateral de encabezados, idealmente una que resalte la sección actualmente visible. Mi primer instinto fue buscar un paquete de npm. Mi segundo instinto, mejor, fue recordar que la premisa entera de este sitio es enviar casi cero JavaScript, y una tabla de contenidos no es una buena razón para romper eso.

Construyendo la lista en sí

Extraer los encabezados es la mitad fácil —el renderizado de markdown de Astro ya le da a cada encabezado un id, así que un paso en tiempo de build simplemente recorre el HTML ya renderizado y recolecta el texto y los ids de h2/h3 en una lista, sin necesitar JS del lado del cliente para esa parte en absoluto.

La parte que necesita el navegador

Resaltar la sección actual sí necesita correr en el navegador, ya que depende de la posición de scroll. Un IntersectionObserver en cada encabezado, en lugar de un listener de scroll haciendo cálculos en cada frame, lo mantiene barato:

const headings = document.querySelectorAll('article :is(h2, h3)[id]');
const tocLinks = document.querySelectorAll('.toc a');

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    tocLinks.forEach((link) => link.classList.remove('is-active'));
    document
      .querySelector(`.toc a[href="#${entry.target.id}"]`)
      ?.classList.add('is-active');
  }
}, { rootMargin: '-20% 0px -70% 0px' });

headings.forEach((heading) => observer.observe(heading));

Cuarenta líneas, sin dependencias, y solo corre en el puñado de entradas lo bastante largas como para realmente necesitar una tabla de contenidos en primer lugar, ya que el script solo se activa si la página tiene más de unos pocos encabezados.