Skeleton Page
Loaders & Skeletons

Skeleton Page

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.

Install

npx shadcn@latest add @paragon/skeleton-page

skeleton-page.tsx

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