Prebuilt skeleton screens — table page, dashboard cards, settings form — sharing one shimmer phase origin, with line-box metrics that let real content crossfade in with zero shift.
npx shadcn@latest add @paragon/skeleton-page"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Sweep period shared by every Paragon skeleton. Phases anchor to the
* document timeline (performance.now() % SWEEP_MS) rather than to mount time,
* so all groups — and the standalone `skeleton` primitives, which use the
* same period — shimmer in one page-wide phase.
*/
const SWEEP_MS = 1800;
const useIsoLayoutEffect =
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
export interface SkeletonProps extends React.ComponentProps<"div"> {}
/**
* A single skeleton block. Inside a `SkeletonGroup` (or one of the prebuilt
* shells) it picks up the group's synchronized shimmer; on its own it renders
* as a static muted block.
*/
export function Skeleton({ className, ...props }: SkeletonProps) {
return (
<div
data-skeleton=""
className={cn("relative overflow-hidden rounded-md bg-muted", className)}
{...props}
/>
);
}
export interface SkeletonGroupProps extends React.ComponentProps<"div"> {
/** Accessible loading label for the whole region. */
label?: string;
}
/**
* Synchronization root for skeleton shimmer.
*
* One keyframe animation is declared per group and every `Skeleton` inside
* references it, all phase-anchored to the shared document-timeline origin —
* so every piece sweeps in the same phase and nothing drifts, even across
* groups mounted at different times. The sweep is an overlay translateX (no
* background-position animation), runs linear (constant motion), pauses when
* the group scrolls offscreen or the tab hides, and softens to a gentle
* opacity pulse under prefers-reduced-motion.
*/
const GROUP_STYLES = `
@keyframes pg-skeleton-page-sweep {
0% { transform: translateX(-100%); }
62%, 100% { transform: translateX(100%); }
}
@keyframes pg-skeleton-page-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
[data-skeleton-shell] [data-skeleton]::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
transparent,
color-mix(in oklch, var(--color-foreground) 6%, transparent),
transparent
);
animation: pg-skeleton-page-sweep ${SWEEP_MS}ms linear infinite;
animation-delay: var(--pg-skeleton-phase, 0ms);
}
.dark [data-skeleton-shell] [data-skeleton]::after {
background-image: linear-gradient(
90deg,
transparent,
color-mix(in oklch, var(--color-foreground) 4.5%, transparent),
transparent
);
}
[data-skeleton-shell][data-paused] [data-skeleton]::after {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
[data-skeleton-shell] [data-skeleton] {
animation: pg-skeleton-page-pulse 2.4s var(--ease-in-out) infinite;
}
[data-skeleton-shell] [data-skeleton]::after {
animation: none;
opacity: 0;
}
}
`;
export function SkeletonGroup({
label = "Loading",
className,
children,
...props
}: SkeletonGroupProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [paused, setPaused] = React.useState(false);
// Anchor this group's sweep to the page-wide phase origin, before paint.
useIsoLayoutEffect(() => {
ref.current?.style.setProperty(
"--pg-skeleton-phase",
`-${Math.round(performance.now() % SWEEP_MS)}ms`,
);
}, []);
// Pause while scrolled offscreen or while the tab is hidden.
React.useEffect(() => {
const node = ref.current;
if (!node) return;
let inView = true;
let visible = !document.hidden;
const update = () => setPaused(!(inView && visible));
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(([entry]) => {
if (entry) {
inView = entry.isIntersecting;
update();
}
});
observer?.observe(node);
const onVisibility = () => {
visible = !document.hidden;
update();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
observer?.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
return (
<div
ref={ref}
role="status"
aria-label={label}
data-skeleton-shell=""
data-paused={paused ? "" : undefined}
className={className}
{...props}
>
<style href="paragon-skeleton-page" precedence="paragon">
{GROUP_STYLES}
</style>
<span className="sr-only">{label}…</span>
<div aria-hidden className="contents">
{children}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Prebuilt shells */
/* */
/* Every bar sits inside an explicit line box (h-5 = text-sm, h-4 = */
/* text-xs, h-8 = text-2xl) so real content swaps in with zero layout */
/* shift. Mirror these boxes — and TABLE_ROW_GRID — in loaded states. */
/* ------------------------------------------------------------------ */
/** Deterministic per-row bar widths — no randomness, stable on replay. */
const NAME_WIDTHS = ["w-28", "w-36", "w-24", "w-32", "w-40", "w-28", "w-36", "w-24"];
const EMAIL_WIDTHS = ["w-40", "w-32", "w-44", "w-36", "w-40", "w-32", "w-44", "w-36"];
/** Row grid shared by header and body rows; email column hides on mobile. */
const TABLE_ROW_GRID =
"grid grid-cols-[minmax(0,1fr)_5rem_4.5rem] items-center gap-4 px-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_5rem_4.5rem]";
export interface SkeletonTablePageProps extends React.ComponentProps<"div"> {
/** Number of table rows. */
rows?: number;
}
/** A full table-page shell: header, toolbar, and a data table. */
export function SkeletonTablePage({
rows = 6,
className,
...props
}: SkeletonTablePageProps) {
return (
<SkeletonGroup
label="Loading table"
className={cn("w-full", className)}
{...props}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex h-5 items-center">
<Skeleton className="h-3.5 w-36" />
</div>
<div className="mt-1 flex h-4 items-center">
<Skeleton className="h-3 w-56 max-w-full" />
</div>
</div>
<Skeleton className="h-9 w-28 shrink-0 rounded-lg" />
</div>
<div className="mt-5 flex items-center gap-2">
<Skeleton className="h-8 w-52 rounded-lg" />
<Skeleton className="h-8 w-20 rounded-full" />
<Skeleton className="h-8 w-24 rounded-full" />
</div>
<div className="mt-4 overflow-hidden rounded-xl bg-card shadow-border">
<div className={cn(TABLE_ROW_GRID, "border-b bg-muted/40 py-2.5")}>
<div className="flex h-4 items-center">
<Skeleton className="h-3 w-16" />
</div>
<div className="hidden h-4 items-center sm:flex">
<Skeleton className="h-3 w-24" />
</div>
<div className="flex h-4 items-center">
<Skeleton className="h-3 w-12" />
</div>
<div className="flex h-4 items-center justify-end">
<Skeleton className="h-3 w-10" />
</div>
</div>
{Array.from({ length: rows }).map((_, row) => (
<div
key={row}
className={cn(TABLE_ROW_GRID, "border-b py-3 last:border-b-0")}
>
<div className="flex min-w-0 items-center gap-3">
<Skeleton className="size-7 shrink-0 rounded-full" />
<div className="flex h-5 min-w-0 items-center">
<Skeleton
className={cn(
"h-3.5 max-w-full",
NAME_WIDTHS[row % NAME_WIDTHS.length],
)}
/>
</div>
</div>
<div className="hidden h-5 min-w-0 items-center sm:flex">
<Skeleton
className={cn(
"h-3.5 max-w-full",
EMAIL_WIDTHS[row % EMAIL_WIDTHS.length],
)}
/>
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<div className="flex h-5 items-center justify-end">
<Skeleton className="h-3.5 w-12" />
</div>
</div>
))}
</div>
</SkeletonGroup>
);
}
/** Deterministic chart bar heights, as percentages. */
const BAR_HEIGHTS = [42, 65, 52, 78, 60, 88, 70, 95, 58, 82, 66, 90];
export interface SkeletonDashboardCardsProps
extends React.ComponentProps<"div"> {
/** Number of stat cards in the top row. */
cards?: number;
}
/** A dashboard shell: a row of stat cards over a chart card. */
export function SkeletonDashboardCards({
cards = 4,
className,
...props
}: SkeletonDashboardCardsProps) {
return (
<SkeletonGroup
label="Loading dashboard"
className={cn("w-full", className)}
{...props}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: cards }).map((_, i) => (
<div key={i} className="rounded-xl bg-card p-4 shadow-border">
<div className="flex h-4 items-center">
<Skeleton className="h-3 w-20" />
</div>
<div className="mt-2 flex h-8 items-center">
<Skeleton className="h-6 w-24" />
</div>
<div className="mt-1 flex h-4 items-center">
<Skeleton className="h-3 w-16" />
</div>
</div>
))}
</div>
<div className="mt-4 rounded-xl bg-card p-4 shadow-border">
<div className="flex items-center justify-between gap-4">
<div className="flex h-5 items-center">
<Skeleton className="h-3.5 w-32" />
</div>
<Skeleton className="h-7 w-40 shrink-0 rounded-lg" />
</div>
<div className="mt-4 flex h-40 items-end gap-2">
{BAR_HEIGHTS.map((height, i) => (
<Skeleton
key={i}
className="w-full rounded-sm"
style={{ height: `${height}%` }}
/>
))}
</div>
</div>
</SkeletonGroup>
);
}
export interface SkeletonSettingsFormProps
extends React.ComponentProps<"div"> {
/** Number of labeled input fields. */
fields?: number;
}
/** A settings-form shell: labeled fields, toggle rows, and a footer. */
export function SkeletonSettingsForm({
fields = 3,
className,
...props
}: SkeletonSettingsFormProps) {
return (
<SkeletonGroup
label="Loading settings"
className={cn("w-full max-w-lg", className)}
{...props}
>
<div>
<div className="flex h-5 items-center">
<Skeleton className="h-3.5 w-32" />
</div>
<div className="mt-1 flex h-4 items-center">
<Skeleton className="h-3 w-64 max-w-full" />
</div>
</div>
<div className="mt-5 space-y-5 rounded-xl bg-card p-5 shadow-border">
{Array.from({ length: fields }).map((_, i) => (
<div key={i}>
<div className="flex h-4 items-center">
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="mt-1.5 h-9 w-full rounded-lg" />
</div>
))}
<div className="border-t" />
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex h-5 items-center">
<Skeleton className="h-3.5 w-32" />
</div>
<div className="flex h-4 items-center">
<Skeleton className="h-3 w-48 max-w-full" />
</div>
</div>
<Skeleton className="h-5 w-9 shrink-0 rounded-full" />
</div>
))}
<div className="flex justify-end gap-2 border-t pt-4">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-28 rounded-lg" />
</div>
</div>
</SkeletonGroup>
);
}