wandres.dev
IMÁGENES II · Responsive, lazy y placeholders

Placeholders que no desplazan nada: color dominante, LQIP y blur hash

Las tres familias de marcador de posición para imágenes, cuánto pesa cada una, cómo se generan en el pipeline de compilación y por qué ninguna sirve de nada si no has reservado el espacio antes.

⏱ 19 min

Un hueco blanco que se rellena de golpe es peor experiencia que una mancha de color que se convierte en una foto, aunque los dos tarden exactamente lo mismo. El marcador de posición es rendimiento percibido puro: no acelera nada, cambia la sensación. Lo que sí es rendimiento medible es la reserva de espacio, y conviene no confundir las dos cosas, porque hay implementaciones populares de placeholder que resuelven la percepción y provocan desplazamiento de contenido.

🎯 Al terminar esta lección sabrás
  • Separar el problema de reservar espacio del problema de rellenar ese espacio.
  • Comparar el coste en bytes y en CPU de las tres familias de placeholder.
  • Generar un LQIP y un color dominante en el proceso de compilación con código ejecutable.
  • Decidir qué tipo de placeholder corresponde a cada tipo de imagen.

Primero el espacio, después el relleno

El desplazamiento de contenido no lo causa la falta de placeholder. Lo causa que la caja de la imagen mida cero antes de cargar y su tamaño real después. Cualquier relleno que pongas dentro de una caja de altura cero sigue produciendo el mismo salto.

La reserva de espacio se consigue de una sola forma correcta: declarando la relación de aspecto antes de que llegue el fichero. Desde 2021 los cuatro motores derivan una aspect-ratio implícita de los atributos width y height del elemento, de modo que con esto basta:

<img src="foto.avif" width="1600" height="900" alt="Cala de arena negra"
     style="width: 100%; height: auto;">

Los atributos no son el tamaño de presentación: son la proporción. El CSS de al lado hace que la imagen ocupe el ancho disponible y calcule la altura a partir de la proporción declarada. La caja tiene su altura definitiva desde el primer layout, antes de que el fichero haya llegado.

Cuando no conoces las dimensiones —imágenes subidas por usuarios, por ejemplo— la alternativa es fijar la proporción en el contenedor:

.tarjeta-media {
  aspect-ratio: 16 / 9;
  overflow: hidden;
}
.tarjeta-media > img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
}

Solo cuando esto está resuelto tiene sentido hablar de placeholder. Todo lo que sigue asume que la caja ya no se mueve.

Las tres familias, con sus números

El color dominante. Un único color, extraído de la imagen en el proceso de compilación, aplicado como fondo del contenedor. Cuesta siete bytes si lo inyectas en línea como un hexadecimal, o cero adicionales si lo metes en una propiedad personalizada que ya viaja en el HTML. No requiere decodificación, no requiere red, no requiere JavaScript. Es la opción por defecto sensata para el noventa por ciento de las imágenes.

El LQIP, imagen de baja calidad. Un JPEG, WebP o AVIF diminuto —típicamente de 16 a 32 píxeles de ancho— incrustado en base64 dentro del HTML o del CSS. Pesa entre 300 y 900 bytes según formato y tamaño, y se escala al tamaño del contenedor con un desenfoque para disimular los artefactos. Da una previsualización reconocible: se distingue si es un retrato, un paisaje o un plato de comida. El coste es que esos bytes viajan en el documento, así que en un listado de cincuenta tarjetas son entre 15 y 45 KB añadidos al HTML, que además no se cachean por separado.

El blur hash. Una codificación compacta de los componentes de baja frecuencia de la imagen, expresada como una cadena ASCII de veinte a treinta caracteres. Se descodifica en el cliente a un mapa de píxeles diminuto que se dibuja en un <canvas> o se convierte en un data: URL. El resultado visual es parecido al de un LQIP muy desenfocado, con una veinteava parte de los bytes.

Técnica Bytes por imagen Coste en cliente Fidelidad
Color dominante ~7 Ninguno Solo el tono
LQIP a 20px 300-900 Decodificar una imagen minúscula Composición reconocible
Blur hash 20-30 Descodificar y pintar en canvas, 1-3 ms Manchas de color por zonas

La tabla explica la decisión mejor que cualquier argumento. Con menos de diez imágenes, el LQIP es cómodo y su coste es despreciable. Con cincuenta o más, el color dominante gana por goleada salvo que la fidelidad importe de verdad. Y el blur hash solo compensa cuando quieres fidelidad y tienes muchas imágenes y ya estás ejecutando JavaScript en esa vista de todas formas.

Generarlos en compilación, con código que funciona

Todo esto tiene que ocurrir en el proceso de compilación o en el momento de subida, nunca en el cliente. Estos dos fragmentos usan sharp, que es la biblioteca estándar para esto en el ecosistema de Node.

Color dominante, con el propio análisis de sharp:

import sharp from 'sharp';

export async function colorDominante(ruta) {
  const { dominant } = await sharp(ruta).stats();
  const hex = (n) => n.toString(16).padStart(2, '0');
  return `#${hex(dominant.r)}${hex(dominant.g)}${hex(dominant.b)}`;
}

