"use client";
import * as React from "react";
export type SkeletonPreset = "auto" | "text" | "avatar" | "card" | "table";
export interface SkeletonLoaderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** While true, placeholders are shown and the children are hidden (but keep their space). */
loading: boolean;
/** The real content. In "auto" mode it is rendered invisibly while loading and its boxes are mirrored. */
children?: React.ReactNode;
/** Placeholder layout: mirror the children ("auto") or a fixed shape. */
preset?: SkeletonPreset;
/** Text lines for the "text" and "card" presets. */
lines?: number;
/** Rows for the "table" preset. */
rows?: number;
/** Highlight sweep, opacity pulse, or static placeholders. */
animation?: "wave" | "pulse" | "none";
/** Direction of the wave. */
direction?: "ltr" | "rtl";
/** Duration of one sweep / pulse in ms. */
speed?: number;
/** Corner radius of rectangular placeholders in px. */
radius?: number;
/** Crossfade from placeholders to content in ms (0 = instant). */
fadeDuration?: number;
/** Text announced to screen readers while loading. */
label?: string;
}
interface Shape {
x: number;
y: number;
w: number;
h: number;
/** Corner radius in px, or "circle". */
r: number | "circle";
}
/** Elements mirrored as one solid block instead of being walked for text. */
const BLOCK_TAGS = new Set(["img", "svg", "video", "canvas", "picture", "iframe", "input", "textarea", "select", "button", "progress", "meter"]);
const MAX_SHAPES = 200;
/**
* Measures the visible boxes of `root`: one bar per line box of every text node (via
* Range.getClientRects) and one block per replaced element, relative to `root`.
* Mark an element with data-skeleton="block" to mirror it whole, or "ignore" to skip it.
*/
function measureShapes(root: HTMLElement): Shape[] {
const base = root.getBoundingClientRect();
const shapes: Shape[] = [];
const range = document.createRange();
const walk = (node: Node) => {
if (shapes.length >= MAX_SHAPES) return;
if (node.nodeType === Node.TEXT_NODE) {
if (!node.textContent?.trim()) return;
range.selectNodeContents(node);
for (const r of range.getClientRects()) {
if (r.width < 1 || r.height < 1) continue;
// A bar about the height of the x-height band reads as text, not as a box.
const h = Math.max(6, Math.round(r.height * 0.62));
shapes.push({ x: r.left - base.left, y: r.top - base.top + (r.height - h) / 2, w: r.width, h, r: Math.min(h / 2, 4) });
}
return;
}
if (!(node instanceof Element)) return;
const hint = node instanceof HTMLElement || node instanceof SVGElement ? node.dataset.skeleton : undefined;
if (hint === "ignore") return;
if (hint === "block" || BLOCK_TAGS.has(node.tagName.toLowerCase())) {
const r = node.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return;
const raw = getComputedStyle(node).borderTopLeftRadius;
const radius = raw.endsWith("%") ? (parseFloat(raw) / 100) * Math.min(r.width, r.height) : parseFloat(raw) || 0;
const circle = Math.abs(r.width - r.height) < 1 && radius >= r.width / 2 - 0.5;
shapes.push({ x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height, r: circle ? "circle" : radius });
return;
}
node.childNodes.forEach(walk);
};
root.childNodes.forEach(walk);
return shapes;
}
/**
* Content-aware skeleton: wraps real content and, while `loading`, shows shimmering placeholders —
* either fixed presets or (in "auto" mode) a mirror of the children's own text lines and images,
* measured after mount. Placeholders and content share one grid cell, so the swap reserves space
* and crossfades without layout shift. One synchronised wave runs per instance via the Web
* Animations API and pauses offscreen, in hidden tabs and with reduced motion.
*/
export function SkeletonLoader({
loading,
children,
preset = "auto",
lines = 3,
rows = 4,
animation = "wave",
direction = "ltr",
speed = 1600,
radius = 6,
fadeDuration = 300,
label = "Loading",
className = "",
style,
...props
}: SkeletonLoaderProps) {
const contentRef = React.useRef<HTMLDivElement>(null);
const overlayRef = React.useRef<HTMLDivElement>(null);
const [shapes, setShapes] = React.useState<Shape[]>([]);
const auto = preset === "auto";
// Keep the placeholders mounted while they fade out after loading finishes.
const [fading, setFading] = React.useState(false);
const [wasLoading, setWasLoading] = React.useState(loading);
if (wasLoading !== loading) {
setWasLoading(loading);
setFading(!loading && fadeDuration > 0);
}
const showSkeleton = loading || fading;
React.useEffect(() => {
if (!fading) return;
const timer = window.setTimeout(() => setFading(false), fadeDuration + 40);
return () => window.clearTimeout(timer);
}, [fading, fadeDuration]);
// Auto mode: measure the hidden children whenever they resize while loading.
React.useEffect(() => {
const el = contentRef.current;
if (!auto || !loading || !el) return;
const ro = new ResizeObserver(() => setShapes(measureShapes(el)));
ro.observe(el);
return () => ro.disconnect();
}, [auto, loading]);
// One wave across the whole skeleton: every shape's gradient is offset by its own position so
// the highlight lines up between shapes. Paused offscreen, in hidden tabs and for reduced motion.
React.useEffect(() => {
const overlay = overlayRef.current;
if (!overlay || !showSkeleton || animation === "none") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const box = overlay.getBoundingClientRect();
const width = Math.max(box.width, 1);
const animations: Animation[] = [];
if (animation === "pulse") {
const inner = overlay.firstElementChild;
if (inner) {
animations.push(
inner.animate([{ opacity: 1 }, { opacity: 0.45 }, { opacity: 1 }], { duration: speed, iterations: Infinity, easing: "ease-in-out" }),
);
}
} else {
overlay.style.setProperty("--sk-w", `${width}px`);
overlay.querySelectorAll<HTMLElement>("[data-sk-shape]").forEach((shape) => {
const left = shape.getBoundingClientRect().left - box.left;
const from = `${-width - left}px 0`;
const to = `${width - left}px 0`;
animations.push(
shape.animate([{ backgroundPosition: direction === "rtl" ? to : from }, { backgroundPosition: direction === "rtl" ? from : to }], {
duration: speed,
iterations: Infinity,
easing: "ease-in-out",
}),
);
});
}
let inView = true;
const sync = () => {
const run = inView && !document.hidden;
for (const a of animations) {
if (run) a.play();
else a.pause();
}
};
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
sync();
});
io.observe(overlay);
document.addEventListener("visibilitychange", sync);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", sync);
for (const a of animations) a.cancel();
};
}, [showSkeleton, animation, direction, speed, shapes, preset, lines, rows]);
const fade = `opacity ${fadeDuration}ms ease`;
const wave = animation === "wave";
const shapeClass = `bg-zinc-900/[0.07] bg-no-repeat dark:bg-white/[0.07] ${
wave ? "bg-[linear-gradient(90deg,transparent,var(--sk-hi),transparent)]" : ""
}`;
const shapeStyle = (extra?: React.CSSProperties): React.CSSProperties => ({
backgroundSize: "var(--sk-w, 100%) 100%",
backgroundPosition: "-200vw 0",
borderRadius: radius,
...extra,
});
const bar = (width: string, height = 12, key?: React.Key) => (
<div key={key} data-sk-shape="" className={shapeClass} style={shapeStyle({ width, height, borderRadius: Math.min(radius, height / 2) })} />
);
const circle = (size: number) => (
<div data-sk-shape="" className={`shrink-0 ${shapeClass}`} style={shapeStyle({ width: size, height: size, borderRadius: 9999 })} />
);
const textLines = (count: number) =>
Array.from({ length: Math.max(1, count) }, (_, i) =>
bar(i === count - 1 && count > 1 ? "62%" : `${[100, 94, 97, 90][i % 4]}%`, 12, i),
);
let presetShapes: React.ReactNode = null;
if (!auto) {
if (preset === "text") presetShapes = <div className="flex flex-col gap-2.5">{textLines(lines)}</div>;
else if (preset === "avatar")
presetShapes = (
<div className="flex items-center gap-3">
{circle(44)}
<div className="flex flex-1 flex-col gap-2">
{bar("42%", 13)}
{bar("68%", 10)}
</div>
</div>
);
else if (preset === "card")
presetShapes = (
<div className="flex flex-col gap-3">
<div data-sk-shape="" className={`aspect-[16/9] w-full ${shapeClass}`} style={shapeStyle()} />
{bar("58%", 16)}
<div className="flex flex-col gap-2">{textLines(lines)}</div>
</div>
);
else
presetShapes = (
<div className="flex flex-col">
{Array.from({ length: Math.max(1, rows) }, (_, i) => (
<div key={i} className="flex items-center gap-3 border-b border-zinc-900/[0.06] py-3 last:border-b-0 dark:border-white/[0.06]">
{circle(30)}
{bar("34%")}
<div className="flex-1" />
{bar("18%")}
{bar("12%")}
</div>
))}
</div>
);
}
return (
<div
className={`relative grid [&>*]:[grid-area:1/1] ${className}`}
aria-busy={loading}
style={{ ...style }}
{...props}
>
<span role="status" className="sr-only">
{loading ? label : ""}
</span>
{/* Real content: always laid out so its size is reserved, invisible and inert while loading. */}
<div
ref={contentRef}
inert={loading || undefined}
className="min-w-0 motion-reduce:transition-none!"
style={{ visibility: loading ? "hidden" : "visible", opacity: loading ? 0 : 1, transition: loading ? "none" : fade }}
>
{children}
</div>
{showSkeleton && (
<div
ref={overlayRef}
aria-hidden="true"
className={`pointer-events-none min-w-0 [--sk-hi:rgb(255_255_255/0.75)] motion-reduce:transition-none! dark:[--sk-hi:rgb(255_255_255/0.07)] ${auto ? "relative" : ""}`}
style={{ opacity: loading ? 1 : 0, transition: fade }}
>
{auto ? (
<div className="absolute inset-0">
{shapes.map((s, i) => (
<div
key={i}
data-sk-shape=""
className={`absolute ${shapeClass}`}
style={shapeStyle({ left: s.x, top: s.y, width: s.w, height: s.h, borderRadius: s.r === "circle" ? 9999 : Math.min(s.r || radius, radius * 2) })}
/>
))}
</div>
) : (
<div>{presetShapes}</div>
)}
</div>
)}
</div>
);
}