wandres.dev
SCROLL · Snap, overscroll y barras

scroll-margin y scroll-padding con cabecera fija

Por qué un enlace a un fragmento deja el título debajo de la barra, cuál de las dos propiedades hay que usar en cada caso, y cómo atarlas a la altura real.

⏱ 16 min

Pulsas un enlace del índice y la página se desplaza al título correcto, que queda tapado por la cabecera pegajosa. Es el bug más universal de las páginas con barra fija, y durante años se resolvió con hacks: un pseudo-elemento invisible antes del ancla, un padding-top con un margin-top negativo del mismo tamaño. Hoy hay dos propiedades hechas exactamente para esto, y elegir la correcta entre las dos ahorra bastante mantenimiento.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el desplazamiento a un fragmento deja el destino bajo la cabecera.
  • Distinguir scroll-margin de scroll-padding y saber dónde va cada una.
  • Atar el valor a la altura real de la cabecera.
  • Coordinarlas con scroll-snap y con el foco del teclado.

Por qué ocurre

Cuando el navegador desplaza la vista hasta un elemento —por un enlace a un fragmento, por scrollIntoView(), o por mover el foco con el teclado— alinea el borde del elemento con el borde del scrollport. El scrollport es el rectángulo visible del contenedor de scroll, y su borde superior está en la parte de arriba de la ventana, debajo de nada.

Una cabecera pegajosa se pinta encima de ese rectángulo, pero no lo reduce. Para el algoritmo de desplazamiento, el borde superior del scrollport sigue estando donde siempre. El resultado es que el destino queda correctamente alineado con el borde y, encima de él, la cabecera.

Las dos propiedades resuelven esto desplazando el punto de alineación, y se diferencian en dónde se declaran.

scroll-padding se declara en el contenedor de scroll. Reduce el scrollport a efectos de alineación: le dice al navegador que la zona útil empieza más abajo.

scroll-margin se declara en el elemento destino. Le añade un margen a efectos de alineación: el navegador alinea el borde de ese margen en lugar del borde del elemento.

Cuál usar

La respuesta corta es scroll-padding en el contenedor, casi siempre.

html {
  scroll-padding-block-start: 4.5rem;   /* la altura de la cabecera */
}

Una sola declaración protege todos los destinos de la página: todos los títulos, todos los elementos enfocados con teclado, todos los scrollIntoView(). Es una propiedad del contenedor porque el problema es del contenedor: hay una franja de su scrollport que está tapada, y eso no depende de qué elemento sea el destino.

scroll-margin es la opción correcta cuando el ajuste es específico de un elemento:

  • Un elemento que debe quedar con más aire que el resto, como el primer título de una sección.
  • Un elemento cuyo borde visual no coincide con su caja, por ejemplo una tarjeta con una sombra que quieres que se vea entera.
  • Los hijos de un contenedor con scroll-snap, donde el margen participa en el punto de ajuste.
h2 { scroll-margin-block-start: 1rem; }     /* aire extra, sumado al padding del contenedor */

Las dos se suman: si el contenedor tiene scroll-padding-block-start: 4.5rem y el destino tiene scroll-margin-block-start: 1rem, el elemento acaba a cinco remes y medio del borde.

Las dos tienen la familia completa de variantes: físicas —scroll-padding-top, scroll-margin-left— y lógicas —scroll-padding-block-start, scroll-margin-inline-end— más los atajos por eje y el atajo de cuatro valores. Usa las lógicas.

flowchart TB
A[El destino queda tapado al navegar a un fragmento] --> B{El ajuste vale para todos los destinos}
B -->|Si, hay una cabecera fija| C[scroll-padding en el contenedor de scroll]
C --> D[Una declaracion protege toda la pagina]
B -->|No, es un elemento concreto| E[scroll-margin en el destino]
E --> F[Se suma al padding del contenedor si lo hay]
A --> G{La altura de la cabecera es variable}
G -->|Si| H[Publica la altura medida en una custom property]
style A fill:#f38ba8,color:#11111b
style C fill:#a6e3a1,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#a6e3a1,color:#11111b
style H fill:#cba6f7,color:#11111b

Atarlo a la altura real

