Skeleton
Loaders & Skeletons

Skeleton

Shimmering skeleton primitive with a page-wide shared sweep phase and text, avatar-row, and card presets sized to real line boxes for zero-shift swaps.

Install

npx shadcn@latest add @paragon/skeleton

skeleton.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/**
 * One shared sweep period for every Paragon skeleton on the page. Each
 * sweep's phase is anchored to the document timeline (performance.now()
 * modulo the period) rather than to its own mount time, so skeletons mounted
 * at different moments — even across separate trees — always shimmer in the
 * same phase instead of drifting.
 */
const SWEEP_MS = 1800;

const useIsoLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

/* One IntersectionObserver shared by every skeleton on the page. */
const inViewCallbacks = new Map<Element, (inView: boolean) => void>();
let sharedObserver: IntersectionObserver | null = null;

function observeInView(el: Element, callback: (inView: boolean) => void) {
  if (typeof IntersectionObserver === "undefined") return () => {};
  sharedObserver ??= new IntersectionObserver((entries) => {
    for (const entry of entries) {
      inViewCallbacks.get(entry.target)?.(entry.isIntersecting);
    }
  });
  inViewCallbacks.set(el, callback);
  sharedObserver.observe(el);
  return () => {
    inViewCallbacks.delete(el);
    sharedObserver?.unobserve(el);
  };
}

/** Pauses the shimmer offscreen (IntersectionObserver) and in hidden tabs. */
function usePlayState() {
  const ref = React.useRef<HTMLDivElement>(null);
  const [playing, setPlaying] = React.useState(true);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let inView = true;
    let visible = !document.hidden;
    const update = () => setPlaying(inView && visible);
    const unobserve = observeInView(el, (next) => {
      inView = next;
      update();
    });
    const onVisibility = () => {
      visible = !document.hidden;
      update();
    };
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      unobserve();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  return { ref, playing };
}

export type SkeletonProps = React.ComponentProps<"div">;

/**
 * Skeleton primitive: a muted block with a masked gradient shimmer sweeping
 * across it. Every instance shares one page-wide phase origin so composed
 * skeletons sweep in sync; the sweep pauses offscreen and in hidden tabs, and
 * reduced motion swaps it for a gentle opacity pulse.
 */
const SKELETON_KEYFRAMES = `
  @keyframes pg-skeleton-sweep {
    0% { transform: translateX(-100%); }
    62%, 100% { transform: translateX(100%); }
  }
  @keyframes pg-skeleton-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.6; }
  }
  @media (prefers-reduced-motion: reduce) {
    [data-skeleton] {
      animation: pg-skeleton-pulse 2.4s var(--ease-in-out) infinite;
    }
    [data-skeleton] > [data-skeleton-sweep] { display: none; }
  }
`;

export function Skeleton({ className, style, ...props }: SkeletonProps) {
  const { ref, playing } = usePlayState();
  const sweepRef = React.useRef<HTMLSpanElement>(null);

  // Anchor this sweep to the shared document-timeline phase origin, before
  // paint, so late-mounted skeletons join the page-wide wave mid-cycle.
  useIsoLayoutEffect(() => {
    const sweep = sweepRef.current;
    if (sweep) {
      sweep.style.animationDelay = `-${Math.round(performance.now() % SWEEP_MS)}ms`;
    }
  }, []);

  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
        ref={sweepRef}
        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 ${SWEEP_MS}ms linear infinite`,
          animationPlayState: playing ? "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. Each bar sits centered in a 20px line box —
 * the line height of `text-sm` — so swapping in real prose produces zero
 * layout shift; the final line runs short, like prose.
 */
export function SkeletonText({
  lines = 3,
  className,
  ...props
}: SkeletonTextProps) {
  return (
    <div role="status" className={cn("w-full", className)} {...props}>
      <span className="sr-only">Loading</span>
      {Array.from({ length: lines }, (_, i) => (
        <div key={i} aria-hidden className="flex h-5 items-center">
          <Skeleton
            className="h-3"
            style={{ width: i === lines - 1 ? "60%" : "100%" }}
          />
        </div>
      ))}
    </div>
  );
}

/**
 * Avatar circle beside two lines — a person or account row loading. The text
 * stack matches a `text-sm` name over a `text-xs` meta line (20px + 16px
 * boxes), so real rows swap in without shifting.
 */
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 aria-hidden className="min-w-0 flex-1">
        <div className="flex h-5 items-center">
          <Skeleton className="h-3.5 w-2/5" />
        </div>
        <div className="flex h-4 items-center">
          <Skeleton className="h-3 w-3/5" />
        </div>
      </div>
    </div>
  );
}

/**
 * Card placeholder: media block, then a `text-sm` title line box and two
 * body line boxes, mirroring a real card's metrics exactly.
 */
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>
      <div aria-hidden>
        <Skeleton className="h-32 w-full rounded-lg" />
        <div className="mt-4 flex h-5 items-center">
          <Skeleton className="h-3.5 w-1/2" />
        </div>
        <div className="mt-1">
          <div className="flex h-5 items-center">
            <Skeleton className="h-3 w-full" />
          </div>
          <div className="flex h-5 items-center">
            <Skeleton className="h-3 w-4/5" />
          </div>
        </div>
      </div>
    </div>
  );
}