Skeleton
A placeholder loading shape that mimics the layout of content before it loads, reducing perceived load time and preventing layout shift.
Use Cases
- Show placeholder text lines while fetching article content
- Display circular placeholders for avatar images during load
- Preview card layouts in dashboards before data arrives
- Fill table rows while a data query is in progress
- Placeholder for profile pages with mixed content types
- Show loading state for image galleries and media grids
- Replace entire page sections during initial server-side data fetch
Simplest Implementation
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
/>
);
}
// Usage
<Skeleton className="h-4 w-48" />A single div with Tailwind's built-in animate-pulse and a neutral background. The consumer controls shape and size entirely through className. The aria-hidden attribute hides the placeholder from screen readers since it carries no meaningful content.
Variations
Text Lines
interface SkeletonTextProps {
lines?: number;
}
export function SkeletonText({ lines = 3 }: SkeletonTextProps) {
return (
<div className="space-y-3" aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={`h-4 animate-pulse rounded-md bg-gray-200 ${
i === lines - 1 ? "w-2/3" : "w-full"
}`}
/>
))}
</div>
);
}Generates multiple lines of placeholder text. The last line is shorter (w-2/3) to mimic how real paragraphs typically end mid-line, which makes the skeleton feel more natural.
Circle (Avatar Skeleton)
type AvatarSize = "sm" | "md" | "lg";
interface SkeletonAvatarProps {
size?: AvatarSize;
}
const sizeClasses: Record<AvatarSize, string> = {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-14 w-14",
};
export function SkeletonAvatar({ size = "md" }: SkeletonAvatarProps) {
return (
<div
className={`animate-pulse rounded-full bg-gray-200 ${sizeClasses[size]}`}
aria-hidden="true"
/>
);
}A circular skeleton that matches common avatar dimensions. Using the same size values as the real Avatar component prevents layout shift when content loads in.
Card Skeleton
export function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-200 p-4" aria-hidden="true">
<div className="h-40 animate-pulse rounded-lg bg-gray-200" />
<div className="mt-4 space-y-3">
<div className="h-5 w-3/4 animate-pulse rounded-md bg-gray-200" />
<div className="h-4 w-full animate-pulse rounded-md bg-gray-200" />
<div className="h-4 w-5/6 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="mt-4 flex items-center gap-3">
<div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
<div className="h-4 w-24 animate-pulse rounded-md bg-gray-200" />
</div>
</div>
);
}Mirrors a typical card layout with image, title, description, and author. The border and padding match the real card so the skeleton occupies the exact same space, eliminating layout shift on load.
Table Row Skeleton
interface SkeletonTableProps {
rows?: number;
columns?: number;
}
export function SkeletonTable({ rows = 5, columns = 4 }: SkeletonTableProps) {
return (
<div className="w-full" aria-hidden="true">
<div className="flex gap-4 border-b border-gray-200 pb-3">
{Array.from({ length: columns }).map((_, i) => (
<div key={i} className="h-4 flex-1 animate-pulse rounded-md bg-gray-300" />
))}
</div>
{Array.from({ length: rows }).map((_, row) => (
<div key={row} className="flex gap-4 border-b border-gray-100 py-3">
{Array.from({ length: columns }).map((_, col) => (
<div key={col} className="h-4 flex-1 animate-pulse rounded-md bg-gray-200" />
))}
</div>
))}
</div>
);
}The header row uses a slightly darker shade (bg-gray-300) to differentiate it from data rows. Flex columns with flex-1 evenly distribute the width to match a typical table layout.
With Shimmer Animation
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`relative overflow-hidden rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</div>
);
}Add this to your tailwind.config.ts to register the shimmer keyframe:
// tailwind.config.ts
export default {
theme: {
extend: {
keyframes: {
shimmer: {
"100%": { transform: "translateX(100%)" },
},
},
},
},
};A shimmer effect that sweeps a light gradient across the placeholder. This feels more polished than the default pulse and gives users a stronger sense that content is loading. The overflow-hidden prevents the gradient from spilling outside rounded corners.
Custom Shapes Composition
interface SkeletonProps {
className?: string;
}
function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
/>
);
}
export function ProfileSkeleton() {
return (
<div className="flex items-start gap-4" aria-hidden="true">
{/* Avatar */}
<Skeleton className="h-16 w-16 shrink-0 rounded-full" />
{/* Info */}
<div className="flex-1 space-y-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
<div className="flex gap-4 pt-1">
<Skeleton className="h-8 w-24 rounded-lg" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
</div>
</div>
);
}Composes the base Skeleton primitive into a profile-specific layout. Building domain-specific skeletons (ProfileSkeleton, CommentSkeleton, etc.) from a single primitive keeps the codebase DRY while ensuring each skeleton matches its real counterpart exactly.
Complex Implementation
"use client";
import { useMemo } from "react";
type SkeletonVariant = "text" | "circular" | "rectangular" | "rounded";
interface SkeletonProps {
variant?: SkeletonVariant;
width?: string | number;
height?: string | number;
lines?: number;
animation?: "pulse" | "shimmer" | "none";
className?: string;
children?: React.ReactNode;
}
const variantClasses: Record<SkeletonVariant, string> = {
text: "rounded-md",
circular: "rounded-full",
rectangular: "rounded-none",
rounded: "rounded-xl",
};
function ShimmerOverlay() {
return (
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/60 to-transparent" />
);
}
export function Skeleton({
variant = "text",
width,
height,
lines,
animation = "pulse",
className,
children,
}: SkeletonProps) {
const style = useMemo(() => {
const s: React.CSSProperties = {};
if (width) s.width = typeof width === "number" ? `${width}px` : width;
if (height) s.height = typeof height === "number" ? `${height}px` : height;
return s;
}, [width, height]);
const animClass = animation === "pulse" ? "animate-pulse" : "";
const hasShimmer = animation === "shimmer";
// Multi-line text skeleton
if (lines && lines > 1) {
return (
<div className="space-y-3" aria-hidden="true" role="status">
<span className="sr-only">Loading...</span>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={[
"bg-gray-200",
variantClasses.text,
animClass,
hasShimmer ? "relative overflow-hidden" : "",
i === lines - 1 ? "w-2/3" : "w-full",
]
.filter(Boolean)
.join(" ")}
style={{ height: typeof height === "number" ? height : 16 }}
>
{hasShimmer && <ShimmerOverlay />}
</div>
))}
</div>
);
}
// Skeleton wrapping real content (overlay mode)
if (children) {
return (
<div className="relative inline-flex" aria-hidden="true">
<div className="invisible">{children}</div>
<div
className={[
"absolute inset-0 bg-gray-200",
variantClasses[variant],
animClass,
hasShimmer ? "overflow-hidden" : "",
]
.filter(Boolean)
.join(" ")}
>
{hasShimmer && <ShimmerOverlay />}
</div>
</div>
);
}
// Single skeleton block
return (
<div aria-hidden="true" role="status">
<span className="sr-only">Loading...</span>
<div
className={[
"bg-gray-200",
variantClasses[variant],
animClass,
hasShimmer ? "relative overflow-hidden" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
style={style}
>
{hasShimmer && <ShimmerOverlay />}
</div>
</div>
);
}
// --- Preset Compositions ---
export function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-200 p-4" role="status" aria-label="Loading card">
<Skeleton variant="rounded" height={160} className="w-full" />
<div className="mt-4 space-y-3">
<Skeleton width="75%" height={20} />
<Skeleton height={16} />
<Skeleton width="85%" height={16} />
</div>
<div className="mt-4 flex items-center gap-3">
<Skeleton variant="circular" width={32} height={32} />
<Skeleton width={96} height={16} />
</div>
</div>
);
}
export function SkeletonTable({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<div className="w-full" role="status" aria-label="Loading table">
<span className="sr-only">Loading...</span>
<div className="flex gap-4 border-b border-gray-200 pb-3">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} height={16} className="flex-1 bg-gray-300" />
))}
</div>
{Array.from({ length: rows }).map((_, row) => (
<div key={row} className="flex gap-4 border-b border-gray-100 py-3">
{Array.from({ length: columns }).map((_, col) => (
<Skeleton key={col} height={16} className="flex-1" />
))}
</div>
))}
</div>
);
}Key aspects:
- Three animation modes --
pulseuses Tailwind's built-in animation,shimmeradds a sweeping gradient for a premium feel, andnonedisables animation for reduced-motion preferences. - Overlay mode with children -- passing children renders them invisibly to preserve the exact layout dimensions, then overlays the skeleton on top. This ensures pixel-perfect size matching.
- Multi-line text support -- the
linesprop generates multiple skeleton rows with the last line shorter, matching natural paragraph endings. - Inline styles for dynamic dimensions --
widthandheightaccept both numbers (px) and strings (percentages, rem) via inline styles, avoiding the need to generate dynamic Tailwind classes. - Screen reader announcement --
role="status"with a visually hidden "Loading..." text tells screen readers that content is loading without showing text on screen. - Preset compositions --
SkeletonCardandSkeletonTabledemonstrate reusable, domain-specific skeletons built from the base primitive, keeping the API both flexible and convenient.
Gotchas
-
Layout shift when content loads -- If the skeleton dimensions do not match the real content, the page jumps when data arrives. Always measure and match the real component's height and width.
-
Too many animate-pulse elements -- Dozens of pulsing elements on a single page can degrade performance on low-end devices. Use a single parent
animate-pulseon the container instead of individual elements. -
Shimmer keyframe not registered -- The shimmer animation requires a custom keyframe in
tailwind.config.ts. Without it, the gradient element remains statically translated off-screen. -
Missing aria-hidden or role -- Skeletons without
aria-hidden="true"orrole="status"clutter the accessibility tree. Empty divs are announced as "group" by some screen readers, confusing users. -
Skeleton never disappears -- Forgetting to conditionally render the skeleton vs. the real content means the placeholder stays visible forever. Always gate skeleton rendering on a loading state:
{isLoading ? <Skeleton /> : <RealContent />}. -
Reduced motion preferences ignored -- Users with
prefers-reduced-motionenabled may find continuous animations distracting. Respect this withmotion-safe:animate-pulseor disable animation entirely via theanimation="none"prop.