Ir al contenido
Evaluar mi proyecto
Componentes · Progreso y cargaDisponible

Esqueletos con destello

Tres plantillas (tarjeta, lista y perfil) con el mismo tamaño que el contenido final para evitar saltos de diseño.

Cargando contenido…

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

esqueleto-shimmer.tsx116 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/esqueleto-shimmer

Ejemplo de uso de Esqueletos con destello

Después de copiar esqueleto-shimmer.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
variant?"tarjeta" | "lista" | "perfil"Opcional.
loading?booleanOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)