Esqueleto
Una forma de carga de marcador que imita el diseño del contenido antes de que se cargue, reduciendo el tiempo de carga percibido e impidiendo cambios de diseño.
Casos de Uso
- Mostrar líneas de texto de marcador mientras se busca contenido del artículo
- Mostrar marcadores circulares para imágenes de avatar durante la carga
- Diseños de tarjeta de vista previa en paneles antes de que lleguen los datos
- Llenar filas de tabla mientras una consulta de datos está en progreso
- Marcador para páginas de perfil con tipos de contenido mixtos
- Mostrar estado de carga para galerías de imágenes y cuadrículas de medios
- Reemplazar secciones de página completas durante la búsqueda inicial de datos del lado del servidor
Implementación más Simple
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
/>
);
}
// Uso
<Skeleton className="h-4 w-48" />Un único div con el animate-pulse integrado de Tailwind y un fondo neutro. El consumidor controla la forma y el tamaño enteramente a través de className. El atributo aria-hidden oculta el marcador de los lectores de pantalla ya que no tiene contenido significativo.
Variaciones
Líneas de Texto
interface SkeletonTextProps {
lines?: number;
}
export function SkeletonText({ lines = 3 }: SkeletonTextProps) {
return (
<div className="space-y-3" aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={`h-4 animate-pulse rounded-md bg-gray-200 ${
i === lines - 1 ? "w-2/3" : "w-full"
}`}
/>
))}
</div>
);
}Genera varias líneas de texto de marcador. La última línea es más corta (w-2/3) para imitar cómo los párrafos reales generalmente terminan a mitad de línea, lo que hace que el esqueleto se sienta más natural.
Círculo (Esqueleto de Avatar)
type AvatarSize = "sm" | "md" | "lg";
interface SkeletonAvatarProps {
size?: AvatarSize;
}
const sizeClasses: Record<AvatarSize, string> = {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-14 w-14",
};
export function SkeletonAvatar({ size = "md" }: SkeletonAvatarProps) {
return (
<div
className={`animate-pulse rounded-full bg-gray-200 ${sizeClasses[size]}`}
aria-hidden="true"
/>
);
}Un esqueleto circular que coincide con las dimensiones comunes del avatar. Usar los mismos valores de tamaño que el componente Avatar real previene cambios de diseño cuando el contenido se carga.
Esqueleto de Tarjeta
export function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-200 p-4" aria-hidden="true">
<div className="h-40 animate-pulse rounded-lg bg-gray-200" />
<div className="mt-4 space-y-3">
<div className="h-5 w-3/4 animate-pulse rounded-md bg-gray-200" />
<div className="h-4 w-full animate-pulse rounded-md bg-gray-200" />
<div className="h-4 w-5/6 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="mt-4 flex items-center gap-3">
<div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
<div className="h-4 w-24 animate-pulse rounded-md bg-gray-200" />
</div>
</div>
);
}Refleja un diseño de tarjeta típico con imagen, título, descripción y autor. El borde y el relleno coinciden con la tarjeta real para que el esqueleto ocupe exactamente el mismo espacio, eliminando cambios de diseño en la carga.
Esqueleto de Fila de Tabla
interface SkeletonTableProps {
rows?: number;
columns?: number;
}
export function SkeletonTable({ rows = 5, columns = 4 }: SkeletonTableProps) {
return (
<div className="w-full" aria-hidden="true">
<div className="flex gap-4 border-b border-gray-200 pb-3">
{Array.from({ length: columns }).map((_, i) => (
<div key={i} className="h-4 flex-1 animate-pulse rounded-md bg-gray-300" />
))}
</div>
{Array.from({ length: rows }).map((_, row) => (
<div key={row} className="flex gap-4 border-b border-gray-100 py-3">
{Array.from({ length: columns }).map((_, col) => (
<div key={col} className="h-4 flex-1 animate-pulse rounded-md bg-gray-200" />
))}
</div>
))}
</div>
);
}La fila de encabezado usa un matiz ligeramente más oscuro (bg-gray-300) para diferenciarse de las filas de datos. Las columnas flexibles con flex-1 distribuyen el ancho uniformemente para coincidir con un diseño de tabla típico.
Con Animación de Shimmer
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`relative overflow-hidden rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</div>
);
}Agrega esto a tu tailwind.config.ts para registrar el fotograma clave de shimmer:
// tailwind.config.ts
export default {
theme: {
extend: {
keyframes: {
shimmer: {
"100%": { transform: "translateX(100%)" },
},
},
},
},
};Un efecto de shimmer que barre un degradado claro en el marcador. Esto se siente más pulido que el pulso predeterminado y da a los usuarios una sensación más fuerte de que el contenido se está cargando. El overflow-hidden evita que el degradado se derrame fuera de las esquinas redondeadas.
Composición de Formas Personalizadas
interface SkeletonProps {
className?: string;
}
function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
/>
);
}
export function ProfileSkeleton() {
return (
<div className="flex items-start gap-4" aria-hidden="true">
{/* Avatar */}
<Skeleton className="h-16 w-16 shrink-0 rounded-full" />
{/* Info */}
<div className="flex-1 space-y-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
<div className="flex gap-4 pt-1">
<Skeleton className="h-8 w-24 rounded-lg" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
</div>
</div>
);
}Compone el primitivo base Skeleton en un diseño específico del perfil. Construir esqueletos específicos del dominio (ProfileSkeleton, CommentSkeleton, etc.) desde un primitivo único mantiene la base de código SECA mientras asegura que cada esqueleto coincida exactamente con su contraparte real.
Implementación Compleja
"use client";
import { useMemo } from "react";
type SkeletonVariant = "text" | "circular" | "rectangular" | "rounded";
interface SkeletonProps {
variant?: SkeletonVariant;
width?: string | number;
height?: string | number;
lines?: number;
animation?: "pulse" | "shimmer" | "none";
className?: string;
children?: React.ReactNode;
}
const variantClasses: Record<SkeletonVariant, string> = {
text: "rounded-md",
circular: "rounded-full",
rectangular: "rounded-none",
rounded: "rounded-xl",
};
function ShimmerOverlay() {
return (
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/60 to-transparent" />
);
}
export function Skeleton({
variant = "text",
width,
height,
lines,
animation = "pulse",
className,
children,
}: SkeletonProps) {
const style = useMemo(() => {
const s: React.CSSProperties = {};
if (width) s.width = typeof width === "number" ? `${width}px` : width;
if (height) s.height = typeof height === "number" ? `${height}px` : height;
return s;
}, [width, height]);
const animClass = animation === "pulse" ? "animate-pulse" : "";
const hasShimmer = animation === "shimmer";
// Esqueleto de texto de varias líneas
if (lines && lines > 1) {
return (
<div className="space-y-3" aria-hidden="true" role="status">
<span className="sr-only">Cargando...</span>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={[
"bg-gray-200",
variantClasses.text,
animClass,
hasShimmer ? "relative overflow-hidden" : "",
i === lines - 1 ? "w-2/3" : "w-full",
]
.filter(Boolean)
.join(" ")}
style={{ height: typeof height === "number" ? height : 16 }}
>
{hasShimmer && <ShimmerOverlay />}
</div>
))}
</div>
);
}
// Esqueleto envolviendo contenido real (modo de superposición)
if (children) {
return (
<div className="relative inline-flex" aria-hidden="true">
<div className="invisible">{children}</div>
<div
className={[
"absolute inset-0 bg-gray-200",
variantClasses[variant],
animClass,
hasShimmer ? "overflow-hidden" : "",
]
.filter(Boolean)
.join(" ")}
>
{hasShimmer && <ShimmerOverlay />}
</div>
</div>
);
}
// Bloque de esqueleto único
return (
<div aria-hidden="true" role="status">
<span className="sr-only">Cargando...</span>
<div
className={[
"bg-gray-200",
variantClasses[variant],
animClass,
hasShimmer ? "relative overflow-hidden" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
style={style}
>
{hasShimmer && <ShimmerOverlay />}
</div>
</div>
);
}
// --- Composiciones Preestablecidas ---
export function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-200 p-4" role="status" aria-label="Cargando tarjeta">
<Skeleton variant="rounded" height={160} className="w-full" />
<div className="mt-4 space-y-3">
<Skeleton width="75%" height={20} />
<Skeleton height={16} />
<Skeleton width="85%" height={16} />
</div>
<div className="mt-4 flex items-center gap-3">
<Skeleton variant="circular" width={32} height={32} />
<Skeleton width={96} height={16} />
</div>
</div>
);
}
export function SkeletonTable({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<div className="w-full" role="status" aria-label="Cargando tabla">
<span className="sr-only">Cargando...</span>
<div className="flex gap-4 border-b border-gray-200 pb-3">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} height={16} className="flex-1 bg-gray-300" />
))}
</div>
{Array.from({ length: rows }).map((_, row) => (
<div key={row} className="flex gap-4 border-b border-gray-100 py-3">
{Array.from({ length: columns }).map((_, col) => (
<Skeleton key={col} height={16} className="flex-1" />
))}
</div>
))}
</div>
);
}Aspectos clave:
- Tres modos de animación --
pulseutiliza la animación integrada de Tailwind,shimmerañade un degradado barridor para una sensación premium, ynonedesactiva la animación para preferencias de movimiento reducido. - Modo de superposición con elementos secundarios -- pasar elementos secundarios los renderiza invisiblemente para preservar las dimensiones exactas del diseño, luego superpone el esqueleto en la parte superior. Esto asegura un ajuste de tamaño perfecto de píxeles.
- Soporte de texto de varias líneas -- la propiedad
linesgenera varias filas de esqueleto con la última línea más corta, coincidiendo con finales de párrafos naturales. - Estilos en línea para dimensiones dinámicas --
widthyheightaceptan tanto números (px) como cadenas (porcentajes, rem) a través de estilos en línea, evitando la necesidad de generar clases dinámicas de Tailwind. - Anuncio del lector de pantalla --
role="status"con texto "Cargando..." visualmente oculto le dice a los lectores de pantalla que el contenido se está cargando sin mostrar texto en la pantalla. - Composiciones preestablecidas --
SkeletonCardySkeletonTabledemuestran esqueletos reutilizables y específicos del dominio construidos desde el primitivo base, manteniendo la API tanto flexible como conveniente.
Trampas
-
Cambio de diseño cuando se carga el contenido -- Si las dimensiones del esqueleto no coinciden con el contenido real, la página salta cuando llegan los datos. Siempre mide y coincide la altura y el ancho del componente real.
-
Demasiados elementos animate-pulse -- Docenas de elementos pulsantes en una sola página pueden degradar el rendimiento en dispositivos de gama baja. Usa un único
animate-pulsepadre en el contenedor en lugar de elementos individuales. -
Fotograma clave de shimmer no registrado -- La animación de shimmer requiere un fotograma clave personalizado en
tailwind.config.ts. Sin él, el elemento de degradado permanece estaticamente traducido fuera de la pantalla. -
Falta aria-hidden o role -- Los esqueletos sin
aria-hidden="true"orole="status"saturan el árbol de accesibilidad. Los divs vacíos se anuncian como "group" por algunos lectores de pantalla, confundiendo a los usuarios. -
El esqueleto nunca desaparece -- Olvidar renderizar condicionalmente el esqueleto frente al contenido real significa que el marcador permanece visible para siempre. Siempre controla la renderización del esqueleto en un estado de carga:
{isLoading ? <Skeleton /> : <RealContent />}. -
Preferencias de movimiento reducido ignoradas -- Los usuarios con
prefers-reduced-motionhabilitado pueden encontrar las animaciones continuas distractoras. Respeta esto conmotion-safe:animate-pulseo desactiva la animación completamente a través de la propiedadanimation="none".