Loading skeletons replace spinners with placeholder shapes that match the layout of real content. They reduce perceived load time and prevent layout shift.
Step 1: Base Skeleton Component
// components/ui/skeleton.tsx
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-gray-200 dark:bg-gray-800", className)}
{...props}
/>
);
}
export { Skeleton };
Step 2: Skeleton Variants
// Text skeleton
<Skeleton className="h-4 w-3/4" />
// Heading skeleton
<Skeleton className="h-8 w-1/2" />
// Circle (avatar)
<Skeleton className="h-12 w-12 rounded-full" />
// Image placeholder
<Skeleton className="h-48 w-full rounded-lg" />
// Button placeholder
<Skeleton className="h-10 w-32 rounded-lg" />
Step 3: Compose Skeleton Layouts
Card Skeleton
function CardSkeleton() {
return (
<div className="rounded-lg border p-6 dark:border-gray-700">
<Skeleton className="h-48 w-full rounded-lg" />
<div className="mt-4 space-y-3">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
<div className="mt-4 flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
</div>
);
}
Table Row Skeleton
function TableRowSkeleton() {
return (
<tr className="border-b dark:border-gray-700">
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-32" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-16" /></td>
<td className="px-4 py-3"><Skeleton className="h-6 w-16 rounded-full" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-24" /></td>
</tr>
);
}
function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<table className="w-full">
<thead>
<tr className="border-b dark:border-gray-700">
{["w-20", "w-32", "w-16", "w-16", "w-24"].map((w, i) => (
<th key={i} className="px-4 py-3">
<Skeleton className={`h-4 ${w}`} />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<TableRowSkeleton key={i} />
))}
</tbody>
</table>
);
}
Profile Skeleton
function ProfileSkeleton() {
return (
<div className="flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
</div>
</div>
);
}
Step 4: Add Shimmer Effect
For a more polished loading state, add a shimmer animation:
/* globals.css */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton-shimmer {
background: linear-gradient(
90deg,
rgb(229 231 235) 25%,
rgb(243 244 246) 37%,
rgb(229 231 235) 63%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.dark .skeleton-shimmer {
background: linear-gradient(
90deg,
rgb(31 41 55) 25%,
rgb(55 65 81) 37%,
rgb(31 41 55) 63%
);
background-size: 200% 100%;
}
function ShimmerSkeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("skeleton-shimmer rounded-md", className)}
{...props}
/>
);
}
Step 5: Use with Next.js loading.tsx
Next.js automatically shows loading.tsx while a route segment loads:
// app/blog/loading.tsx
import { Skeleton } from "@/components/ui/skeleton";
export default function BlogLoading() {
return (
<main className="mx-auto max-w-7xl px-6 py-12">
<Skeleton className="h-10 w-48" />
<Skeleton className="mt-2 h-5 w-96" />
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
</main>
);
}
Step 6: Suspense Boundaries
Use React Suspense for component-level loading:
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div className="grid gap-6 lg:grid-cols-3">
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<TableSkeleton rows={10} />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
);
}
Accessibility
Add proper ARIA attributes:
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
role="status"
aria-label="Loading"
className={cn("animate-pulse rounded-md bg-gray-200 dark:bg-gray-800", className)}
{...props}
>
<span className="sr-only">Loading...</span>
</div>
);
}
Best Practices
- Match skeleton shapes to actual content layout
- Use consistent timing (1-2 seconds for the pulse animation)
- Do not show skeletons for instant loads (< 200ms)
- Avoid skeleton for error states
- Test skeleton layouts against actual content to prevent layout shift
Need Help With UX?
We design and build applications with polished loading states and optimized user experiences. Contact us to discuss your project.