LQIP en AVIF, que a estos tamaños comprime mejor que JPEG:

import sharp from 'sharp';

export async function lqip(ruta, ancho = 20) {
  const buf = await sharp(ruta)
    .resize({ width: ancho })
    .avif({ quality: 40, effort: 6 })
    .toBuffer();
  return `data:image/avif;base64,${buf.toString('base64')}`;
}

Y el consumo en la plantilla, con una propiedad personalizada que sirve para las dos técnicas:

<div class="marco" style="--ph: url('data:image/avif;base64,...'); aspect-ratio: 3 / 2;">
  <img src="foto-800.avif" srcset="foto-400.avif 400w, foto-800.avif 800w, foto-1200.avif 1200w"
       sizes="(min-width: 800px) 380px, 100vw"
       width="1200" height="800" alt="Puente de hierro sobre el río"
       loading="lazy" decoding="async">
</div>
.marco {
  position: relative;
  overflow: hidden;
  background-image: var(--ph);
  background-size: cover;
  background-position: center;
  /* El desenfoque disimula los artefactos del LQIP a 20px */
  filter: none;
}
.marco > img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  /* Aparece cuando el navegador la ha pintado, sin JavaScript */
  animation: aparecer 220ms ease-out;
}
@keyframes aparecer { from { opacity: 0; } to { opacity: 1; } }

Fíjate en lo que no hay aquí: ni un onload, ni una clase que se añade desde JavaScript, ni un observador. La transición se hace con una animación CSS que arranca cuando el elemento se pinta, y el fondo se queda debajo. Si el JavaScript falla o llega tarde, la imagen se ve igual.

El desenfoque del placeholder es la razón oculta de la mitad de los jankeos al hacer scroll

Aplicar filter: blur(20px) a un elemento hace que el navegador tenga que pintar el elemento en una superficie aparte, aplicarle una convolución y componerla. En un listado con treinta tarjetas, treinta desenfoques activos simultáneos en un móvil de gama media producen exactamente lo que estás pensando: caída de fotogramas al desplazarse, con la CPU y la GPU al máximo, y un perfil lleno de tiempo en pintura.

Hay tres salidas y las tres funcionan.

La primera y mejor: no desenfoques nada en el cliente. Genera el LQIP ya desenfocado. sharp(ruta).resize({ width: 20 }).blur(2) produce un fichero de veinte píxeles con el desenfoque ya cocido, y al escalarlo a 400 píxeles el propio filtrado bilineal del navegador hace el resto del trabajo gratis. Cero coste de filtro en tiempo de ejecución.

La segunda: si necesitas el filtro en vivo, quítalo en cuanto la imagen carga. Un filter que se queda puesto en un elemento cuya imagen ya está visible sigue costando en cada pintado, aunque visualmente esté tapado. Es un coste invisible que se acumula durante toda la sesión.

La tercera, para el caso de fondo: aplica el desenfoque a un pseudoelemento con will-change: opacity y elimínalo del árbol con content-visibility: auto en el contenedor, de forma que las tarjetas fuera de pantalla no paguen nada.

Y una advertencia sobre el blur hash: descodificar treinta hashes y pintarlos en treinta canvas es entre 30 y 90 milisegundos de hilo principal, justo durante el arranque. Si vas a usarlo en listados largos, descodifica solo los visibles y hazlo por lotes con cesión del hilo entre lote y lote.

Qué placeholder para qué imagen

La decisión se resuelve con tres preguntas y no necesita más.

¿La imagen es un elemento del LCP? Entonces no le pongas placeholder ninguno más allá del color de fondo. Un LQIP en la imagen del hero añade bytes al HTML crítico, y el HTML crítico es exactamente lo que no debe crecer. Además, algunos navegadores han considerado en el pasado el propio placeholder como candidato al LCP, lo que produce una métrica engañosamente buena y una experiencia real igual de lenta.

¿Hay más de veinte imágenes en la vista? Color dominante. Los bytes del LQIP multiplicados por veinte compiten con el propio contenido.

¿La imagen es fotográfica y su contenido importa antes de verla? LQIP o blur hash. En una galería de un fotógrafo, en un catálogo de moda o en un mapa de miniaturas, saber qué hay antes de que cargue tiene valor real. En una lista de artículos de blog con imágenes decorativas, no.

Un caso que no encaja en ninguna de las tres: las imágenes con transparencia. Un LQIP en JPEG pierde el canal alfa y produce un rectángulo opaco donde debería haber un contorno recortado. Usa AVIF o WebP para el LQIP si hay transparencia, o quédate con el color dominante aplicado solo dentro de la silueta, que en la práctica significa no poner nada.

⚔️ Reto práctico

Genera los tres tipos de placeholder para la misma imagen y mide: el peso en bytes de cada uno, el tiempo de decodificación en el panel de rendimiento con la CPU estrangulada a 4x, y el tiempo hasta que aparece algo visible en el hueco con la red a 3G lento. Después repite el listado completo con treinta tarjetas y compara los fotogramas por segundo al desplazarte, con desenfoque en vivo y con desenfoque precocido en el fichero.