Loaders & Skeletons

Skeleton

Shimmering skeleton primitive that pauses offscreen, with text, avatar-row, and card presets; static pulse under reduced motion.

Install

npx shadcn@latest add @paragon/skeleton

skeleton.tsx

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