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
| Principle | Guidance |
|---|---|
| Match the layout | Skeleton shapes should closely match the final content — same height, width proportion, and border radius. A mismatch causes layout shift on load. |
| Show count intent | If a list typically shows 5 items, show 5 skeleton rows — not 1 or 20. This sets accurate user expectations. |
| Avoid over-animation | Use the shimmer only on the blocks themselves. Do not animate the surrounding container or add spin loaders alongside skeletons. |
| Respect reduced motion | Set animation: none when prefers-reduced-motion: reduce is active. The static placeholder still communicates loading state. |
| Duration threshold | Only show skeletons for loads expected to take >300ms. For instant data, skeletons cause more flicker than they prevent. |
| Skeleton vs spinner | Use a skeleton when content has a predictable shape (text, cards, rows). Use a spinner for indeterminate progress (upload, export, AI generation). |
Anatomy
| Element | Spec |
|---|---|
| Fill | --surface-raised (same token used for hover states — blends naturally in both light and dark modes) |
| Animation | animate-pulse (Tailwind) — opacity cycle, 1.5s ease-in-out infinite. Alternatively: shimmer gradient sweep at 200% background-size |
| Reduced motion | animation: none — static placeholder remains visible |
| aria-hidden | true — skeleton blocks are decorative and must be hidden from screen readers |
| Border radius | Match 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
| Prop | Type | Default | Description |
|---|---|---|---|
width | string | number | — | CSS width of the skeleton block. Pass a number for pixel value, or any CSS string. |
height | string | number | — | CSS 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. |
animate | boolean | true | Enable the shimmer animation. Set false to show a static placeholder (e.g. when printing). |
className | string | — | Additional 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.