Loaders & Skeletons

Skeleton Page

Prebuilt skeleton screens — table page, dashboard cards, settings form — with shimmer synchronized from one shared origin, paused offscreen.

Install

npx shadcn@latest add @paragon/skeleton-page

skeleton-page.tsx

"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>
  );
}