Contenido inyectado y animaciones que desplazan
El patrón de reservar antes de inyectar aplicado a banners, anuncios y avisos de cookies, la ventana de 500 milisegundos tras la entrada del usuario, y qué animaciones producen desplazamiento y cuáles no.
Reservar espacio en las imágenes y ajustar las fuentes arregla el CLS de la carga. El CLS que hunde a los sitios reales llega después: el aviso de consentimiento a los tres segundos, el anuncio que se recarga con otra altura a los doce, la barra de notificación que aparece cuando el usuario ya está leyendo. Ninguna herramienta de laboratorio lo ve, y la mayoría se arregla no con una propiedad de CSS sino con una decisión sobre dónde y cuándo aparece el contenido.
- Aplicar el patrón de reservar antes de inyectar con cifras derivadas de datos.
- Usar la superposición fuera del flujo cuando la reserva no es aceptable.
- Explicar la ventana de exclusión de 500 milisegundos y usarla como criterio de diseño.
- Clasificar una animación como desplazante o no según la propiedad que anima.
Las tres formas de inyectar sin desplazar
Cuando un bloque de contenido aparece después de la carga, hay exactamente tres maneras de que no mueva nada, y la elección entre ellas es de producto, no técnica.
Reservar el hueco desde el principio. El elemento existe en el documento inicial con su altura final, vacío. Cuando llega el contenido, lo rellena y nada se mueve. Es lo correcto para todo lo que sabes que va a aparecer: bloques de anuncios, recomendaciones, contadores.
<div class="ranura" data-ranura="lateral-superior"></div>
.ranura {
min-block-size: 250px; /* la altura del formato que se sirve */
contain: layout;
}
@media (max-width: 700px) {
.ranura { min-block-size: 100px; } /* el formato movil es otro */
}
La cifra del min-block-size sale de los datos, no de la intuición. Un observador de redimensionado en producción durante una semana da la distribución real:
const alturas = [];
new ResizeObserver((entradas) => {
for (const e of entradas) alturas.push(Math.round(e.contentRect.height));
}).observe(document.querySelector('.ranura'));
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden' || !alturas.length) return;
const orden = [...alturas].sort((a, b) => a - b);
navigator.sendBeacon('/rum-ranuras', JSON.stringify({
p50: orden[Math.floor(orden.length * 0.5)],
p90: orden[Math.floor(orden.length * 0.9)],
max: orden.at(-1),
}));
});
Con el percentil 90 reservas de más en nueve de cada diez casos y evitas el desplazamiento en todos ellos. Reservar el máximo absoluto suele ser un error, por lo que ya vimos sobre el coste del hueco vacío.
Sacarlo del flujo. Un elemento con position: fixed o position: absolute no ocupa espacio en el flujo y por tanto no puede desplazar nada. Es la solución correcta para todo lo que no tiene una altura previsible y no puede tener un hueco reservado: avisos de cookies, mensajes emergentes, barras de estado.
.aviso-consentimiento {
position: fixed;
inset-block-end: 0;
inset-inline: 0;
z-index: 100;
}
El aviso de cookies inyectado en el flujo, arriba del todo, es probablemente la causa individual de CLS más extendida de la web occidental. Empuja el contenido entero hacia abajo con una fracción de impacto cercana a uno, en una página donde el usuario ya está mirando. Convertirlo en una superposición inferior es un cambio de tres líneas que elimina de golpe entre 0,05 y 0,15 puntos de CLS. Y hay un efecto secundario que a nadie le disgusta: la tasa de aceptación no baja, porque el aviso sigue siendo igual de visible.
Ponerlo donde no se ve. Los elementos que no están dentro de la ventana no contribuyen a la fracción de impacto. Inyectar contenido por debajo de lo visible es gratis para la métrica, siempre que el usuario no esté a punto de llegar ahí. Es la razón técnica de que el scroll infinito bien hecho no genere CLS: si el contenido nuevo se añade al final, por debajo del pliegue, y el pie de página no está visible, nada se mueve en la pantalla del usuario.
El corolario incómodo es que el scroll infinito con un pie de página visible sí genera CLS, y mucho, porque cada tanda de contenido empuja el pie. La solución habitual es no tener pie de página en las vistas de scroll infinito, o fijarlo.
La ventana de los 500 milisegundos
Un desplazamiento que ocurre dentro de los 500 milisegundos siguientes a una entrada discreta del usuario —un clic, un toque, una pulsación de tecla— se marca con la bandera de entrada reciente y no cuenta para el CLS.
Esa exclusión existe por una razón sensata: si el usuario pulsa “ver más” y el contenido se expande, el movimiento es esperado y no es una mala experiencia. Y esa razón sensata se convierte en un criterio de diseño concreto y muy potente:
Todo lo que puedas convertir en respuesta a una acción del usuario, conviértelo. Un panel que se abre al pulsar, un formulario que crece al enfocar un campo, un menú que despliega: todo eso es gratis si el cambio ocurre dentro de la ventana.
Y de ahí sale el número que hay que tener en la cabeza al escribir animaciones: si tu transición de altura dura más de 500 milisegundos desde la pulsación, la parte que sobresale sí cuenta. Un acordeón con una transición de 300 milisegundos es invisible para la métrica; el mismo acordeón con 700 milisegundos genera desplazamiento en sus últimos 200. Es un argumento a favor de animaciones cortas que no depende del gusto de nadie.
Dos límites de la exclusión que hay que conocer para no confiarse.
El desplazamiento de página no es una entrada discreta. Ni el arrastre, ni el gesto de pellizco. Un salto que ocurra mientras el usuario hace scroll suma íntegro, y ese es precisamente el momento en que se cargan las imágenes diferidas y las incrustaciones.
La bandera se aplica a la entrada de desplazamiento completa, no a cada elemento. Un fotograma donde coinciden un cambio pedido por el usuario y una inyección que no tiene nada que ver queda excluido entero. Se puede usar eso para maquillar la métrica agrupando inyecciones justo después de un clic, y no deberías: el usuario ve el salto igual, y lo único que consigues es quedarte sin señal para detectarlo.
Animaciones que desplazan y animaciones que no
La regla es de una sola frase: una propiedad desplaza si participa en el layout.
| Anima esto | Etapa que dispara | Desplaza |
|---|---|---|
transform, opacity, filter |
Composición o pintura | No |
top, left, inset en un elemento posicionado |
Layout del propio elemento | No, si está fuera del flujo |
top, left, margin en un elemento en flujo |
Layout | Sí |
width, height, padding, border-width |
Layout | Sí |
font-size, line-height, letter-spacing |
Layout | Sí |
grid-template-rows, flex-basis, gap |
Layout | Sí |
De ahí las sustituciones canónicas, que además son las que dan animaciones fluidas: transform: translate() en lugar de mover con top o left, y transform: scale() en lugar de crecer con width y height.
El caso que no tiene sustitución limpia es el acordeón, porque scale deforma el contenido en lugar de revelarlo. Las tres opciones reales, con sus consecuencias:
Animar la altura y quedarse dentro de los 500 milisegundos. La más simple y la que mejor se ve. Si la animación arranca desde una pulsación y dura menos de medio segundo, el CLS no se entera. Con las palabras clave de tamaño interpolables ya disponibles en algunos motores, animar desde cero hasta el contenido natural es posible sin JavaScript, y aun así conviene la comprobación de soporte:
.panel {
block-size: 0;
overflow: hidden;
transition: block-size 260ms ease-out;
}
.panel[data-abierto] {
block-size: auto;
}
@supports (interpolate-size: allow-keywords) {
:root { interpolate-size: allow-keywords; }
}
Reservar el espacio del panel abierto siempre. Elimina el problema y desperdicia espacio. Solo tiene sentido cuando el panel es pequeño.
Superponer el panel en lugar de empujar. Un desplegable con position: absolute sobre el contenido no mueve nada. Es lo correcto para menús y para selectores, y es discutible para contenido que el usuario quiere leer junto al resto.
Un caso menos evidente pero muy común: la aparición de la barra de desplazamiento. Cuando el contenido crece lo suficiente para necesitar barra, en los sistemas donde la barra ocupa espacio, el área de contenido se estrecha unos 15 píxeles de golpe y toda la página se recompone. Se arregla con una declaración:
:root {
scrollbar-gutter: stable;
}
Reserva el canal de la barra siempre, exista o no. Cuesta unos píxeles de anchura y elimina una clase entera de desplazamientos que son muy difíciles de atribuir cuando aparecen en la tabla de culpables, porque el elemento que se mueve no tiene nada que ver con la causa.
Hay una combinación de decisiones muy razonables por separado que produce el peor CLS posible, y he tenido que diagnosticarla las suficientes veces como para reconocerla por el patrón en los datos: una masa de desplazamientos repartida a lo largo de toda la sesión, con puntuaciones medias, sin ningún culpable dominante.
La receta es esta. Las imágenes llevan carga perezosa, que es correcto. El observador de intersección que las carga usa el umbral por defecto, es decir, dispara cuando el elemento ya está entrando en la ventana. El usuario hace scroll, la imagen empieza a descargarse justo cuando aparece, tarda 300 milisegundos en llegar, y durante esos 300 milisegundos hay un hueco que después se rellena. Si el hueco estaba reservado no pasa nada; si no lo estaba, cada imagen que entra en pantalla genera su propio desplazamiento, dentro de la ventana visible, y sin la protección de la exclusión de entrada porque el scroll no cuenta como entrada.
Cada uno de esos desplazamientos puntúa poco. Pero ocurren en rápida sucesión mientras el usuario se desplaza, es decir, dentro de la misma ventana de sesión, que acumula. Diez imágenes entrando en pantalla en cuatro segundos, a 0,02 cada una, son 0,2 en una sola ventana: el doble del presupuesto entero.
Tres arreglos, y el orden importa.
Uno: reserva el espacio, obviamente. Es el arreglo de fondo y ya lo tienes. Si la caja tiene su altura desde el principio, todo lo demás es irrelevante.
Dos: carga con antelación. El atributo nativo de carga perezosa usa un margen de descarga que los motores calculan según el tipo de conexión, y es generoso; el problema aparece cuando alguien reimplementa la carga perezosa con un observador de intersección y deja el margen a cero. La corrección es explícita:
const observador = new IntersectionObserver(
(entradas) => {
for (const e of entradas) {
if (!e.isIntersecting) continue;
e.target.src = e.target.dataset.src;
observador.unobserve(e.target);
}
},
{ rootMargin: '600px 0px' }, // empezar a cargar 600 px antes de que se vea
);Seiscientos píxeles de antelación, a una velocidad de scroll normal, son entre medio segundo y un segundo de margen: suficiente para que la imagen llegue antes de entrar en escena en la mayoría de las conexiones. Y el coste de equivocarse es bajo, porque son imágenes que el usuario iba a ver de todos modos.
Tres, y es el que casi nadie hace: no apliques carga perezosa por encima del pliegue. Una imagen visible en la carga inicial marcada como perezosa se descarga más tarde que si no lo estuviera, porque el navegador la despriorizará hasta confirmar que está en la ventana. Además de retrasar el LCP, garantiza un desplazamiento en el peor sitio posible. La regla operativa es sencilla: carga perezosa a partir de la segunda pantalla, nunca en la primera.
El diagnóstico que confirma el patrón, por si te lo encuentras: en la tabla de culpables, muchas filas de imágenes distintas, todas con dy parecido y moderado, y con instantes repartidos en lugar de agrupados al principio. Si esa es la forma de tus datos, no busques un culpable único: el culpable es el procedimiento.
Localiza los tres bloques de tu sitio que se inyectan después de la carga y clasifícalos: reservable, superponible o irrelevante por estar fuera de la ventana. Aplica el tratamiento que corresponda a cada uno. Después mide la altura real de las ranuras reservables con el observador de redimensionado durante una semana y ajusta el min-block-size al percentil 90 medido en lugar del inventado.