Shimmering skeleton primitive that pauses offscreen, with text, avatar-row, and card presets; static pulse under reduced motion.
npx shadcn@latest add @paragon/skeleton"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/** Pauses the shimmer when the skeleton scrolls offscreen. */
function useInViewPlayState() {
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(true);
React.useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([entry]) =>
setInView(entry.isIntersecting),
);
io.observe(el);
return () => io.disconnect();
}, []);
return { ref, inView };
}
export type SkeletonProps = React.ComponentProps<"div">;
/**
* Skeleton primitive: a muted block with a masked gradient shimmer sweeping
* across it. The sweep pauses offscreen (IntersectionObserver) and reduced
* motion swaps it for a static opacity pulse.
*/
const SKELETON_KEYFRAMES = `
@keyframes pg-skeleton-sweep {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
@keyframes pg-skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
@media (prefers-reduced-motion: reduce) {
[data-skeleton] {
animation: pg-skeleton-pulse 2s var(--ease-in-out) infinite;
}
[data-skeleton] > [data-skeleton-sweep] { display: none; }
}
`;
export function Skeleton({ className, style, ...props }: SkeletonProps) {
const { ref, inView } = useInViewPlayState();
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
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 1.5s linear infinite`,
animationPlayState: inView ? "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; the final line runs short, like prose. */
export function SkeletonText({
lines = 3,
className,
...props
}: SkeletonTextProps) {
return (
<div
role="status"
className={cn("w-full space-y-2.5", className)}
{...props}
>
<span className="sr-only">Loading</span>
{Array.from({ length: lines }, (_, i) => (
<Skeleton
key={i}
className="h-3.5"
style={{ width: i === lines - 1 ? "60%" : "100%" }}
/>
))}
</div>
);
}
/** Avatar circle beside two lines — a person or account row loading. */
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 className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-2/5" />
<Skeleton className="h-3 w-3/5" />
</div>
</div>
);
}
/** Card placeholder: media block, then title and body lines. */
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>
<Skeleton className="h-32 w-full rounded-lg" />
<div className="mt-4 space-y-2.5">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-4/5" />
</div>
</div>
);
}