Esqueletos con destello
Tres plantillas (tarjeta, lista y perfil) con el mismo tamaño que el contenido final para evitar saltos de diseño.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useState, type CSSProperties } from "react";
/** Esqueletos de carga con destello: tres plantillas (tarjeta, lista y perfil) que ocupan el mismo espacio que el contenido final para evitar saltos de diseño. Con alternador para ver el cambio. React + TypeScript, sin dependencias. */
export type EsqueletoShimmerProps = {
variant?: "tarjeta" | "lista" | "perfil";
loading?: boolean;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcesh-root { box-sizing: border-box; width: 100%; max-width: 360px; padding: 16px; border: 1px solid #e7e8ee; border-radius: 16px; background: #fff; color: #1f2430; font: 400 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcesh-root *, .gcesh-root *::before, .gcesh-root *::after { box-sizing: border-box; }
.gcesh-root button { font: inherit; }
.gcesh-root :focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcesh-sk { animation: none !important; } }
.gcesh-sk { position: relative; overflow: hidden; border-radius: 8px; background: #eceef4; }
.gcesh-sk::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .85), transparent); animation: gcesh-sweep 1.5s ease-in-out infinite; }
.gcesh-hero { height: 150px; border-radius: 12px; margin-bottom: 14px; }
.gcesh-line { height: 14px; margin-bottom: 10px; }
.gcesh-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.gcesh-circle { flex: none; border-radius: 50%; }
.gcesh-col { flex: 1; min-width: 0; }
.gcesh-real-img { height: 150px; margin-bottom: 14px; border-radius: 12px; background: linear-gradient(135deg, #2f5bff, #8a6dff); }
.gcesh-real h4 { margin: 0 0 6px; font-size: 17px; }
.gcesh-real p { margin: 0; color: #5b6477; }
.gcesh-avatar { flex: none; display: grid; place-items: center; border-radius: 50%; background: #2f5bff; color: #fff; font-weight: 700; }
.gcesh-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid #eef0f5; font-size: 12px; color: #6b7388; }
.gcesh-toggle { min-height: 32px; padding: 0 12px; border: 1px solid #d9dce6; border-radius: 8px; background: #fff; color: inherit; cursor: pointer; }
.gcesh-toggle:hover { background: #f5f6fa; }
@keyframes gcesh-sweep { to { transform: translateX(100%); } }`;
const Sk = ({ w = "100%", h = 14, r }: { w?: string | number; h?: number; r?: string }) => <div className="gcesh-sk" style={{ width: w, height: h, borderRadius: r, marginBottom: 10 }} aria-hidden="true" />;
export function EsqueletoShimmer({ variant = "tarjeta", loading, className = "", style }: EsqueletoShimmerProps) {
const [inner, setInner] = useState(true);
const isLoading = loading ?? inner;
return (
<div className={`gcesh-root ${className}`} style={style} aria-busy={isLoading}>
<style>{CSS}</style>
{variant === "tarjeta" ? (
isLoading ? (
<>
<div className="gcesh-sk gcesh-hero" aria-hidden="true" />
<Sk w="70%" h={18} />
<Sk />
<Sk w="85%" />
</>
) : (
<div className="gcesh-real">
<div className="gcesh-real-img" />
<h4>Cotización de proyecto</h4>
<p>Alcance, tiempos y precio en un solo documento claro.</p>
</div>
)
) : null}
{variant === "lista"
? [0, 1, 2, 3].map((i) => (
<div key={i} className="gcesh-row">
{isLoading ? (
<>
<div className="gcesh-sk gcesh-circle" style={{ width: 40, height: 40 }} aria-hidden="true" />
<div className="gcesh-col">
<Sk w="60%" h={14} />
<Sk w="90%" h={12} />
</div>
</>
) : (
<>
<span className="gcesh-avatar" style={{ width: 40, height: 40 }}>{"ABCD"[i]}</span>
<div className="gcesh-col">
<strong>{["Ana", "Beto", "Carla", "Dani"][i]} Gómez</strong>
<div style={{ color: "#5b6477", fontSize: 13 }}>Ha comentado en tu documento</div>
</div>
</>
)}
</div>
))
: null}
{variant === "perfil" ? (
isLoading ? (
<div style={{ display: "grid", justifyItems: "center", gap: 10 }}>
<div className="gcesh-sk gcesh-circle" style={{ width: 84, height: 84 }} aria-hidden="true" />
<Sk w="50%" h={18} />
<Sk w="70%" h={12} />
<div style={{ display: "flex", gap: 10, width: "100%" }}>
<div className="gcesh-sk" style={{ flex: 1, height: 44 }} aria-hidden="true" />
<div className="gcesh-sk" style={{ flex: 1, height: 44 }} aria-hidden="true" />
</div>
</div>
) : (
<div className="gcesh-real" style={{ display: "grid", justifyItems: "center", gap: 6, textAlign: "center" }}>
<span className="gcesh-avatar" style={{ width: 84, height: 84, fontSize: 28 }}>LG</span>
<h4 style={{ margin: "6px 0 0" }}>Luis Gassier</h4>
<p>Desarrollo de software a medida</p>
<div style={{ display: "flex", gap: 10, width: "100%", marginTop: 8 }}>
<button className="gcesh-toggle" style={{ flex: 1, minHeight: 44 }} type="button">Seguir</button>
<button className="gcesh-toggle" style={{ flex: 1, minHeight: 44 }} type="button">Mensaje</button>
</div>
</div>
)
) : null}
{loading === undefined ? (
<div className="gcesh-bar">
<span>{isLoading ? "Cargando contenido…" : "Contenido cargado"}</span>
<button className="gcesh-toggle" type="button" onClick={() => setInner((value) => !value)}>
{isLoading ? "Mostrar contenido" : "Ver esqueleto"}
</button>
</div>
) : null}
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/esqueleto-shimmerEjemplo de uso de Esqueletos con destello
Después de copiar esqueleto-shimmer.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { EsqueletoShimmer } from "./esqueleto-shimmer";
export default function Ejemplo() {
return <EsqueletoShimmer variant="tarjeta" loading />;
}Propiedades de EsqueletoShimmer
EsqueletoShimmer acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
variant? | "tarjeta" | "lista" | "perfil" | Opcional. |
loading? | boolean | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Variantes disponibles
variant:tarjeta,lista,perfil
Accesibilidad y rendimiento
El archivo tiene 116 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Esqueletos con destello por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Es un componente de cliente de Next.js: incluye la directiva "use client".