Skeleton

Placeholder blocks that mimic the shape of loading content. Skeletons reduce perceived wait time by giving the user a preview of layout before data arrives.

Preview

Inline blocks

Cards

List

Table rows

NameStatusDateOwner

KPI tiles

Guidelines

PrincipleGuidance
Match the layoutSkeleton shapes should closely match the final content — same height, width proportion, and border radius. A mismatch causes layout shift on load.
Show count intentIf a list typically shows 5 items, show 5 skeleton rows — not 1 or 20. This sets accurate user expectations.
Avoid over-animationUse the shimmer only on the blocks themselves. Do not animate the surrounding container or add spin loaders alongside skeletons.
Respect reduced motionSet animation: none when prefers-reduced-motion: reduce is active. The static placeholder still communicates loading state.
Duration thresholdOnly show skeletons for loads expected to take >300ms. For instant data, skeletons cause more flicker than they prevent.
Skeleton vs spinnerUse a skeleton when content has a predictable shape (text, cards, rows). Use a spinner for indeterminate progress (upload, export, AI generation).

Anatomy

ElementSpec
Fill--surface-raised (same token used for hover states — blends naturally in both light and dark modes)
Animationanimate-pulse (Tailwind) — opacity cycle, 1.5s ease-in-out infinite. Alternatively: shimmer gradient sweep at 200% background-size
Reduced motionanimation: none — static placeholder remains visible
aria-hiddentrue — skeleton blocks are decorative and must be hidden from screen readers
Border radiusMatch the final element. Text lines: rounded-lg. Avatars: rounded-full. Cards: rounded-xl.

Implementation

Skeleton.tsx
tsx
import { cn } from "@/lib";

interface SkeletonProps {
  width?: string | number;
  height?: string | number;
  rounded?: "none" | "sm" | "md" | "lg" | "full";
  animate?: boolean;
  className?: string;
}

const RADIUS = {
  none: "rounded-none",
  sm:   "rounded",
  md:   "rounded-lg",
  lg:   "rounded-xl",
  full: "rounded-full",
} as const;

export function Skeleton({
  width,
  height,
  rounded = "md",
  animate = true,
  className,
}: SkeletonProps) {
  return (
    <div
      className={cn(
        "bg-[rgb(var(--surface-raised))]",
        RADIUS[rounded],
        animate && "animate-pulse",
        className
      )}
      style={{
        width:  typeof width  === "number" ? `${width}px`  : width,
        height: typeof height === "number" ? `${height}px` : height,
      }}
      aria-hidden="true"
    />
  );
}

// --- Composed patterns ---

// Card skeleton
export function CardSkeleton() {
  return (
    <div className="p-4 rounded-xl border border-[rgb(var(--border))] space-y-3">
      <div className="flex items-center gap-3">
        <Skeleton width={36} height={36} rounded="full" />
        <div className="space-y-1.5 flex-1">
          <Skeleton width="60%" height={14} />
          <Skeleton width="40%" height={12} />
        </div>
      </div>
      <Skeleton height={12} />
      <Skeleton height={12} width="80%" />
      <Skeleton height={12} width="65%" />
    </div>
  );
}

// Table row skeleton
export function TableRowSkeleton({ cols = 4 }: { cols?: number }) {
  return (
    <div className="flex items-center gap-4 px-4 py-3 border-b border-[rgb(var(--border-subtle))]">
      {Array.from({ length: cols }).map((_, i) => (
        <Skeleton key={i} height={14} className="flex-1" style={{ maxWidth: i === 0 ? "40%" : undefined }} />
      ))}
    </div>
  );
}

// List item skeleton
export function ListItemSkeleton() {
  return (
    <div className="flex items-center gap-3 px-4 py-2.5">
      <Skeleton width={32} height={32} rounded="full" />
      <div className="space-y-1.5 flex-1">
        <Skeleton height={13} width="55%" />
        <Skeleton height={11} width="35%" />
      </div>
      <Skeleton width={60} height={24} rounded="full" />
    </div>
  );
}

Props

PropTypeDefaultDescription
widthstring | numberCSS width of the skeleton block. Pass a number for pixel value, or any CSS string.
heightstring | numberCSS height of the skeleton block.
rounded"none" | "sm" | "md" | "lg" | "full""md"Border radius of the block. Use 'full' for circular avatars and badge pills.
animatebooleantrueEnable the shimmer animation. Set false to show a static placeholder (e.g. when printing).
classNamestringAdditional Tailwind classes to override width, height, or margin inline.

Accessibility

  • Set aria-hidden='true' on all skeleton blocks — they are purely decorative and must not be announced by screen readers.
  • Wrap the loading region in a live region: <div aria-live='polite' aria-busy='true'>. When content loads, update aria-busy='false' and replace skeletons with real content.
  • Screen readers will announce the live region update, giving non-visual users an equivalent loading cue.
  • Never use skeleton text as a substitute for real content — remove it completely once data is available.
  • Respect prefers-reduced-motion: disable the shimmer animation for users who have requested reduced motion in their OS settings.