El Cumulative Layout Shift (CLS) es, honestamente, el Core Web Vital más subestimado de todos. Mientras LCP e INP se llevan toda la atención, un CLS deficiente te arruina la experiencia: botones que se mueven justo cuando vas a tocarlos, anuncios que empujan el contenido hacia abajo, imágenes que aparecen sin reservar espacio. En 2026, Google sigue exigiendo un CLS inferior a 0,1 para que una página se considere "Buena", y con las nuevas APIs de navegación instantánea (View Transitions, Speculation Rules) los layout shifts son más visibles que nunca.
Esta guía cubre las técnicas modernas — muchas estandarizadas en los últimos 18 meses — para llevar tu CLS prácticamente a cero. No voy a repetir consejos obvios tipo "pon width y height en las imágenes" (eso ya lo sabes). Vamos directo a las herramientas reales que necesitas hoy: aspect-ratio, content-visibility, size-adjust para fuentes, CSS Containment y debugging con LayoutShift PerformanceObserver.
Qué es CLS y por qué sigue importando en 2026
CLS mide la inestabilidad visual de una página, o sea, cuánto se desplaza el contenido de forma inesperada mientras el usuario interactúa con ella. Se calcula como la suma de los puntajes de cada shift inesperado, donde cada puntaje es el producto de la fracción de impacto (el área del viewport afectada) y la fracción de distancia (cuánto se movió el contenido).
Desde la actualización de junio de 2021, Google solo cuenta el peor burst de shifts en una ventana de sesión de 5 segundos — no la suma total. Esto premia páginas que concentran sus shifts al inicio (mientras se cargan recursos) y castiga a las que generan shifts continuos durante toda la navegación. Es una distinción importante, y aún así mucha gente la pasa por alto.
Los umbrales actuales en 2026 siguen siendo:
- Bueno: CLS ≤ 0,1
- Necesita mejorar: 0,1 < CLS ≤ 0,25
- Pobre: CLS > 0,25
El percentil 75 de las sesiones reales de tus usuarios tiene que estar en "Bueno" para pasar el Core Web Vital. Si tu CRUX (Chrome User Experience Report) muestra p75 = 0,18, estás suspendiendo, así de simple.
Las cinco causas principales de CLS en sitios modernos
Antes de las soluciones, conviene reconocer los patrones que generan shifts. En auditorías recientes que he hecho a sitios reales (B2C, B2B, e-commerce, news), estas son las cinco causas más frecuentes:
- Imágenes y vídeos sin dimensiones reservadas — el navegador no sabe cuánto espacio dejar.
- Fuentes web que cargan después del texto fallback — el clásico FOUT (Flash of Unstyled Text) produce reflow cuando llegan los
woff2. - Anuncios, iframes y embeds insertados dinámicamente sin un contenedor de tamaño fijo.
- Contenido inyectado por JavaScript — banners de cookies, "notificaciones", componentes lazy-loaded sin placeholder.
- Animaciones que usan propiedades de layout (
top,left,width) en vez detransform.
Vamos a resolver cada una con código actualizado.
Reservar espacio con aspect-ratio (no solo width/height)
La técnica clásica de poner width y height en HTML sigue funcionando: el navegador calcula el ratio y reserva el espacio. Pero falla cuando el contenedor es responsive y la imagen tiene que adaptarse. Y aquí es donde entra aspect-ratio, soportada de forma estable en todos los navegadores desde 2022.
/* Reserva el espacio antes de que la imagen cargue */
.hero-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background-color: #f3f4f6; /* placeholder visual */
}
/* Para imágenes con dimensiones variables, combina con max-width */
.product-thumbnail {
width: 100%;
max-width: 320px;
aspect-ratio: 1 / 1;
}
Para vídeos e iframes embebidos (YouTube, Vimeo, mapas) la regla es la misma:
.video-embed {
width: 100%;
aspect-ratio: 16 / 9;
}
.video-embed iframe {
width: 100%;
height: 100%;
border: 0;
}
Una ventaja menos conocida — y bastante útil: aspect-ratio respeta los atributos width y height del elemento. Si tu imagen tiene <img width="800" height="450">, los navegadores modernos derivan aspect-ratio: 800/450 automáticamente. Es el famoso "default aspect ratio" del HTML spec, y te ahorra muchísimo CSS extra.
Imágenes responsive sin shift con srcset
Cuando usas srcset para servir distintas resoluciones, el navegador puede elegir una imagen con ratio ligeramente diferente. Para garantizar cero shift, asegúrate de que todas las fuentes en srcset mantienen el mismo ratio:
<img
src="/img/hero-800.jpg"
srcset="/img/hero-400.jpg 400w,
/img/hero-800.jpg 800w,
/img/hero-1600.jpg 1600w"
sizes="(max-width: 768px) 100vw, 800px"
width="1600"
height="900"
alt="Producto destacado"
fetchpriority="high">
Eliminar el shift de fuentes web con size-adjust
Cuando una fuente web reemplaza al fallback del sistema, las métricas (x-height, ancho de caracteres, line-height computado) cambian. ¿Resultado? El texto se reorganiza y todos los bloques bajo el párrafo se desplazan. Esto solía resolverse con font-display: optional (es decir, saltarse la fuente si no carga rápido), pero esa solución sacrifica branding — y a nadie le gusta entregar la identidad visual.
La solución moderna usa los descriptores size-adjust, ascent-override, descent-override y line-gap-override en @font-face. Te permiten ajustar las métricas del fallback para que coincidan con las de la fuente web. Cero shift, cero pérdida de marca.
/* Fuente real */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-variable.woff2") format("woff2-variations");
font-display: swap;
font-weight: 100 900;
}
/* Fallback ajustado para coincidir con Inter */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107.4%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter Fallback", sans-serif;
}
Los valores exactos dependen de cada fuente. Herramientas como Fallback Font Generator calculan los descriptores comparando métricas reales. Frameworks como Next.js (con next/font), Nuxt y Astro lo hacen automáticamente desde 2024, así que si trabajas con alguno de ellos, probablemente ya te lo dan resuelto.
Cuándo usar font-display: optional
Para textos críticos como el LCP, font-display: optional sigue siendo una opción válida si no puedes precargar la fuente: si no llega en 100 ms, el navegador usa el fallback y no intentará swap. Cero CLS por fuentes — pero solo si tienes una buena estrategia de cache para visitas recurrentes (de lo contrario, en la primera visita el usuario nunca verá tu tipografía).
Contenido dinámico: reserva espacio antes de inyectar
Los banners de cookies, notificaciones push y widgets de chat inyectados desde JavaScript son la fuente número uno de CLS en sitios B2C. La regla es simple: nunca insertes contenido sobre el fold sin reservar el espacio en el HTML inicial.
<!-- Espacio reservado en el HTML -->
<div id="cookie-banner-slot" style="min-height: 88px;"></div>
<script>
// El JS llena el slot, pero no añade altura nueva
document.getElementById("cookie-banner-slot")
.innerHTML = renderBanner();
</script>
Si la altura del banner es variable (depende del idioma, o de algún A/B test), usa min-height con el valor máximo probable. Mejor un poco de espacio extra que un shift visible.
Anuncios e iframes: contenedores de tamaño fijo
Para anuncios de Google AdSense u otras redes, define el tamaño del slot antes de que cargue:
.ad-slot {
display: block;
width: 300px;
height: 250px;
margin: 1rem auto;
background-color: #f9fafb;
overflow: hidden;
}
Si usas formatos responsive (anchored ads, multiplex), reserva el tamaño máximo esperado o aísla el anuncio con CSS Containment para que el shift no se propague al resto de la página.
CSS Containment: aislar partes de la página
contain y content-visibility son dos propiedades infravaloradas que ayudan tanto con CLS como con rendering general. contain le dice al navegador que un elemento es independiente de su contexto: los cambios dentro no afectan al layout exterior.
.widget {
contain: layout style;
}
/* Para tarjetas en una lista virtualizada */
.card {
contain: layout style paint;
}
content-visibility: auto va un paso más allá: el navegador omite el rendering de elementos fuera del viewport hasta que se acercan. Esto reduce drásticamente el trabajo en listas largas y, combinado con contain-intrinsic-size, evita shifts al renderizar bajo demanda.
/* Cada artículo del listado: solo renderiza si está cerca del viewport */
.article-card {
content-visibility: auto;
contain-intrinsic-size: 0 320px; /* alto estimado */
}
El truco está en contain-intrinsic-size: si no lo defines, el navegador asume altura 0 y produce shift cuando el elemento entra en el viewport. Con un valor razonable, reserva el espacio correctamente — y nadie se entera.
Animaciones compatibles con CLS
Cualquier propiedad CSS que cause layout cuenta para CLS si se anima sin interacción del usuario. La regla de oro: animar solo transform y opacity. Estas dos propiedades se promueven a una capa de composición y no disparan reflow.
/* ❌ Genera CLS si se ejecuta automáticamente */
@keyframes slide-bad {
from { left: 0; width: 100px; }
to { left: 200px; width: 200px; }
}
/* ✅ Cero CLS */
@keyframes slide-good {
from { transform: translateX(0) scaleX(1); }
to { transform: translateX(200px) scaleX(2); }
}
Excepción importante: si la animación se dispara como respuesta directa a una interacción del usuario (un click menos de 500 ms antes del shift), Google la marca como expected y no cuenta para CLS. Por eso un menú que se expande al hacer clic está perfectamente bien, pero un banner que aparece automáticamente a los 3 segundos no lo está.
Debugging: encontrar la fuente exacta de cada shift
Chrome DevTools tiene una pestaña Performance Insights que destaca cada shift y muestra el elemento culpable. Pero para análisis programático y monitoring en producción, lo que necesitas es PerformanceObserver con la entrada layout-shift:
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Ignora shifts causados por interacción del usuario
if (entry.hadRecentInput) continue;
const firstEntry = sessionEntries[0];
const lastEntry = sessionEntries[sessionEntries.length - 1];
// Si han pasado >1s desde el último shift o >5s desde el primero, nueva sesión
if (
sessionValue &&
entry.startTime - lastEntry.startTime < 1000 &&
entry.startTime - firstEntry.startTime < 5000
) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
if (sessionValue > clsValue) {
clsValue = sessionValue;
clsEntries = sessionEntries;
// Log del culpable
console.log("Nuevo peor shift:", {
value: clsValue.toFixed(4),
sources: entry.sources?.map(s => ({
node: s.node,
previousRect: s.previousRect,
currentRect: s.currentRect,
})),
});
}
}
});
observer.observe({ type: "layout-shift", buffered: true });
La propiedad entry.sources es la clave del asunto: contiene los nodos DOM que se movieron y sus rectángulos antes y después. Envía esos datos a tu sistema de monitoring (Sentry, Datadog, New Relic) y vas a identificar enseguida qué componentes generan shifts en producción.
Web Vitals library para producción
En vez de implementar la lógica manualmente, usa la librería oficial web-vitals — ya maneja la ventana de sesión y los casos edge por ti:
import { onCLS } from "web-vitals/attribution";
onCLS((metric) => {
// metric.attribution.largestShiftTarget es el selector CSS culpable
navigator.sendBeacon("/analytics", JSON.stringify({
name: "CLS",
value: metric.value,
rating: metric.rating,
target: metric.attribution.largestShiftTarget,
time: metric.attribution.largestShiftTime,
}));
}, { reportAllChanges: false });
Checklist de auditoría CLS 2026
- Toda
<img>y<video>tienewidth,heightoaspect-ratiodefinido. - Los iframes y embeds están dentro de un contenedor con
aspect-ratio. - Las fuentes web usan
size-adjust+ descriptores override en el fallback, ofont-display: optional. - Las fuentes críticas se precargan con
<link rel="preload" as="font" crossorigin>. - Los banners y notificaciones tienen espacio reservado en el HTML inicial.
- Los slots de anuncios tienen
widthyheightexplícitos. - Las listas largas usan
content-visibility: autoconcontain-intrinsic-size. - Las animaciones automáticas solo modifican
transformyopacity. - El monitoring envía
attribution.largestShiftTargetpara identificar shifts en producción. - El p75 del CRUX está por debajo de 0,1.
Preguntas frecuentes
¿Por qué mi CLS local es bueno pero el de CRUX es malo?
Lighthouse mide CLS solo durante la carga inicial, pero CRUX captura toda la sesión del usuario. Los shifts que ocurren después del scroll, durante interacciones tardías, o con anuncios que cargan más adelante, no aparecen en Lighthouse. Usa PerformanceObserver con buffered: true en producción para capturar exactamente lo que CRUX está viendo.
¿Cuenta como CLS un shift causado por el clic del usuario?
No. Los shifts que ocurren dentro de los 500 ms posteriores a una interacción (click, tap, keypress) se marcan con hadRecentInput: true y se excluyen del cálculo. Esto cubre menús desplegables, acordeones y modales abiertos manualmente. Pero ojo: un banner que aparece automáticamente sin interacción sí cuenta.
¿aspect-ratio funciona en Safari antiguo?
Sí, desde Safari 15 (septiembre de 2021). En 2026 la compatibilidad es prácticamente universal — >97% según caniuse. Para soportar navegadores históricos puedes mantener el truco del padding-top: 56.25%, pero la verdad es que ya no es necesario en producción para la mayoría de sitios.
¿Cómo afecta View Transitions API al CLS?
Las animaciones generadas por View Transitions API (estables desde 2023 para SPA y 2024 para navegación cross-document) no cuentan como CLS, porque ocurren dentro de un snapshot controlado por el navegador. Aprovecha esto para transiciones suaves entre páginas sin penalización de Core Web Vitals — una de las mejores ganancias gratis que tenemos ahora mismo.
¿Es content-visibility seguro para SEO?
Sí. Googlebot procesa el contenido aunque content-visibility: auto esté activo: la propiedad solo retrasa el rendering visual, no oculta el DOM. El texto sigue accesible para parsers, lectores de pantalla y crawlers. La excepción es content-visibility: hidden, que sí esconde el contenido completamente — esa sí evítala si te importa el SEO.
Conclusión
El CLS de 2026 no se resuelve con trucos: requiere disciplina arquitectónica. Reserva espacio para todo lo que cargará después del HTML inicial, alinea las métricas de tus fuentes web con las del fallback, aísla las partes dinámicas con Containment y monitorea en producción con atribución. Si sigues las técnicas de esta guía, tu p75 de CLS en CRUX debería estabilizarse bien por debajo de 0,05 — y créeme, tu LCP, tu INP y tus conversiones lo van a agradecer.