Placeholders de Imagen y Carga
Receta
Utiliza técnicas de placeholder para eliminar cambios de diseño y proporcionar retroalimentación visual mientras se cargan las imágenes. Los enfoques principales son codificación de blur hash, esqueletos CSS shimmer, y placeholders de imagen de baja calidad (LQIP).
Placeholder BlurHash:
npm install blurhash sharp// lib/blurhash.ts
import { encode } from "blurhash";
import sharp from "sharp";
export async function generateBlurHash(imagePath: string): Promise<string> {
const image = sharp(imagePath);
const { width, height } = await image.metadata();
// Redimensiona a dimensiones pequeñas para codificación rápida
const { data, info } = await image
.resize(32, 32, { fit: "inside" })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
return encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4);
}Genera blurDataURL para next/image:
// lib/blur-data-url.ts
import sharp from "sharp";
export async function generateBlurDataURL(imagePath: string): Promise<string> {
const buffer = await sharp(imagePath)
.resize(8, 8, { fit: "inside" })
.jpeg({ quality: 50 })
.toBuffer();
return `data:image/jpeg;base64,${buffer.toString("base64")}`;
}
// Para imágenes remotas
export async function generateRemoteBlurDataURL(imageUrl: string): Promise<string> {
const res = await fetch(imageUrl);
const arrayBuffer = await res.arrayBuffer();
const buffer = await sharp(Buffer.from(arrayBuffer))
.resize(8, 8, { fit: "inside" })
.jpeg({ quality: 50 })
.toBuffer();
return `data:image/jpeg;base64,${buffer.toString("base64")}`;
}Ejemplo Funcional
Una galería de imágenes con efecto de carga blur-up:
// lib/images-with-blur.ts
import { generateRemoteBlurDataURL } from "@/lib/blur-data-url";
interface ImageData {
src: string;
alt: string;
width: number;
height: number;
}
export interface ImageWithBlur extends ImageData {
blurDataURL: string;
}
export async function getImagesWithBlur(images: ImageData[]): Promise<ImageWithBlur[]> {
return Promise.all(
images.map(async (image) => ({
...image,
blurDataURL: await generateRemoteBlurDataURL(image.src),
}))
);
}// app/gallery/page.tsx
import { getImagesWithBlur } from "@/lib/images-with-blur";
import { BlurGallery } from "./blur-gallery";
const imageData = [
{ src: "https://images.unsplash.com/photo-1506744038136-46273834b3fb", alt: "Lago de montaña", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1469474968028-56623f02e42e", alt: "Luz solar en el bosque", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1447752875215-b2761acb3c5d", alt: "Camino forestal", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1433086966358-54859d0ed716", alt: "Puente de cascada", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1472214103451-9374bd1c798e", alt: "Colinas verdes", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1501854140801-50d01698950b", alt: "Bosque aéreo", width: 1200, height: 800 },
];
export default async function GalleryPage() {
const images = await getImagesWithBlur(imageData);
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<h1 className="mb-6 text-3xl font-bold">Galería con Carga Blur-Up</h1>
<BlurGallery images={images} />
</main>
);
}// app/gallery/blur-gallery.tsx
"use client";
import Image from "next/image";
import { useState } from "react";
import type { ImageWithBlur } from "@/lib/images-with-blur";
export function BlurGallery({ images }: { images: ImageWithBlur[] }) {
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{images.map((image) => (
<div
key={image.src}
className="relative aspect-[3/2] overflow-hidden rounded-xl"
>
<Image
src={image.src}
alt={image.alt}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
placeholder="blur"
blurDataURL={image.blurDataURL}
onLoad={() =>
setLoadedImages((prev) => new Set(prev).add(image.src))
}
className={`object-cover transition-all duration-700 ${
loadedImages.has(image.src)
? "scale-100 blur-0"
: "scale-110 blur-lg"
}`}
/>
</div>
))}
</div>
);
}Placeholder de esqueleto shimmer CSS:
// components/image-skeleton.tsx
export function ImageSkeleton({ aspectRatio = "3/2" }: { aspectRatio?: string }) {
return (
<div
className="relative overflow-hidden rounded-xl bg-gray-200"
style={{ aspectRatio }}
>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</div>
);
}/* Añade a tu CSS global o configuración de tailwind */
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}// tailwind.config.ts - alternativa: define en la configuración de Tailwind
// animation: { shimmer: "shimmer 2s infinite" }
// keyframes: { shimmer: { "100%": { transform: "translateX(100%)" } } }Inmersión Profunda
Cómo Funciona
- BlurHash codifica datos de color de imagen en una cadena corta (típicamente 20-30 caracteres). La cadena se decodifica en el cliente en una imagen de vista previa borrosa. La codificación se realiza en tiempo de compilación o en el servidor.
- blurDataURL es una imagen minúscula codificada en base64 (usualmente 8x8 o 10x10 píxeles) que
next/imagemuestra como fondo CSS mientras se carga la imagen completa. Los imports estáticos generan esto automáticamente. - LQIP (Placeholder de Imagen de Baja Calidad) sirve una versión muy pequeña de la imagen real (alrededor de 20px de ancho) y la escala con desenfoque CSS. Esto es lo que implementa la técnica blur-up.
- Esqueletos shimmer utilizan una animación CSS para mostrar un gradiente pulsante, indicando que el contenido se está cargando sin datos de imagen.
- El componente
next/imagemaneja la transición de placeholder a imagen cargada automáticamente cuando se usaplaceholder="blur". Las transiciones personalizadas requieren el callbackonLoad.
Variaciones
Renderizado de canvas BlurHash (cliente):
"use client";
import { decode } from "blurhash";
import { useEffect, useRef } from "react";
interface BlurHashCanvasProps {
hash: string;
width: number;
height: number;
className?: string;
}
export function BlurHashCanvas({ hash, width, height, className }: BlurHashCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const pixels = decode(hash, width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return;
const imageData = ctx.createImageData(width, height);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}, [hash, width, height]);
return <canvas ref={canvasRef} width={width} height={height} className={className} />;
}Import estático con desenfoque automático (enfoque más simple):
import Image from "next/image";
import heroImage from "@/public/images/hero.jpg";
// blurDataURL se genera automáticamente en tiempo de compilación
export function Hero() {
return (
<Image
src={heroImage}
alt="Hero"
placeholder="blur"
priority
className="h-[60vh] w-full object-cover"
/>
);
}Carga progresiva con múltiples tamaños:
"use client";
import { useState } from "react";
interface ProgressiveImageProps {
tinyUrl: string;
fullUrl: string;
alt: string;
}
export function ProgressiveImage({ tinyUrl, fullUrl, alt }: ProgressiveImageProps) {
const [loaded, setLoaded] = useState(false);
return (
<div className="relative overflow-hidden">
{/* Versión diminuta borrosa - siempre visible inicialmente */}
<img
src={tinyUrl}
alt=""
aria-hidden
className={`absolute inset-0 h-full w-full scale-110 object-cover blur-xl transition-opacity duration-500 ${
loaded ? "opacity-0" : "opacity-100"
}`}
/>
{/* Imagen de resolución completa */}
<img
src={fullUrl}
alt={alt}
onLoad={() => setLoaded(true)}
className={`relative h-full w-full object-cover transition-opacity duration-500 ${
loaded ? "opacity-100" : "opacity-0"
}`}
/>
</div>
);
}Notas de TypeScript
- La librería
blurhashexportaencode(lado del servidor, necesita datos de píxeles) ydecode(lado del cliente, devuelve matriz de píxeles). sharpes una librería solo de Node.js. Úsala solo en Server Components, rutas API, o scripts de compilación.- El
onLoaddenext/imageestá tipado comoReact.ReactEventHandler<HTMLImageElement>.
Trampa
sharpno puede ejecutarse en el navegador o en Edge Runtime. Úsalo solo en Server Components de Node.js o scripts de tiempo de compilación.- La codificación de BlurHash en tiempo de solicitud añade latencia. Genera hashes de desenfoque en tiempo de compilación o cáchalos en una base de datos junto a los metadatos de la imagen.
- Las imágenes
blurDataURLmuy pequeñas (menos de 4x4 píxeles) pueden producir bandas visibles en lugar de un efecto de desenfoque suave. Utiliza 8x8 o 10x10 para mejores resultados. - El prop
placeholder="blur"ennext/imagecon imágenes remotas requiere unblurDataURL. Sin él, Next.js lanza un error. - El filtro CSS
blur()en imágenes grandes puede causar problemas de rendimiento en dispositivos de baja potencia. Aplica el desenfoque solo a la imagen placeholder minúscula, nunca a la imagen de resolución completa. - Los esqueletos shimmer deben coincidir con las dimensiones y relación de aspecto del contenido final para evitar cambios de diseño.
- El evento
onLoadse dispara cuando la imagen se decodifica, que puede ser ligeramente después de que sea visible. Para transiciones pixel-perfectas, consideraonLoadingComplete(deprecado en Next.js 14+) o la API nativadecode().
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| BlurHash | Carga minúscula (30 caracteres), desenfoque suave | Requiere paso de codificación, decodificación del lado del cliente |
| blurDataURL (next/image) | Incorporado, automático para imports estáticos | Generación manual para imágenes remotas |
| Esqueleto shimmer CSS | Sin datos de imagen necesarios, CSS puro | Genérico, sin vista previa de color |
| LQIP (imagen minúscula) | Vista previa de imagen real, simple | Más grande que cadena BlurHash, solicitud adicional |
| Color dominante | Carga más pequeña (un valor hex) | Sin información espacial, solo color sólido |
| ThumbHash | Mejor calidad que BlurHash, incluye alpha | Más reciente, soporte de ecosistema limitado |
FAQs
¿Cuáles son las cuatro técnicas principales de placeholder cubiertas en esta página?
- BlurHash -- codifica datos de color en una cadena corta, decodificada en el cliente.
- blurDataURL -- una imagen minúscula codificada en base64 utilizada por
next/image. - LQIP -- una versión de baja calidad de la imagen real escalada con desenfoque CSS.
- Esqueleto shimmer -- una animación CSS con un gradiente pulsante, sin datos de imagen necesarios.
¿Cómo funciona placeholder="blur" de manera diferente para imágenes estáticas vs. remotas?
- Los imports estáticos generan automáticamente
blurDataURLen tiempo de compilación -- sin trabajo adicional necesario. - Las imágenes remotas requieren que proporciones un prop
blurDataURLmanualmente. - Sin
blurDataURL, usarplaceholder="blur"en una imagen remota lanza un error.
¿Qué tamaño debe tener la imagen minúscula para un blurDataURL efectivo?
- Usa 8x8 o 10x10 píxeles para mejores resultados.
- Las imágenes muy pequeñas (menos de 4x4) producen bandas visibles en lugar de un desenfoque suave.
- La cadena base64 sigue siendo minúscula (menos de 1KB).
Trampa: ¿Dónde puede ejecutarse sharp, y dónde fallará?
sharpes una librería solo de Node.js.- Funciona en Server Components, rutas API, y scripts de tiempo de compilación.
- No puede ejecutarse en el navegador o en Edge Runtime.
¿Cómo funciona el efecto de carga blur-up en el ejemplo de la galería?
className={`object-cover transition-all duration-700 ${
loadedImages.has(image.src) ? "scale-100 blur-0" : "scale-110 blur-lg"
}`}La imagen comienza escalada y borrosa, luego transiciona a lo normal al cargar a través del callback onLoad.
¿Cuándo debes generar valores BlurHash -- en tiempo de solicitud o en tiempo de compilación?
- Genera en tiempo de compilación o cachea en una base de datos junto a los metadatos de la imagen.
- Codificar en tiempo de solicitud añade latencia a cada carga de página.
¿Cómo creas un placeholder de esqueleto shimmer CSS?
<div className="relative overflow-hidden rounded-xl bg-gray-200" style={{ aspectRatio: "3/2" }}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite]
bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</div>Trampa: ¿Puede CSS blur() en imágenes grandes causar problemas de rendimiento?
- Sí, en dispositivos de baja potencia puede causar jank.
- Aplica desenfoque solo a la imagen placeholder minúscula, nunca a la imagen de resolución completa.
¿Cuáles son los tipos de TypeScript relevantes para las librerías BlurHash y sharp?
blurhashexportaencode(lado del servidor, necesita datos de píxeles) ydecode(lado del cliente, devuelveUint8ClampedArray).sharpes solo de Node.js y devuelve objetosBuffery metadatos tipados.- El
onLoaddenext/imageestá tipado comoReact.ReactEventHandler<HTMLImageElement>.
¿Cómo difiere la técnica de carga progresiva de BlurHash?
- La carga progresiva utiliza una versión diminuta real de la imagen (p. ej., 20px de ancho) escalada con desenfoque CSS.
- BlurHash utiliza una cadena compacta (20-30 caracteres) que debe decodificarse en datos de píxeles.
- La carga progresiva es más simple pero requiere una solicitud de imagen adicional; BlurHash es más pequeño pero necesita un paso de decodificación.
¿Por qué los esqueletos shimmer deben coincidir con las dimensiones del contenido final?
- Las dimensiones no coincidentes causan cambios de diseño (CLS) cuando se carga el contenido real.
- Siempre establece la misma relación de aspecto en el esqueleto que en la imagen final.