Shimmering skeleton primitive with a page-wide shared sweep phase and text, avatar-row, and card presets sized to real line boxes for zero-shift swaps.
npx shadcn@latest add @paragon/skeleton"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* One shared sweep period for every Paragon skeleton on the page. Each
* sweep's phase is anchored to the document timeline (performance.now()
* modulo the period) rather than to its own mount time, so skeletons mounted
* at different moments — even across separate trees — always shimmer in the
* same phase instead of drifting.
*/
const SWEEP_MS = 1800;
const useIsoLayoutEffect =
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
/* One IntersectionObserver shared by every skeleton on the page. */
const inViewCallbacks = new Map<Element, (inView: boolean) => void>();
let sharedObserver: IntersectionObserver | null = null;
function observeInView(el: Element, callback: (inView: boolean) => void) {
if (typeof IntersectionObserver === "undefined") return () => {};
sharedObserver ??= new IntersectionObserver((entries) => {
for (const entry of entries) {
inViewCallbacks.get(entry.target)?.(entry.isIntersecting);
}
});
inViewCallbacks.set(el, callback);
sharedObserver.observe(el);
return () => {
inViewCallbacks.delete(el);
sharedObserver?.unobserve(el);
};
}
/** Pauses the shimmer offscreen (IntersectionObserver) and in hidden tabs. */
function usePlayState() {
const ref = React.useRef<HTMLDivElement>(null);
const [playing, setPlaying] = React.useState(true);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
let inView = true;
let visible = !document.hidden;
const update = () => setPlaying(inView && visible);
const unobserve = observeInView(el, (next) => {
inView = next;
update();
});
const onVisibility = () => {
visible = !document.hidden;
update();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
unobserve();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
return { ref, playing };
}
export type SkeletonProps = React.ComponentProps<"div">;
/**
* Skeleton primitive: a muted block with a masked gradient shimmer sweeping
* across it. Every instance shares one page-wide phase origin so composed
* skeletons sweep in sync; the sweep pauses offscreen and in hidden tabs, and
* reduced motion swaps it for a gentle opacity pulse.
*/
const SKELETON_KEYFRAMES = `
@keyframes pg-skeleton-sweep {
0% { transform: translateX(-100%); }
62%, 100% { transform: translateX(100%); }
}
@keyframes pg-skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@media (prefers-reduced-motion: reduce) {
[data-skeleton] {
animation: pg-skeleton-pulse 2.4s var(--ease-in-out) infinite;
}
[data-skeleton] > [data-skeleton-sweep] { display: none; }
}
`;
export function Skeleton({ className, style, ...props }: SkeletonProps) {
const { ref, playing } = usePlayState();
const sweepRef = React.useRef<HTMLSpanElement>(null);
// Anchor this sweep to the shared document-timeline phase origin, before
// paint, so late-mounted skeletons join the page-wide wave mid-cycle.
useIsoLayoutEffect(() => {
const sweep = sweepRef.current;
if (sweep) {
sweep.style.animationDelay = `-${Math.round(performance.now() % SWEEP_MS)}ms`;
}
}, []);
return (
<div
ref={ref}
aria-hidden
data-skeleton=""
className={cn("relative overflow-hidden rounded-md bg-muted", className)}
style={style}
{...props}
>
<style href="paragon-skeleton" precedence="paragon">
{SKELETON_KEYFRAMES}
</style>
<span
ref={sweepRef}
data-skeleton-sweep=""
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-foreground/[0.055] to-transparent dark:via-foreground/[0.04]"
style={{
animation: `pg-skeleton-sweep ${SWEEP_MS}ms linear infinite`,
animationPlayState: playing ? "running" : "paused",
}}
/>
</div>
);
}
export interface SkeletonTextProps extends React.ComponentProps<"div"> {
/** Number of text lines. The last line renders shorter. */
lines?: number;
}
/**
* Stacked text-line placeholders. Each bar sits centered in a 20px line box —
* the line height of `text-sm` — so swapping in real prose produces zero
* layout shift; the final line runs short, like prose.
*/
export function SkeletonText({
lines = 3,
className,
...props
}: SkeletonTextProps) {
return (
<div role="status" className={cn("w-full", className)} {...props}>
<span className="sr-only">Loading</span>
{Array.from({ length: lines }, (_, i) => (
<div key={i} aria-hidden className="flex h-5 items-center">
<Skeleton
className="h-3"
style={{ width: i === lines - 1 ? "60%" : "100%" }}
/>
</div>
))}
</div>
);
}
/**
* Avatar circle beside two lines — a person or account row loading. The text
* stack matches a `text-sm` name over a `text-xs` meta line (20px + 16px
* boxes), so real rows swap in without shifting.
*/
export function SkeletonAvatar({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
role="status"
className={cn("flex w-full items-center gap-3", className)}
{...props}
>
<span className="sr-only">Loading</span>
<Skeleton className="size-10 shrink-0 rounded-full" />
<div aria-hidden className="min-w-0 flex-1">
<div className="flex h-5 items-center">
<Skeleton className="h-3.5 w-2/5" />
</div>
<div className="flex h-4 items-center">
<Skeleton className="h-3 w-3/5" />
</div>
</div>
</div>
);
}
/**
* Card placeholder: media block, then a `text-sm` title line box and two
* body line boxes, mirroring a real card's metrics exactly.
*/
export function SkeletonCard({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
role="status"
className={cn("w-full rounded-xl bg-card p-4 shadow-border", className)}
{...props}
>
<span className="sr-only">Loading</span>
<div aria-hidden>
<Skeleton className="h-32 w-full rounded-lg" />
<div className="mt-4 flex h-5 items-center">
<Skeleton className="h-3.5 w-1/2" />
</div>
<div className="mt-1">
<div className="flex h-5 items-center">
<Skeleton className="h-3 w-full" />
</div>
<div className="flex h-5 items-center">
<Skeleton className="h-3 w-4/5" />
</div>
</div>
</div>
</div>
);
}