Construir un TOC lateral derecho para documentación: sticky, scroll-margin y resaltado de sección activa
Tu página de documentación es larga. El encabezado está fijado. Alguien hace clic en una entrada del TOC y queda ubicado debajo del título, medio oculto tras la barra de navegación.
Luego el resaltado activo miente sobre dónde están. No es un “pequeño fallo de UI”; es fricción que hace que la gente abandone la página.
Así es como construyes una tabla de contenidos en la derecha que se comporta como si estuviera de guardia: fija pero no molesta, saltos de ancla que aterrizan correctamente,
y resaltado de sección activo que no colapsa el hilo principal ni engaña a tus lectores.
La mayoría de los fallos del TOC son autoinfligidos: IDs de encabezado malos, offsets de desplazamiento ausentes y listeners de scroll frágiles.
Tabla de contenidos
El TOC que ves a la derecha se construye a partir de los encabezados de esta página. Si te parece aburridamente fiable, bien. Ese es el objetivo.
- Requisitos que realmente importan
- Hechos y contexto: por qué los TOC se volvieron raros
- Diseño: TOC lateral fijo que no lucha con la página
- Navegación con anclas: scroll-margin y manejo de offsets
- Resaltado de sección activo: IntersectionObserver bien aplicado
- Rendimiento y fiabilidad: evita fallos autoinfligidos
- Guía rápida de diagnóstico
- Errores comunes (síntoma → causa → solución)
- Tres microhistorias corporativas desde el campo
- Tareas prácticas (comandos, salidas, decisiones)
- Listas de verificación / plan paso a paso
- Preguntas frecuentes
- Conclusión: próximos pasos que puedes desplegar
Requisitos que realmente importan
Un TOC es un sistema de navegación. Trátalo como tal. Eso significa que tiene requisitos más allá de “se ve bien en mi pantalla”.
En la práctica, estás optimizando para: corrección, respuesta percibida, accesibilidad y bajo mantenimiento.
Requisitos de corrección
- Los saltos de ancla aterrizan en la línea visual correcta, no detrás de encabezados fijos. Usa
scroll-margin-topen los encabezados; deja de jugar al gato y al ratón con offsets en JS. - El resaltado de sección activo coincide con lo que ven los usuarios. “El encabezado más cercano a la parte superior” es más difícil de lo que parece en páginas largas con H2/H3 mezclados y bloques de código.
- Los enlaces profundos sobreviven a refactors. Los IDs de los encabezados deben ser estables. Si tu generador cambia IDs por puntuación o emoji, romperás enlaces en tickets, chat y memoria muscular.
Requisitos de rendimiento
- No bucles calientes en eventos de scroll. Tu TOC no debe convertir la página en una estufa. Usa
IntersectionObservery limita los pocos eventos restantes. - Funciona en páginas grandes. Las páginas de documentación pueden alcanzar miles de líneas. Manejar 200 encabezados no debería ser una crisis.
- No provocar thrashing de layout. Evita llamadas repetidas a
getBoundingClientRect()dentro de handlers de scroll.
Requisitos de UX y accesibilidad
- La navegación por teclado funciona (Tab al TOC, Enter para activar). No construyas un menú falso que atrape el foco.
- Estados de foco visibles. Si tu TOC resalta “activo” pero oculta el “foco”, los usuarios de teclado sufren.
- Comportamiento responsivo honesto. En móvil, un TOC lateral se convierte en un cajón superior o en un colapsable; no forces un diseño de dos columnas que aplaste el contenido.
Broma #1: un “scrollspy” es como un becario con binoculares—impresionante hasta que te das cuenta de que informa con cinco segundos de retraso.
Hechos y contexto: por qué los TOC se volvieron raros
Los TOC parecen simples porque los hemos tenido siempre. Pero la web siguió cambiando debajo de ellos: encabezados fijos, SPAs, contenido dinámico y primitivas de layout que no existían hace 15 años.
Aquí hay puntos de contexto concretos que explican los bordes afilados de hoy.
position: sticky se estandarizó tras años de comportamiento por vendor; “sticky en un contenedor con scroll” aún hace tropezar a los equipos cuando un ancestro tiene overflow establecido.IntersectionObserver llegó precisamente para evitar handlers de scroll que forzaban layouts síncronos y drenaban la batería en móviles.scroll-margin-top forma parte de la era de CSS Scroll Snap, pero es ampliamente útil incluso cuando no usas scroll snapping.General Gordon R. Sullivan
Nada de esto es exótico. El truco es elegir primitivas que sean estables ante cambios: CSS para offsets, APIs de observador para seguimiento activo y marcado limpio para accesibilidad.
Diseño: TOC lateral fijo que no lucha con la página
El TOC lateral funciona mejor cuando es una columna hermana en un grid o layout flex, no una superposición posicionada absolutamente.
Los TOC por encima del contenido son la razón por la que terminas con bugs de “por qué no puedo seleccionar texto” y problemas misteriosos de clics pasantes.
El layout mínimo viable
Usa un grid con columnas para contenido y TOC. Dale al TOC position: sticky y un top razonable que tenga en cuenta tu encabezado fijo.
Después haz que el TOC haga scroll internamente con max-height y overflow: auto para que no se desborde del viewport.
Modo de fallo: sticky no se fija. Casi siempre causado por un ancestro con overflow: hidden/auto o por faltar un contexto de altura.
Sticky es exigente, no está roto.
Reglas de contenedor que te mantienen fuera de problemas
- No envuelvas toda la página en un contenedor de scroll salvo que tengas una razón real. Deja que
document.scrollingElementsea el valor por defecto del navegador. - Si debes tener un contenedor con scroll, haz que el resaltado del TOC observe ese contenedor explícitamente (observer
root), o tu estado “activo” llegará tarde o fallará. - Mantén el ancho del TOC relativamente fijo, pero no uses píxeles rígidos en todas partes. Un ancho basado en clamp o una variable CSS hace que los ajustes futuros sean baratos.
Hazlo responsivo sin heroísmos
En pantallas más pequeñas, el “lado derecho” deja de existir. Tienes dos opciones sensatas:
- Mover el TOC arriba del artículo (simple, fiable, sin JS).
- Cajón colapsable (más complejo; ahora debes encargarte de la gestión del foco y estados ARIA).
Si eliges el cajón, trátalo como un componente con pruebas. Si no, regresará el día que alguien “solo ajuste el padding”.
Navegación con anclas: scroll-margin y manejo de offsets
Aquí está el fallo más común del TOC: clicas un enlace, el navegador desplaza, el encabezado fijo cubre el título.
La gente entonces sube un poco, lo que cambia la sección “activa”, y el resaltado del TOC salta. Eso no es un pequeño fallo; es un bucle.
Usa scroll-margin-top en los encabezados (no offsets en JS)
Pon esto en los propios encabezados:
h2, h3 { scroll-margin-top: calc(var(--headerHeight) + 16px); }
Funciona para navegación por hash, element.scrollIntoView() programático y saltos iniciados por el usuario.
También escala entre páginas sin tener que recordar “sumar 72px al offset” en seis funciones distintas.
Estabiliza los IDs de los encabezados
Tus enlaces del TOC son tan fiables como tus IDs. En docs en producción, la gente pega enlaces profundos en tickets.
Luego renombraste “Cache & Consistency” a “Cache consistency”, tu generador cambia el slug y de repente el equipo de soporte hace arqueología.
Haz esto:
- Prefiere IDs explícitos cuando sea posible (el autor los escribe, el generador los respeta).
- Usa una función de slug determinista (minúsculas, guiones, quitar puntuación) y cérrala como contrato de interfaz.
- Cuando debas cambiar IDs, publica redirecciones para hashes si tu plataforma lo permite (algunos routers de sitios estáticos pueden mapear hashes legacy).
Decide cómo quieres que se comporte el foco
La navegación por hash desplaza, pero puede que no mueva el foco del teclado al encabezado. Para accesibilidad, suele ser bueno enfocar el encabezado (o una ancla oculta),
pero debes evitar romper la posición de scroll llamando a focus() sin preventScroll.
Un enfoque pragmático:
- En el clic del TOC, deja que el navegador haga la navegación por hash.
- Luego llama a
heading.focus({ preventScroll: true })en un encabezado que sea enfocables (agregatabindex="-1").
Si omites esto, los usuarios de teclado y tecnologías de asistencia tendrán peor experiencia. Si lo implementas mal, obtendrás jitter de doble desplazamiento. Elige y aplica con cuidado.
Resaltado de sección activo: IntersectionObserver bien aplicado
El resaltado activo no es una característica de adorno. Es como los lectores mantienen la orientación en páginas largas.
Cuando está mal, la gente lo nota de inmediato. Puede que no presenten un bug, pero dejarán de confiar en tu documentación.
El modelo: “activo” significa el encabezado más cercano por encima de la línea umbral
El modelo ingenuo es “el encabezado actualmente visible”. Eso falla cuando dos encabezados son visibles, o cuando un encabezado es visible pero estás dentro de la sección.
Una definición mejor:
- Define una línea umbral superior (normalmente justo debajo del encabezado fijo).
- La sección activa es el último encabezado cuya parte superior está por encima de esa línea.
IntersectionObserver puede aproximar esto observando encabezados y rastreando cuáles han cruzado a una “banda superior”.
Ajustas esto con rootMargin para que el “viewport” del observador empiece por debajo del encabezado fijo.
Una estrategia de observador robusta
Usa un observador para encabezados (H2 y opcionalmente H3). Mantén un pequeño mapa de estados de visibilidad.
En el callback, calcula el mejor encabezado activo basado en:
- Orden de los encabezados en el documento
- Si el bounding box del encabezado está dentro de una banda superior
- Fallback al primer encabezado cuando estés arriba, o al último cuando estés cerca del final
No hagas: en cada scroll, recorrer todos los encabezados y llamar a getBoundingClientRect(). Ese patrón provoca thrash de layout e inconsistencias en frames en páginas largas.
Maneja “salto a hash” y “botón atrás”
Cuando la página carga con un hash, el navegador desplaza antes de que tu JS esté listo. El resaltado del TOC aún debe ser correcto.
Eso significa:
- Al iniciar, marca activo según
location.hashsi coincide con un ID de encabezado. - Después de que las fuentes/imagenes se estabilicen, reevalúa una vez (no para siempre). Un solo
requestAnimationFrameo unsetTimeouttras la carga suele ser suficiente.
Mantén la entrada activa del TOC visible dentro del TOC
Si el propio TOC hace scroll, debes asegurar que el elemento activo permanezca visible (auto-scroll sutil).
Hazlo con educación: desplaza el TOC solo cuando el elemento activo esté fuera de vista, y usa scrollIntoView({ block: "nearest" }).
Broma #2: si el resaltado del TOC va con retraso, felicidades—has inventado una página de estado para el scrolling.
Rendimiento y fiabilidad: evita fallos autoinfligidos
Un TOC de docs puede afectar resultados de negocio reales. No por gasto de CPU, sino por confianza.
Cuando la navegación falla, la gente asume que el contenido también es descuidado. Y cuando tus docs viven dentro de la UI del producto, un TOC malo puede degradar el rendimiento general de la app.
Donde los TOC van a morir
- Inyección dinámica de contenido: los encabezados aparecen después del render inicial (hidratación MDX, fetch del cliente). Tu TOC necesita re-escanear y re-observar.
- Desplazamientos de layout: imágenes sin dimensiones, fuentes web que cargan tarde, acordiones que se expanden. Tu lógica de “encabezado activo” debe tolerar movimiento.
- Contenedores de scroll anidados: el contenido principal hace scroll dentro de un div, no la ventana. Los observadores necesitan
rootconfigurado, yscroll-margin-toppuede no coincidir con el comportamiento visible del encabezado. - Demasiados observadores: crear un observador por encabezado es derrochador. Quieres una instancia observadora que vigile muchos targets.
Trampas de accesibilidad
Un TOC es básicamente un conjunto de enlaces de navegación en la página. Trátalo como un <nav aria-label="On this page">.
Manténlo simple y semántico. Sobre-ingenierizar es donde ARIA tiende a ser mal usada.
- Usa
aria-current="true"(oaria-current="location") en el enlace activo. - Mantén el texto del enlace corto e idéntico a los encabezados cuando sea posible (los lectores de pantalla no deberían adivinar).
- Asegura que los estilos de foco sean visibles frente a tu fondo.
Realidades de SPAs y plataformas de docs
Si tienes routing del lado cliente, necesitas reconstruir el TOC en cambios de ruta y volver a adjuntar observadores. Eso significa:
- Escuchar eventos de ruta (específico del framework) y reejecutar la configuración del TOC.
- Desconectar observadores en teardown para evitar fugas de memoria.
- No asumir que los encabezados existen inmediatamente después de la navegación; espera al render completado.
Desde la lente SRE: si una característica UI requiere una “secuencia de init” compleja, fallará durante despliegues parciales, A/B tests o experimentos de contenido.
Haz el TOC tolerante a encabezados ausentes y contenido retrasado.
Guía rápida de diagnóstico
Cuando el TOC está roto en producción, no tienes tiempo para debates filosóficos sobre APIs de scroll.
Necesitas un embudo rápido que encuentre el cuello de botella: layout CSS, offsets de ancla, lógica del observador o ciclo de vida de la plataforma.
Primero: ¿funciona el sticky?
- Abre la página, desplázate, confirma que el TOC se mantiene anclado bajo el encabezado.
- Si no: inspecciona ancestros por
overflowy transformaciones. Sticky falla silenciosamente cuando el contexto de layout es incorrecto.
Segundo: ¿los saltos de ancla aterrizan correctamente?
- Haz clic en un ítem medio de la página. Si el encabezado queda oculto bajo el encabezado fijo, te falta
scroll-margin-top(o lo aplicaste al elemento equivocado). - Si aterriza correctamente pero luego “salta” de nuevo, probablemente tengas JS que compite llamando a
scrollIntoView()o foco sinpreventScroll.
Tercero: ¿el resaltado activo es correcto y estable?
- Desplázate lentamente por la frontera entre secciones. Si el resaltado parpadea, tus thresholds o rootMargin del observador están mal.
- Si nunca se actualiza, el observador puede estar observando el root equivocado (ventana vs contenedor de scroll) o los encabezados no se observan tras la hidratación.
Cuarto: ¿rompe solo en algunas páginas?
- Compara páginas con distintos tipos de contenido: muchos bloques de código, imágenes, encabezados anidados o paneles colapsables.
- Busca colisiones de ID de encabezado (encabezados duplicados) e IDs inválidos (espacios, puntuación) si tu generador es descuidado.
Quinto: ¿solo falla tras navegaciones en una SPA?
- Si la carga inicial funciona pero rutas subsecuentes no, no estás re-inicializando o no desconectas observadores antiguos.
- Si funciona tras refresh completo pero no en navegación cliente, tu código corre antes de que los encabezados se rendericen.
Errores comunes (síntoma → causa → solución)
Síntoma: al hacer clic en un enlace del TOC se posiciona “demasiado arriba” o “demasiado abajo”
Causa: falta scroll-margin-top, o lo aplicaste al elemento equivocado (como un div wrapper en lugar del encabezado). A veces la altura del encabezado fijo cambia según breakpoints.
Solución: aplica scroll-margin-top directamente a h2/h3 (o al elemento anclado) usando una variable CSS para la altura del encabezado por breakpoint.
Síntoma: el resaltado del TOC es incorrecto cerca del final de la página
Causa: el último encabezado nunca “intersecta” la banda del observador, especialmente si rootMargin/threshold está afinado para comportamiento medio-página.
Solución: añade un elemento centinela al fondo, o especializa el caso “cerca del final” comprobando la posición de scroll vs scrollHeight y forzar el último encabezado activo.
Síntoma: el resaltado parpadea entre dos encabezados
Causa: thresholds demasiado sensibles; encabezados muy juntos; shifts de layout (imágenes) causan pequeños cambios. Otro culpable es mezclar H2 y H3 sin una regla consistente de “activo”.
Solución: usa una estrategia de threshold única (banda superior), reduce los targets observados (rastrear solo H2 para activo, marcar H3 como secundario) y debouncea actualizaciones a frames de animación.
Síntoma: el TOC fijo deja de fijarse en algunas páginas
Causa: un ancestro tiene overflow: hidden/auto, o transform establecido, creando un nuevo contenedor que cambia el comportamiento de sticky.
Solución: elimina el overflow/transform del ancestro, o mueve el elemento sticky fuera de ese contexto. Si debes mantenerlo, usa otra estrategia de layout (p. ej., fixed + padding) pero espera más trabajo.
Síntoma: entradas del TOC no coinciden con encabezados tras actualizaciones de contenido
Causa: TOC generado en build time, pero contenido inyectado en runtime; o los encabezados cambian vía renderizado cliente después de inicializar el TOC.
Solución: genera el TOC en tiempo de ejecución después del render, u observa mutaciones del DOM (con moderación) y re-escanea encabezados en cambios significativos.
Síntoma: al hacer clic en el TOC ocurre “doble scroll” con jitter
Causa: tanto la navegación por hash por defecto como un handler JS llaman a scrollIntoView(). O enfocas el encabezado sin preventScroll.
Solución: elige un método de navegación. Si usas hashes, deja que el navegador haga el scroll y solo añade foco con preventScroll.
Síntoma: la página se siente lenta sólo mientras se desplaza
Causa: handler de scroll haciendo lecturas/escrituras de layout repetidamente. O demasiadas actualizaciones DOM (toggle de clases) por tick de scroll.
Solución: pasa a IntersectionObserver, actualiza el DOM sólo cuando cambie el encabezado activo y agrupa cambios de clases.
Síntoma: encabezados duplicados rompen enlaces profundos
Causa: tu generador de slugs emite el mismo ID para encabezados idénticos, así los enlaces apuntan a la primera ocurrencia.
Solución: desambiguar IDs añadiendo un sufijo contador de forma determinista (p. ej., -2, -3).
Tres microhistorias corporativas desde el campo
Incidente: la suposición equivocada sobre “el contenedor de scroll”
Una empresa lanzó un portal de documentación embebido dentro de la UI del producto. Parecía moderno: navegación superior fija, rail izquierdo y un bonito TOC lateral derecho.
También tenía un contenedor de scroll personalizado porque el equipo de producto quería que todo el marco de la app se sintiera “nativo”.
El resaltado del TOC funcionaba perfectamente en staging. En producción, era un sinsentido: a veces el ítem activo nunca cambiaba; otras saltaba dos secciones de golpe.
Soporte lo clasificó como “esporádico” porque dependía de la altura del viewport, que es el tipo de bug que gusta de ser mal diagnosticado.
La suposición equivocada fue sutil: la implementación del TOC usaba IntersectionObserver con el root por defecto (el viewport).
Pero el scroll real ocurría dentro de div.app-scroll. Desde la perspectiva del navegador, los encabezados no se movían respecto al viewport como el equipo esperaba.
La solución fue aburrida e inmediata: establecer el root del observador al contenedor de scroll, y usar un rootMargin que tuviera en cuenta el encabezado fijo dentro del mismo contenedor.
Además quitaron un wrapper overflow extra que bloqueaba el posicionamiento sticky del propio TOC.
Conclusión postmortem: si construyes scroll personalizado, te haces responsable de todos los efectos secundarios. “Contenedor de scroll” no es un detalle; es el personaje principal.
Optimización que salió mal: “precomputemos todo en el scroll”
Otra organización tenía un sitio de docs con contenido técnico pesado—muchos bloques de código y referencias de API.
Notaron que el resaltado del TOC iba con lag en portátiles de gama baja, así que alguien “optimizó” cacheando la posición Y de cada encabezado en la carga de la página.
Ese cambio se probó bien en un conjunto pequeño de páginas. Luego se desplegó al corpus completo.
Una semana después llegaron los reportes: al hacer clic en un enlace del TOC, aterrizaba correctamente, pero el resaltado estaba desfasado por una sección. Peor en páginas con diagramas.
Esto pasó: imágenes y fuentes cargaron después del cache inicial. El layout se desplazó, los encabezados se movieron, pero las Y cached no.
La lógica siguió siendo rápida, sí. También estaba equivocada con convicción—probablemente lo peor.
Revirtieron el caching y pasaron a IntersectionObserver. Donde todavía necesitaban offsets (un botón especial “ir a la siguiente sección”), calcularon posiciones perezosamente, justo a tiempo, y nunca asumieron que el layout era estable hasta después de la carga.
Conclusión: el trabajo de rendimiento que ignora los desplazamientos de layout no es optimización; es viajar en el tiempo a un DOM pasado que ya no existe.
Práctica aburrida pero correcta que salvó el día: IDs estables y una capa de compatibilidad
Un equipo migró de un renderer Markdown a otro para mejorar el resaltado de sintaxis.
Nuevo renderer, nuevas reglas de slug. De repente, enlaces profundos antiguos de tickets y runbooks comenzaron a fallar.
No fue una caída catastrófica, pero afectó a quienes ya estaban estresados: ingenieros de guardia buscando procedimientos.
El equipo que evitó el dolor había hecho dos cosas aburridas antes:
primero, requerían IDs explícitos para encabezados top-level de runbooks; segundo, mantuvieron un pequeño mapa de compatibilidad para hashes legacy que redirigía a nuevos IDs.
Durante la migración, ejecutaron una comprobación automatizada sobre el corpus: extraer cada ID de encabezado antes y después, diferenciarlos y generar mapeos para las roturas más comunes.
Donde no podían mapear de forma fiable, rechazaron el cambio hasta que los autores proporcionaran IDs explícitos.
El resultado fue aburrido. No hubo enlaces rotos sorpresa. Soporte no lo notó. Guardias no lo notaron. Esa es la victoria.
Conclusión: los anclajes estables son datos operacionales. Trátalos con la misma seriedad que la compatibilidad de una API.
Tareas prácticas (comandos, salidas, decisiones)
A continuación hay tareas que puedes ejecutar hoy en un workstation Linux típico o en un runner CI para diagnosticar y prevenir regresiones del TOC.
Cada tarea incluye: un comando, qué significa la salida y la decisión que tomas a partir de ella.
Uso un directorio de salida estático llamado dist/ y un directorio fuente llamado docs/. Ajusta nombres; mantén la intención.
Tarea 1: Confirmar que los encabezados tienen IDs (higiene básica)
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<h[23][^>]*>' dist | head
dist/guide.html:118:<h2 id="requirements-that-actually-matter">Requirements that actually matter</h2>
dist/guide.html:176:<h2 id="facts-and-context">Facts and context: why TOCs got weird</h2>
dist/guide.html:265:<h3 id="the-minimum-viable-layout">The minimum viable layout</h3>
Qué significa: ves encabezados con id="...". Si faltan IDs, tus enlaces del TOC serán frágiles o imposibles.
Decisión: si faltan IDs, arregla tu pipeline de render/build para emitir IDs deterministas o exige IDs explícitos en autoría.
Tarea 2: Detectar encabezados sin IDs (lista de fallos)
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<h[23](?![^>]*\sid=)[^>]*>' dist | head
dist/faq.html:44:<h2>FAQ</h2>
dist/intro.html:90:<h3 class="note">A subtle caveat</h3>
Qué significa: esos encabezados carecen de IDs.
Decisión: o (a) genera IDs en build time, o (b) excluye esos encabezados del generador del TOC para evitar enlaces rotos.
Tarea 3: Encontrar IDs duplicados (corrupción silenciosa de enlaces)
cr0x@server:~$ python3 - <<'PY'
import glob, re
from collections import Counter
ids = []
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
ids += re.findall(r'\bid="([^"]+)"', s)
c = Counter(ids)
dups = [k for k,v in c.items() if v > 1]
print("duplicate ids:", len(dups))
print("\n".join(dups[:20]))
PY
duplicate ids: 2
faq
overview
Qué significa: al menos dos IDs se repiten en páginas (o dentro de una página). Dentro de la misma página es el verdadero problema para la navegación por hash.
Decisión: asegúrate de que los IDs sean únicos por página. Si los duplicados son solo cross-page, está bien. Si son intra-página, cambia tu slugger para añadir sufijos.
Tarea 4: Verificar que scroll-margin-top exista en el CSS construido
cr0x@server:~$ rg -n --glob 'dist/**/*.css' 'scroll-margin-top' dist | head
dist/assets/site.css:211:h2{scroll-margin-top:calc(var(--headerHeight) + 16px)}
dist/assets/site.css:212:h3{scroll-margin-top:calc(var(--headerHeight) + 16px)}
Qué significa: tu build output incluye la regla de offset.
Decisión: si falta, añádela en la hoja global de estilos para contenido de docs, no en una página aislada.
Tarea 5: Comprobar si sticky está siendo derrotado por overflow en ancestros
cr0x@server:~$ rg -n --glob 'dist/**/*.html' 'overflow:\s*(auto|hidden|scroll)' dist | head
dist/assets/site.css:88:.shell{overflow:hidden}
dist/assets/site.css:132:.content-wrap{overflow:auto}
Qué significa: tienes reglas de overflow que pueden crear contextos de contención para sticky.
Decisión: audita wrappers de layout. Si el TOC está dentro de un elemento con overflow distinto de visible, el comportamiento sticky puede cambiar. Mueve el elemento sticky o elimina el wrapper overflow.
Tarea 6: Confirmar que los enlaces del TOC coinciden con IDs reales
cr0x@server:~$ python3 - <<'PY'
import bs4, glob
from bs4 import BeautifulSoup
for fn in ["dist/guide.html"]:
s = open(fn, "r", encoding="utf-8").read()
soup = BeautifulSoup(s, "html.parser")
ids = {t.get("id") for t in soup.select("[id]")}
bad = []
for a in soup.select("aside#toc a[href^='#']"):
h = a.get("href")[1:]
if h and h not in ids:
bad.append(h)
print(fn, "bad toc hrefs:", bad[:20])
PY
dist/guide.html bad toc hrefs: []
Qué significa: los anchors del TOC resuelven a elementos en la página.
Decisión: si hay hrefs malos, tu generador del TOC está desincronizado con el renderer, o los encabezados se añaden/quitan después del build del TOC.
Tarea 7: Detectar “hash links” que no apuntan a nada en todo el sitio
cr0x@server:~$ python3 - <<'PY'
import glob, re
from collections import defaultdict
by_page_ids = {}
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
ids = set(re.findall(r'\bid="([^"]+)"', s))
by_page_ids[fn] = ids
broken = []
for fn in glob.glob("dist/**/*.html", recursive=True):
s = open(fn, "r", encoding="utf-8").read()
for href in re.findall(r'href="#([^"]+)"', s):
if href not in by_page_ids[fn]:
broken.append((fn, href))
print("broken in-page hashes:", len(broken))
for x in broken[:10]:
print(x[0], "#"+x[1])
PY
broken in-page hashes: 1
dist/faq.html #top
Qué significa: al menos un enlace hash en página no corresponde a un ID en esa página.
Decisión: añade el target ID faltante (p. ej., id="top") o elimina el enlace.
Tarea 8: Medir cuántos encabezados estás observando (chequeo de escala)
cr0x@server:~$ python3 - <<'PY'
import glob, re
fn = "dist/guide.html"
s = open(fn, "r", encoding="utf-8").read()
h2 = len(re.findall(r'<h2\b', s))
h3 = len(re.findall(r'<h3\b', s))
print("h2:", h2, "h3:", h3, "total:", h2+h3)
PY
h2: 12 h3: 27 total: 39
Qué significa: esta página tiene 39 encabezados. Observar 39 elementos está bien. Observar 400 podría seguir siendo viable, pero requiere deliberación.
Decisión: si el recuento de encabezados es enorme, considera observar solo H2 para sección activa, y tratar H3 como navegación únicamente (sin seguimiento activo), o implementar una selección más inteligente.
Tarea 9: Confirmar que no estás enviando un handler de scroll que corre cada tick
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'addEventListener\(\s*["'\'']scroll["'\'']' dist | head
dist/assets/toc.js:14:window.addEventListener('scroll', onScroll)
Qué significa: estás adjuntando un listener de scroll.
Decisión: inspecciona qué hace. Si lee layout y actualiza el DOM en cada evento de scroll, reemplázalo por IntersectionObserver o throttlea a animation frames y actualiza solo cuando cambie el activo.
Tarea 10: Confirmar que se usa IntersectionObserver (y no uno por encabezado)
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'new\s+IntersectionObserver' dist
dist/assets/toc.js:38:const io = new IntersectionObserver(onIntersect, { root: null, rootMargin: '-72px 0px -70% 0px', threshold: [0, 1] })
Qué significa: el build incluye una instancia de IntersectionObserver.
Decisión: confirma que es un único observador reutilizado para muchos encabezados. Si lo ves creado dentro de un bucle, arréglalo.
Tarea 11: Ejecutar un test de rendimiento al estilo Lighthouse/PSI localmente (rápido)
cr0x@server:~$ node -e "console.log('Run a local perf audit via your CI tool or headless Chrome; verify no long tasks during scroll.');"
Run a local perf audit via your CI tool or headless Chrome; verify no long tasks during scroll.
Qué significa: sí, este es un comando placeholder—pero la decisión es real: mide el rendimiento del scroll con herramientas, no con sensaciones.
Decisión: si el scroll produce long tasks, inspecciona primero el código del TOC. Es un culpable común porque corre durante el scroll por definición.
Tarea 12: Verificar que los encabezados estén en un único landmark main para lectores de pantalla
cr0x@server:~$ rg -n --glob 'dist/**/*.html' '<main\b' dist | head
dist/guide.html:52:<main>
dist/faq.html:18:<main>
Qué significa: la página usa un landmark <main>, lo que mejora la navegación para tecnología asistida.
Decisión: si falta, añádelo. Luego asegura que tu TOC sea un landmark <nav aria-label="On this page">, no un div cualquiera.
Tarea 13: Confirmar que el ítem TOC activo pone aria-current
cr0x@server:~$ rg -n --glob 'dist/**/*.js' 'aria-current' dist
dist/assets/toc.js:97:link.setAttribute('aria-current', isActive ? 'location' : 'false')
Qué significa: tu JS alterna aria-current.
Decisión: si no está presente, añádelo. Si pone valores inválidos, arréglalo a location o elimina el atributo cuando esté inactivo.
Tarea 14: Detectar cambios de texto en encabezados que rompen IDs estables
cr0x@server:~$ git diff --word-diff -- docs/guide.md | head -n 40
diff --git a/docs/guide.md b/docs/guide.md
--- a/docs/guide.md
+++ b/docs/guide.md
@@
-## Active section highlighting: IntersectionObserver done right
+## Active section highlighting with IntersectionObserver
Qué significa: un encabezado cambió. Si tus IDs derivan del texto del encabezado, probablemente cambió también el ID.
Decisión: o mantén IDs explícitos estables o acepta un cambio rompedor y gestionalo (redirecciones/mapeos). No lo dejes cambiar silenciosamente.
Tarea 15: Confirmar que cambios de ruta en SPA reinicializan el TOC (smoke test con logs)
cr0x@server:~$ rg -n --glob 'src/**/*.ts' 'initToc\(|setupToc\(' src | head
src/toc/init.ts:12:export function initToc(){ ... }
src/router.ts:48:router.on('routeChangeComplete', () => initToc())
Qué significa: init del TOC se llama tras el cambio de ruta.
Decisión: si falta, añádelo. Si está pero falla, asegura que desconecte observadores previos y maneje renders retardados.
Listas de verificación / plan paso a paso
Paso a paso: enviar un TOC que se comporte
- Define tu política de encabezados. Elige qué niveles aparecen (solo H2, o H2+H3). Decide IDs estables.
- Implementa offset de scroll en CSS. Añade
scroll-margin-topen encabezados usando una variable de altura de encabezado. - Construye marcado semántico. El TOC es un
<nav aria-label="On this page">con una lista de enlaces. - Haz el TOC fijo. Usa layout grid; aplica
position: sticky, offset top, max-height y scroll interno. - Resaltado activo con IntersectionObserver. Una instancia observadora, muchos targets de encabezado, rootMargin afinado.
- Configura aria-current. Usa
aria-current="location"en el enlace activo; remuévelo cuando esté inactivo. - Maneja hash-on-load. Al iniciar, si
location.hashcoincide con un encabezado, márcalo activo inmediatamente. - Maneja navegación SPA. Re-escanea encabezados en cambio de ruta y desconecta observadores antiguos.
- Prueba casos de layout shift. Páginas con imágenes, bloques de código y colapsables. Confirma que no hay parpadeos.
- Guardrails en CI. Comprueba IDs faltantes, IDs duplicados y enlaces hash en página rotos.
Lista de verificación: endurecimiento en producción
- El TOC funciona cuando JavaScript falla (los enlaces siguen desplazando vía hash).
- La altura del encabezado fijo es consistente (o la variable CSS cambia por breakpoint).
- Los IDs son estables y deterministas; los duplicados se desambiguán.
- El resaltado no parpadea con scroll lento.
- El TOC no causa long tasks durante el scroll.
- Navegación por teclado y estados de foco son visibles.
Lista de verificación: qué no hacer
- No adjuntes un listener de scroll que recalcule todas las posiciones de encabezado cada tick.
- No almacenes offsets cacheados a menos que también rastrees layout shifts (que probablemente no harás bien).
- No construyas un “TOC cajón” en móvil a menos que estés listo para encargarte de ARIA, trapping de foco y comportamiento de escape.
- No dejes que los IDs de encabezado cambien a la ligera. Eso es un cambio rompedor; trátalo como tal.
Preguntas frecuentes
1) ¿Debo generar el TOC en build time o runtime?
Build time es más simple y rápido, pero solo si tus encabezados renderizados son estables y no se inyectan tras la carga.
Si tienes hidratación MDX o contenido cliente, haz generación en runtime (o build-time más una reconciliación en runtime).
2) ¿Se puede confiar lo suficiente en scroll-margin-top?
Sí para navegadores modernos. Si soportas navegadores muy antiguos, necesitarás fallback, pero la mayoría de portales de docs pueden exigir motores modernos.
El riesgo mayor no es la compatibilidad; es olvidar aplicarlo al elemento anclado real.
3) ¿Por qué no usar simplemente un handler de scroll?
Puedes, pero reinventarás mal la mitad de IntersectionObserver: throttle, lecturas de layout y casos límite alrededor del final de la página.
Los handlers de scroll también tienden a degradar porque “funciona” hasta que añades un tipo de contenido más.
4) Mi resaltado del TOC está desfasado por una sección. ¿Cuál es el culpable usual?
Root margin que no tiene en cuenta la altura del encabezado fijo, o tu definición de “activo” es “cualquier encabezado visible” en lugar de “último encabezado por encima de una línea umbral”.
Ajusta rootMargin y define una regla determinista.
5) ¿Debería resaltar ítems H3 también?
Solo si ayuda a los lectores. En páginas muy densas, resaltar H3 puede parpadear porque los H3 están muy juntos.
Un compromiso común: activo es el H2 actual; dentro de ese H2, resaltar el H3 más cercano solo si está cómodamente espaciado.
6) ¿Cómo evito romper enlaces profundos cuando los encabezados cambian?
Usa IDs explícitos para secciones importantes (runbooks, APIs, troubleshooting). Si no puedes, mantén una capa de compatibilidad que mapee hashes antiguos a nuevos IDs.
Si no, acepta que estás lanzando un cambio rompedor y comunícalo.
7) ¿Qué pasa con páginas con secciones colapsables o pestañas?
Los colapsables complican el “activo” porque cambia la visibilidad del contenido. El camino seguro: incluye solo encabezados que siempre estén presentes en el flujo.
Si incluyes encabezados dentro de colapsables, tu TOC debe entender el estado abierto/cerrado y actualizar observadores acorde.
8) ¿Por qué sticky falla solo en algunas páginas?
Porque algunas páginas tienen un wrapper que ajusta overflow o transform y cambia el contenedor de sticky.
Sticky no es global; es contextual. Audita estilos de ancestros, no solo el elemento sticky.
9) ¿Necesito auto-escrollear la barra lateral del TOC para mantener visible el ítem activo?
Es un plus si tu TOC es largo. Hazlo solo cuando sea necesario (block: "nearest"), y no luches contra el usuario si está desplazando manualmente el TOC.
10) ¿Cómo pruebo esto de forma fiable?
Usa páginas deterministas: una con muchos encabezados, otra con imágenes grandes, otra con bloques de código, otra con colapsables.
Añade comprobaciones automatizadas para enlaces hash rotos y IDs duplicados. Para el resaltado activo, haz tests de navegador que hagan scroll a offsets conocidos y aserten aria-current.
Conclusión: próximos pasos que puedes desplegar
Un TOC lateral derecho es una de esas características que parece cosmética hasta que se rompe y todos se convierten en detectives de UI reacios.
La solución no es más JavaScript. Es elegir las primitivas correctas y aplicar reglas aburridas.
- Añade
scroll-margin-topa los encabezados usando una variable de altura de encabezado que coincida con tu encabezado fijo. - Asegura IDs de encabezado estables y únicos (explícitos donde importa; slug determinista en el resto).
- Haz el TOC sticky vía layout grid, y evita wrappers overflow que saboteen sticky.
- Usa un único IntersectionObserver para conducir el resaltado activo, con rootMargin afinado debajo del encabezado.
- Implementa guardrails en CI para IDs faltantes, IDs duplicados y enlaces hash rotos en página.
Si haces solo dos cosas: usa scroll-margin-top y deja de ejecutar lógica pesada en un handler de scroll.
Eso por sí solo elimina la mayoría de los bugs de TOC que he visto en producción.