El índice lateral y cómo saber que algo está pegado
El patrón de sumario que acompaña al texto, el resaltado de la sección activa, y las dos formas de detectar el estado pegado: el centinela portable y la consulta de scroll.
Un índice lateral que acompaña al lector es el segundo uso más frecuente del posicionamiento pegajoso y tiene dos partes: la geometría, que es CSS puro y sale de las tres condiciones, y el resaltado de la sección activa, que necesita observar el scroll. Y hay una pregunta relacionada que aparece constantemente: cómo saber, desde CSS o desde JavaScript, que un elemento ha llegado a su estado pegado, para cambiarle el estilo.
- Montar un índice lateral pegajoso con desplazamiento propio.
- Resaltar la sección activa con un observador de intersección.
- Detectar el estado pegado con la técnica del centinela.
- Conocer el estado real de la alternativa declarativa en 2026.
La geometría del índice
El índice va en una columna lateral y debe quedarse a la vista mientras el artículo se desplaza. La estructura mínima:
<div class="disposicion">
<article class="texto">...</article>
<nav class="indice">
<ol>
<li><a href="#instalacion">Instalación</a></li>
<li><a href="#configuracion">Configuración</a></li>
</ol>
</nav>
</div>
.disposicion {
display: grid;
grid-template-columns: minmax(0, 1fr) 16rem;
gap: 3rem;
align-items: start; /* imprescindible: sin esto el índice se estira */
}
.indice {
position: sticky;
inset-block-start: 2rem;
max-block-size: calc(100dvh - 4rem);
overflow-y: auto; /* si el índice es más largo que la ventana */
scrollbar-gutter: stable;
}
Cuatro decisiones que conviene justificar.
align-items: start en la rejilla. Sin él, el valor inicial stretch estira el índice hasta la altura de la fila, y con eso desaparece el recorrido que la tercera condición exige. Es el fallo número uno de este patrón.
max-block-size con 100dvh. Si el índice es más alto que la ventana, la parte de abajo queda inalcanzable: el elemento está pegado, así que nunca se desplaza para mostrar el resto. Limitando su altura y dándole scroll propio, el índice se vuelve navegable. dvh es la unidad correcta en móvil porque tiene en cuenta las barras del navegador que aparecen y desaparecen.
overflow-y: auto en el propio índice. Ojo con la ironía: aquí sí queremos un contenedor de scroll, y como está en el propio elemento sticky y no en un ancestro, no interfiere con su pegado.
scrollbar-gutter: stable reserva el espacio de la barra para que el contenido no se desplace cuando aparezca.
Resaltar la sección activa
La parte que necesita JavaScript. IntersectionObserver es la herramienta adecuada porque no dispara en cada fotograma de scroll: el navegador le avisa solo cuando cambia el estado de intersección.
const enlaces = new Map();
document.querySelectorAll('.indice a[href^="#"]').forEach((a) => {
const destino = document.querySelector(a.getAttribute('href'));
if (destino) enlaces.set(destino, a);
});
const observador = new IntersectionObserver((entradas) => {
for (const entrada of entradas) {
const a = enlaces.get(entrada.target);
if (a) a.setAttribute('aria-current', entrada.isIntersecting ? 'true' : 'false');
}
}, {
// la banda activa es el tercio superior del viewport
rootMargin: '0px 0px -66% 0px',
threshold: 0,
});
enlaces.forEach((_, destino) => observador.observe(destino));
.indice a[aria-current="true"] { font-weight: 600; color: AccentColor; }
El rootMargin negativo por abajo encoge el rectángulo de observación al tercio superior de la ventana, de modo que la sección “activa” es la que está en la parte de arriba, que es donde el lector está mirando. Sin ese ajuste, varias secciones estarían intersecando a la vez y el resaltado saltaría.
Usar aria-current en lugar de una clase no es un detalle cosmético: comunica el estado a las tecnologías de asistencia y sirve de gancho de estilo a la vez.
Detectar el estado pegado: el centinela
CSS no tiene, de forma universal, un selector para “este elemento está pegado”. La técnica portable es el centinela: un elemento vacío de un píxel colocado justo antes del sticky, en su posición de reposo. Cuando el centinela sale del viewport por arriba, el sticky ha alcanzado su umbral.
<div class="centinela" aria-hidden="true"></div>
<header class="cabecera">...</header>
.centinela { block-size: 1px; }
.cabecera { position: sticky; inset-block-start: 0; transition: box-shadow .2s; }
.cabecera[data-pegada] { box-shadow: 0 2px 12px color-mix(in oklch, CanvasText 20%, transparent); }
const centinela = document.querySelector('.centinela');
const cabecera = document.querySelector('.cabecera');
new IntersectionObserver(([e]) => {
cabecera.toggleAttribute('data-pegada', !e.isIntersecting);
}, { threshold: 0 }).observe(centinela);
Funciona en todos los navegadores, no escucha el evento de scroll, y no mide nada. Los dos detalles que hay que cuidar: el centinela tiene que estar fuera del elemento sticky, y si la cabecera se pega a un umbral distinto de cero, el centinela necesita un rootMargin que lo compense.
flowchart TB
A[Quieres cambiar el estilo cuando algo se pega] --> B{Puedes exigir solo Chromium}
B -->|Si| C[container-type scroll-state en el propio pegajoso]
C --> D[Consulta con container scroll-state stuck que estila descendientes]
D --> E[Sin JavaScript pero sin Firefox ni Safari]
B -->|No, necesitas los tres motores| F[Centinela de un pixel antes del sticky]
F --> G[IntersectionObserver que alterna un atributo]
G --> H[Funciona en todas partes y no escucha scroll]
style A fill:#89b4fa,color:#11111b
style E fill:#f9e2af,color:#11111b
style H fill:#a6e3a1,color:#11111bLa alternativa declarativa, y su estado real
Existe una forma declarativa de hacer esto: las consultas de estado de scroll, que añaden un valor scroll-state a container-type y permiten preguntar si el contenedor está pegado. Hay un detalle de uso que conviene tener claro: el contenedor de la consulta tiene que ser el propio elemento pegajoso, y como toda container query estila descendientes y no el contenedor, lo que cambias es el estilo de algo que hay dentro.
.cabecera {
position: sticky;
inset-block-start: 0;
container-type: scroll-state;
}
@container scroll-state(stuck: top) {
.cabecera-interior { box-shadow: 0 2px 12px color-mix(in oklch, CanvasText 20%, transparent); }
}
Pero el soporte, a agosto de 2026, es solo Chromium: llegó en Chrome 133 y ni Firefox ni Safari lo han implementado. Sigue marcado como experimental.
Eso lo deja en la categoría de mejora progresiva: úsalo con @supports para añadir un detalle en los navegadores que lo tienen, nunca como mecanismo del que dependa la legibilidad.
@supports (container-type: scroll-state) {
.cabecera { container-type: scroll-state; }
@container scroll-state(stuck: top) {
.cabecera-interior { border-block-end-color: AccentColor; }
}
}
Si el cambio de estilo es funcionalmente relevante —una sombra que separa la barra del contenido, un fondo que la hace legible— usa el centinela, que funciona en los tres motores hoy.
Merece la pena entender por qué la técnica del centinela es tan superior a la alternativa obvia, que sería escuchar el evento de scroll y comparar getBoundingClientRect().top con el umbral. La comparación directa parece más honesta, pero tiene tres problemas que se acumulan. El primero es de coste: el evento de scroll se dispara muchas veces por segundo y en el hilo principal, y llamar a getBoundingClientRect() dentro de él fuerza un reflujo sincrónico en cada llamada, porque obliga al motor a resolver el layout pendiente antes de responder. El segundo es de sincronía: el scroll en los navegadores modernos ocurre a menudo en un hilo de composición separado, así que tu manejador se ejecuta después de que la pintura ya haya salido, y el cambio de estilo llega un fotograma tarde. El tercero es de corrección: el umbral que comparas tiene que replicar en JavaScript el valor que declaraste en CSS, y esos dos números se desincronizan en cuanto alguien toca la hoja de estilos. El centinela elimina los tres de golpe. No lee ninguna posición: convierte la pregunta dónde está en la pregunta cruzó o no cruzó, que es exactamente la pregunta que IntersectionObserver está diseñado para responder de forma asíncrona, fuera del hilo principal y sin forzar layout. Es un buen ejemplo del patrón general: cuando una medición continua te está costando cara, busca el evento discreto que de verdad te importaba.
- Construye el índice lateral con
align-items: starty comprueba qué pasa si lo quitas. - Haz que el índice sea más largo que la ventana y verifica que sin
max-block-sizeno puedes llegar al final. - Implementa el resaltado con
IntersectionObservery ajusta elrootMarginhasta que la sección activa coincida con lo que lees. - Añade el centinela a una cabecera pegajosa y comprueba que la sombra aparece exactamente al pegarse.
- Prueba la versión con
scroll-stateen Chromium y confirma con@supportsque degrada limpiamente en los otros motores.