Cómo crear un índice sticky con seguimiento al hacer scroll en React
Un índice interactivo sticky en React usa una nav lateral que permanece fija mientras el contenido se desplaza. Cada sección se registra mediante el callback onViewportEnter de Framer Motion, que actualiza el ID activo en el state local y cambia el resaltado del borde izquierdo del elemento correspondiente del índice.
- Stack: React 19 + Framer Motion 11 + Lucide React, ~120 líneas, cero dependencias adicionales.
- La detección al hacer scroll usa onViewportEnter de Framer Motion con un margen de -80px, sin IntersectionObserver manual.
- Accesible: la nav lleva aria-label="Table des matieres" y los elementos del índice son verdaderos elementos <button>.
- Advertencia responsive: la sidebar sticky desaparece en pantallas pequeñas; prevé un drawer móvil o una variante de índice en la parte superior para táctil.
- Tematización mediante custom properties CSS (--color-accent, --color-border), sin colores codificados a mano.
Blog Interactive TOC es un layout de artículo a dos columnas con una sidebar izquierda sticky que sigue la posición del lector dentro del contenido. A medida que cada sección entra en el viewport, su entrada en el índice se resalta con un borde izquierdo de acento, dando al lector una referencia visual inmediata dentro de un texto largo, el tipo de detalle de navegación que distingue una documentación cuidada de una simple página con scroll.
Anatomía
El componente tiene tres zonas visuales. En la parte superior, un header de artículo a todo el ancho muestra la etiqueta de categoría, el título, el autor, la fecha y el tiempo de lectura con iconos de Lucide. Debajo, una grilla CSS divide la vista en una columna nav sticky de 220px y una columna de contenido fluida. La nav lista los títulos de sección como botones con un borde izquierdo común; el elemento activo recibe un resaltado de borde izquierdo coloreado. Cada sección de contenido es una motion.div con su propio fade-in escalonado y un hook onViewportEnter.
Cómo funciona
El mecanismo de scroll-spy evita el boilerplate de IntersectionObserver delegando en el tracking de viewport de Framer Motion. Cada sección de contenido se envuelve en una motion.div con onViewportEnter={() => setActiveId(s.id)} y un margen de viewport de -80px. Cuando más de 80px de una sección entran en el área visible, setActiveId se dispara y la actualización del state vuelve a renderizar el índice con el nuevo resaltado activo. Hacer clic en un botón del índice también llama a setActiveId directamente, manteniendo la navegación por teclado en sintonía con la posición del scroll.
Cómo crearlo en React
Definir la estructura de datos e inicializar el state
Crea una interface Section con id, title y content. Acepta un array sections como prop e inicializa activeId con useState, tomando por defecto el ID de la primera sección. Este único fragmento de state controla tanto el resaltado del índice como la lógica de scroll-spy.
const [activeId, setActiveId] = useState(sections[0]?.id ?? "");Construir la nav de la sidebar sticky
Renderiza una motion.nav con position:sticky y top:2rem. Recorre las secciones para producir un botón por cada entrada. Controla el color del borde izquierdo y el font-weight desde activeId, los items activos reciben --color-accent y el peso 600, los inactivos --color-foreground-muted y el peso 400. Aplica una transición CSS sobre color y border-color para un cambio fluido.
borderLeftColor: activeId === s.id ? "var(--color-accent)" : "transparent", fontWeight: activeId === s.id ? 600 : 400,Adjuntar onViewportEnter a las secciones de contenido
Envuelve cada sección del artículo en una motion.div y pasa onViewportEnter={() => setActiveId(s.id)}. Ajusta el margen del viewport a -80px para que el resaltado se dispare ligeramente antes de que la parte superior de la sección alcance el borde superior de la pantalla. Escalona el fade-in inicial con delay: i * 0.05 para un efecto en cascada en la primera carga.
<motion.div onViewportEnter={() => setActiveId(s.id)} viewport={{ once: true, margin: "-80px" }} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: i * 0.05, ease: EASE }} >Conectar la navegación al clic y gestionar el layout responsive
El onClick de cada botón del índice llama a setActiveId directamente para que los usuarios de teclado y ratón permanezcan sincronizados sin eventos de scroll. En móvil, repliega la sidebar en un acordeón en la parte superior de la página o un drawer, la columna sticky de 220px es demasiado estrecha para pantallas pequeñas y debe ocultarse por debajo de un breakpoint con una media query CSS o un prefijo responsive de Tailwind.
Cuándo usarlo
Usa este layout para contenidos largos donde los lectores necesitan orientación: documentación técnica, tutoriales en profundidad, casos de estudio o artículos editoriales con cuatro o más secciones con nombre. Evítalo para entradas cortas (menos de tres secciones) donde la sidebar añade ruido visual sin valor de navegación, y para landing pages de marketing donde una sidebar CTA sticky supera a un índice.
Usado por
- Stripe Docs, Índice sticky en el lado derecho con resaltado de la sección activa en todas las páginas de referencia de API y de guías.
- MDN Web Docs, Índice persistente en la sidebar que sigue la posición del scroll y marca el encabezado actual en artículos técnicos largos.
- Vercel Docs, Layout a dos columnas con un índice flotante que resalta las secciones al hacer scroll en todas sus guías de despliegue.
FAQ
¿Por qué usar onViewportEnter de Framer Motion en lugar de IntersectionObserver?
onViewportEnter ya está disponible en cualquier motion.div que añadas para animaciones, por lo que no hay configuración de observer adicional, ni cableado de ref, ni limpieza necesaria. Si Framer Motion ya está en tu proyecto, esto son cuatro caracteres de superficie de API añadida.
¿Cómo gestiono el desplazamiento real con anclas en la página en lugar de solo el resaltado visual?
Reemplaza el onClick del botón por document.getElementById(s.id)?.scrollIntoView({ behavior: 'smooth' }) y mantén setActiveId únicamente en onViewportEnter. Así el índice controla el desplazamiento real y el resaltado permanece sincronizado mediante el callback de viewport.
¿Funciona la sidebar sticky con los layouts del App Router de Next.js?
Sí, pero asegúrate de que el contenedor de scroll sea la ventana del documento y no una div anidada con overflow:auto. App Router envuelve las páginas en un layout raíz, si ese layout tiene overflow:hidden u overflow:auto, position:sticky pierde su referencia y deja de funcionar.
¿Cómo hago el índice accesible para los usuarios de teclado?
La implementación actual ya usa elementos button semánticos, por lo que son enfocables y activables mediante Enter o Espacio sin trabajo adicional. Añade aria-current="true" al item activo para que los lectores de pantalla anuncien qué sección está seleccionada cuando el foco se mueve por la lista.