Prebuilt skeleton screens — table page, dashboard cards, settings form — with shimmer synchronized from one shared origin, paused offscreen.
npx shadcn@latest add @paragon/skeleton-page"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
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 starting on the same mount commit — a single shared
* delay origin, so every piece sweeps in the same phase and nothing drifts.
* The sweep is an overlay translateX (no background-position animation),
* runs linear (constant motion), pauses when the group scrolls offscreen,
* and disappears entirely under prefers-reduced-motion, leaving calm static
* blocks.
*/
export function SkeletonGroup({
label = "Loading",
className,
children,
...props
}: SkeletonGroupProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(true);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(([entry]) => {
if (entry) setInView(entry.isIntersecting);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
role="status"
aria-label={label}
data-skeleton-shell=""
data-paused={inView ? undefined : ""}
className={className}
{...props}
>
<style href="paragon-skeleton-page" precedence="paragon">{`
@keyframes pg-skeleton-page-sweep {
0% { transform: translateX(-100%); }
60%, 100% { transform: translateX(100%); }
}
[data-skeleton-shell] [data-skeleton]::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background-image: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.55), transparent);
animation: pg-skeleton-page-sweep 2s linear infinite;
}
.dark [data-skeleton-shell] [data-skeleton]::after {
background-image: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.06), transparent);
}
[data-skeleton-shell][data-paused] [data-skeleton]::after {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
[data-skeleton-shell] [data-skeleton]::after {
animation: none;
opacity: 0;
}
}
`}</style>
<span className="sr-only">{label}…</span>
<div aria-hidden className="contents">
{children}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Prebuilt shells */
/* ------------------------------------------------------------------ */
/** Deterministic per-row name widths — no randomness, stable on replay. */
const NAME_WIDTHS = ["w-28", "w-36", "w-24", "w-32", "w-40", "w-28"];
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 className="space-y-2">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-3.5 w-56" />
</div>
<Skeleton className="h-9 w-28 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="flex items-center gap-4 border-b bg-muted/40 px-4 py-3">
<Skeleton className="h-3 w-32" />
<Skeleton className="hidden h-3 w-40 sm:block" />
<Skeleton className="h-3 w-16" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{Array.from({ length: rows }).map((_, row) => (
<div
key={row}
className="flex items-center gap-4 border-b px-4 py-3 last:border-b-0"
>
<div className="flex items-center gap-3">
<Skeleton className="size-7 rounded-full" />
<Skeleton
className={cn("h-3.5", NAME_WIDTHS[row % NAME_WIDTHS.length])}
/>
</div>
<Skeleton className="hidden h-3.5 w-40 sm:block" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-auto h-3.5 w-14" />
</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">
<Skeleton className="h-3 w-20" />
<Skeleton className="mt-3 h-7 w-24" />
<Skeleton className="mt-2 h-3 w-16" />
</div>
))}
</div>
<div className="mt-4 rounded-xl bg-card p-4 shadow-border">
<div className="flex items-center justify-between gap-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-7 w-40 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 className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-64" />
</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} className="space-y-2">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="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="space-y-1.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-48" />
</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>
);
}