El valor fijo funciona mientras la cabecera mida lo que dijiste. Deja de funcionar cuando el usuario aumenta el tamaño de fuente, cuando el texto se envuelve en móvil, o cuando alguien añade una segunda barra.

La forma robusta es una custom property publicada por un ResizeObserver, la misma técnica que en las cabeceras encadenadas:

:root { --alto-cabecera: 4.5rem; }
html  { scroll-padding-block-start: calc(var(--alto-cabecera) + 1rem); }
const cabecera = document.querySelector('.cabecera');
new ResizeObserver(([e]) => {
  document.documentElement.style.setProperty(
    '--alto-cabecera', e.contentRect.height + 'px'
  );
}).observe(cabecera);

El valor de la custom property en el CSS es el respaldo para antes de que el observador se ejecute, y el + 1rem es el aire que separa el título de la barra.

Si la cabecera solo existe a partir de cierto ancho, condiciona también el scroll-padding:

html { scroll-padding-block-start: 1rem; }

@media (width >= 48rem) {
  html { scroll-padding-block-start: calc(var(--alto-cabecera) + 1rem); }
}

Los dos usos que no son cabeceras

Con scroll-snap. scroll-padding en el contenedor y scroll-margin en los hijos participan en el cálculo de los puntos de ajuste. En un carrusel, scroll-padding-inline: 1rem hace que la tarjeta ajustada quede a un remio del borde en lugar de pegada. Es la forma correcta de dar aire a un carrusel, mejor que un padding normal, que interfiere con la anchura de los hijos.

.carrusel {
  scroll-snap-type: inline mandatory;
  scroll-padding-inline: 1rem;
}

Con el foco del teclado. Cuando el usuario tabula hasta un elemento que está fuera de la vista, el navegador lo desplaza usando las mismas reglas. Un scroll-padding correcto significa que ningún elemento enfocado queda nunca tapado por la cabecera, lo cual es un requisito real de accesibilidad y no un detalle: un control que recibe el foco pero no es visible deja al usuario de teclado sin saber dónde está.

Esa es, de hecho, la razón más fuerte para preferir scroll-padding en el contenedor frente a poner scroll-margin en unos cuantos títulos. Los títulos son los destinos que tú previste; el foco del teclado puede aterrizar en cualquier control de la página, y solo la declaración del contenedor los cubre a todos.

El scrollport óptico y el scrollport real son dos rectángulos distintos, y esa es la raíz del problema

Merece la pena nombrar bien lo que está pasando, porque el mismo desajuste reaparece en varios sitios y reconocerlo ahorra tiempo. El navegador tiene un único concepto de scrollport: el rectángulo visible del contenedor de scroll. Pero una interfaz con superposiciones tiene en realidad dos rectángulos que no coinciden: el que el navegador conoce, y el rectángulo ópticamente libre, el que queda tras descontar la cabecera fija, la barra de acciones inferior, el panel lateral pegajoso. Ninguna de esas superposiciones reduce el scrollport real, porque están pintadas encima y no participan en el layout del contenedor. scroll-padding es, exactamente, la forma de declararle al navegador cuál es tu scrollport óptico. Vista así, la propiedad deja de ser un parche para enlaces y pasa a ser una pieza de información que el motor necesita y no puede deducir. Y de ahí sale una regla que conviene adoptar como norma de proyecto: cada vez que añadas una superposición fija que tape parte de la ventana, actualiza el scroll-padding del documento en el mismo cambio. Si no lo haces, no rompes solo los enlaces del índice: rompes también el desplazamiento al enfocar con teclado, el scrollIntoView() de cualquier componente, los puntos de ajuste si hay scroll-snap, y el desplazamiento a un resultado de la búsqueda en página. Los cinco tienen la misma causa y la misma línea de corrección.

⚔️ Alinea bien los destinos
  1. Monta una página con cabecera pegajosa y un índice, y comprueba que los títulos quedan tapados.
  2. Corrígelo con scroll-padding-block-start en html y verifica que vale para todos los enlaces a la vez.
  3. Tabula hasta un control que esté fuera de la vista y comprueba que tampoco queda tapado.
  4. Añade el ResizeObserver y aumenta el tamaño de fuente del navegador para confirmar que el ajuste sigue siendo correcto.
  5. Aplica scroll-padding-inline a un carrusel con ajuste y compara el resultado con un padding normal.