content-visibility: saltarse el renderizado de lo que no se ve
Los tres valores de content-visibility, qué containment aplica cada uno, qué cuenta como relevante para el usuario, y cómo parar trabajo de JavaScript con contentvisibilityautostatechange.
contain corta la propagación pero no evita el trabajo: el navegador sigue maquetando y pintando todo lo que hay en el documento, esté en pantalla o a nueve mil píxeles de distancia. content-visibility: auto es la propiedad que le da permiso para no hacerlo, aplicando y retirando containment de forma dinámica según lo que el usuario pueda ver. Es la ganancia más grande que se puede conseguir con una sola declaración de CSS en una página larga, y también una de las que más comportamiento cambia por debajo.
- Enumerar los tres valores de
content-visibilityy el containment exacto que aplica cada uno. - Determinar cuándo un elemento es “relevante para el usuario” y por tanto se renderiza.
- Usar
contentvisibilityautostatechangepara suspender trabajo de JavaScript en lo que no se ve. - Elegir entre
content-visibility: hiddenydisplay: nonecon criterio.
Los tres valores
content-visibility acepta visible, auto y hidden. La diferencia entre ellos es qué containment aplican y cuándo.
| Valor | Containment | Contenido renderizado |
|---|---|---|
visible |
Ninguno | Siempre |
auto |
layout style paint siempre; además size cuando no es relevante |
Solo cuando es relevante para el usuario |
hidden |
size layout style paint siempre |
Nunca, salvo que lo cambies |
La fila de auto esconde el detalle que más problemas da y que casi ningún tutorial menciona: la contención de layout, estilo y pintado está activa siempre, también cuando el elemento está perfectamente visible en pantalla. Es decir, poner content-visibility: auto en un contenedor le aplica de forma permanente todos los efectos secundarios de contain: layout paint: se convierte en contenedor de bloque para descendientes fixed y absolute, crea contexto de apilamiento, y recorta todo lo que sobresalga. Si dentro había un position: sticky que se pegaba al viewport, o un menú que se salía, se rompen igual que con contain, y se rompen siempre, no solo al hacer scroll.
Lo que sí es dinámico es la contención de tamaño y el salto del renderizado. Cuando el elemento no es relevante, el navegador se comporta como si estuviera vacío: no calcula el layout de su interior, no lo pinta y no hace hit testing sobre él. El coste de esa sección pasa a ser el de una caja vacía.
/* El patron canonico: secciones largas de una pagina larga. */
.seccion-articulo {
content-visibility: auto;
contain-intrinsic-size: auto 1200px;
}
El artículo de web.dev que presentó la propiedad midió sobre una página de demostración con muchas secciones un tiempo de renderizado inicial de 232 ms que bajaba a 30 ms al aplicarla. Es una demostración construida para lucir, no una promesa; el orden de magnitud sí es representativo cuando la página tiene mucho contenido fuera de pantalla y poco de él es visible al cargar.
Mídelo tú en tu caso con este procedimiento, que evita el error clásico de cronometrar sin forzar el trabajo:
function medirRenderInicial() {
// Fuerza estilo, layout y la preparacion del pintado del documento.
const t0 = performance.now();
document.body.getBoundingClientRect();
const t1 = performance.now();
return +(t1 - t0).toFixed(1);
}
// Ejecuta con y sin la clase que aplica content-visibility, recargando
// entre medidas. El navegador cachea demasiado como para fiarse de
// medir las dos versiones en la misma carga.
console.log(medirRenderInicial(), 'ms');
Qué cuenta como relevante para el usuario
El navegador decide renderizar el contenido de un elemento con auto cuando ese elemento es relevante para el usuario. La lista de condiciones importa porque de ella dependen la accesibilidad y la búsqueda dentro de la página, que es donde esta propiedad se ha ganado la confianza que display: none nunca tuvo.
Un elemento es relevante si se cumple alguna de estas:
- Está dentro del viewport o cerca de él. Chrome usa un margen del orden de medio viewport en cada dirección; el valor exacto es un detalle de implementación y no está en la especificación, así que no construyas nada que dependa de él.
- El elemento o alguno de sus descendientes tiene el foco.
- El elemento o alguno de sus descendientes está dentro de la selección actual del usuario.
- El elemento está en la capa superior: un
dialogmodal abierto o un popover. - El elemento o alguno de sus descendientes contiene una coincidencia activa de la búsqueda dentro de la página.
Las tres últimas son las que cambian el juego. Cuando el usuario pulsa la combinación de búsqueda del navegador y escribe una palabra que está en una sección saltada, el navegador renderiza esa sección y desplaza hasta ella. Lo mismo pasa al navegar a un ancla que apunta dentro, al hacer scrollIntoView sobre un descendiente, y al tabular hacia un control que estaba fuera de pantalla. El contenido de una sección con content-visibility: auto está en el árbol de accesibilidad y es alcanzable por un lector de pantalla.
Esa es la diferencia esencial con las virtualizaciones hechas a mano en JavaScript, donde los nodos no existen: si no existen, ni la búsqueda del navegador los encuentra, ni el lector de pantalla los anuncia, ni el ancla funciona. Con content-visibility los nodos sí existen; lo que se salta es su renderizado. La búsqueda del navegador recorre el DOM, no los píxeles.
Para consultar el estado desde JavaScript sin adivinar, existe un método específico:
// ¿Este elemento esta siendo saltado por content-visibility: auto?
const saltado = !el.checkVisibility({ contentVisibilityAuto: true });
Parar el trabajo de lo que no se ve
La propiedad ahorra el trabajo del navegador, pero no toca el tuyo. Si dentro de una sección saltada hay un canvas animándose con requestAnimationFrame, un contador que se actualiza cada segundo o un gráfico que recalcula, ese JavaScript sigue ejecutándose y sigue quemando batería para pintar píxeles que nadie ve.
Para eso existe el evento contentvisibilityautostatechange, que se dispara en el elemento cuando cambia su estado de saltado. Su propiedad skipped te dice hacia dónde va el cambio.
const panel = document.querySelector('.panel-grafico');
let animando = false;
let id = 0;
function bucle() {
dibujarFotograma(panel.querySelector('canvas'));
id = requestAnimationFrame(bucle);
}
panel.addEventListener('contentvisibilityautostatechange', (evento) => {
if (evento.skipped) {
cancelAnimationFrame(id);
animando = false;
} else if (!animando) {
animando = true;
id = requestAnimationFrame(bucle);
}
});
// Estado inicial: puede que ya naciera saltado.
if (!panel.checkVisibility({ contentVisibilityAuto: true })) {
panel.dispatchEvent(
new Event('contentvisibilityautostatechange')
);
}
El evento tiene una ventaja sobre IntersectionObserver para este uso: llega exactamente cuando el navegador decide saltar o dejar de saltar, así que tu trabajo y el suyo se sincronizan sin dos criterios distintos de “está cerca del viewport”. Con un observador tendrías que replicar el margen del navegador a mano y acertar.
Lo que content-visibility no hace es evitar peticiones de red. Las imágenes de una sección saltada se descargan igual salvo que lleven loading="lazy", los iframes también, y el JavaScript que ya estaba en el bundle ya se descargó. La propiedad ahorra trabajo de renderizado, no de red.
La tentación en cuanto entiendes la propiedad es escribir article > * { content-visibility: auto } y celebrar. No lo hagas, y no por la razón que suele darse —“el navegador tiene que hacer seguimiento de intersección”—, que también, sino por dos razones más profundas. La primera es que el ahorro de content-visibility es proporcional al trabajo evitado por elemento, y ese trabajo es el layout y el pintado de su subárbol. Un p de tres líneas cuesta microsegundos de layout; la contabilidad de saltarlo, gestionar su estado de relevancia y guardar su tamaño recordado cuesta un orden parecido. Aplicarlo a mil párrafos convierte una operación barata en mil operaciones baratas más mil contabilidades. La regla de oro es que el elemento contenido debe agrupar al menos decenas de nodos, idealmente cientos: secciones, capítulos, tarjetas grandes, filas de tabla complejas, comentarios completos. Docenas de contenedores, no miles. La segunda razón es la que de verdad muerde, y es semántica: cada elemento al que le pones content-visibility: auto recibe contención de layout y de pintado permanente, y con ella un contexto de apilamiento, un contenedor de bloque para descendientes fijos y un recorte de todo lo que sobresalga. Aplicarlo con un selector genérico es aplicar cuatro cambios de comportamiento a cientos de elementos a los que no has mirado. El síntoma llega días después y en forma de bug incomprensible: un tooltip que se corta solo en la sección tres, un sticky que deja de pegarse cuando el usuario ha hecho scroll suficiente, un modal que aparece dentro de una tarjeta. Y como el CSS que lo causó era una línea genérica y el efecto es local, nadie lo relaciona. La disciplina correcta es la contraria a la de casi cualquier otra optimización de CSS: enumera los contenedores a mano, uno a uno, con una clase específica y un comentario que diga por qué.
hidden frente a display: none
content-visibility: hidden esconde el contenido igual que display: none, pero conserva el estado de renderizado del subárbol. Cuando lo vuelves a mostrar, el navegador no tiene que reconstruir el árbol de cajas desde cero: reutiliza lo que ya tenía. Para paneles que se muestran y se ocultan muchas veces —pestañas, acordeones, pasos de un formulario largo— la diferencia al mostrar es medible y a favor de hidden.
Las diferencias de comportamiento, que son las que deciden:
display: none |
content-visibility: hidden |
|
|---|---|---|
| Genera caja | No | Sí, del tamaño que digas |
| Ocupa espacio | No | Sí |
| Coste de volver a mostrar | Reconstruye el subárbol | Reutiliza el estado |
| Búsqueda en la página | No encuentra | No encuentra |
| Árbol de accesibilidad | Fuera | Fuera |
| Estado de scroll interno | Se pierde | Se conserva |
Que ocupe espacio es la trampa: content-visibility: hidden aplica contención de tamaño, así que el elemento mide lo que le digas con contain-intrinsic-size y, si no le dices nada, cero. Un panel oculto con altura cero se comporta como display: none a efectos de layout, pero sigue siendo un elemento con caja, y eso importa si es hijo de un contenedor flexible o de una rejilla, donde una caja de tamaño cero sigue contando como elemento y consume una pista.
Existe una variante interesante para contenido plegado que el usuario debería poder encontrar: el atributo hidden="until-found", disponible en Chromium, aplica content-visibility: hidden pero permite que la búsqueda dentro de la página lo encuentre, disparando el evento beforematch para que despliegues el acordeón antes de que el navegador desplace. Es la solución correcta para preguntas frecuentes plegadas, donde ocultar el texto significa que nadie lo encuentra buscando. Fuera de Chromium el soporte aún no es universal, así que conviene tratarlo como una mejora progresiva.
La estimación de tamaño, que es la pieza que falta para que todo esto no destroce la barra de scroll, tiene su propia lección: contain-intrinsic-size y la búsqueda dentro de la página.
- Coge un artículo largo con al menos veinte secciones. Mide el tiempo de renderizado inicial antes y después de aplicar
content-visibility: autoa las secciones. - Comprueba que la búsqueda del navegador encuentra una palabra que está en la sección dieciocho, y que el navegador desplaza hasta ella.
- Añade un
canvasanimado en una sección lejana y suspende su bucle concontentvisibilityautostatechange. Verifica en el perfil que el JavaScript deja de ejecutarse. - Mete un
position: stickydentro de una sección concontent-visibility: autoy documenta qué le pasa y por qué. - Compara el coste de mostrar un panel oculto con
display: nonefrente acontent-visibility: hidden, alternando cien veces.