Skeleton Loading
Spinners say "something is happening." Skeletons say "this is what's coming." They preview the content structure, reducing perceived loading time and preventing layout shift when data arrives.
A spinner offers no information about what will appear. A skeleton shows the shape of content—users mentally prepare for what's coming, and the transition from placeholder to real content feels natural rather than jarring.
The Co-location Pattern
Export skeletons alongside their components. When you change a component's layout, the skeleton is right there to update:
// PostList.tsx
export async function PostList({ searchParams }) {
const posts = await getPosts(searchParams);
return (
<div className="space-y-4">
{posts.map(post => (
<Card key={post.slug}>
<CardTitle>{post.title}</CardTitle>
<p>{post.description}</p>
</Card>
))}
</div>
);
}
// Co-located skeleton in the same file
export function PostListSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<Card key={i}>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-full" />
</Card>
))}
</div>
);
}Match the Layout Structure
The skeleton should mirror the real component's structure exactly:
- Same spacing (
space-y-4) - Same container types (
Card,CardHeader) - Same approximate dimensions
When data loads, content replaces skeleton with zero layout shift.
Using with Suspense
Import both the component and its skeleton:
import { PostList, PostListSkeleton } from './_components/PostList';
export default function DashboardPage({ searchParams }) {
return (
<Suspense fallback={<PostListSkeleton />}>
<PostList searchParams={searchParams} />
</Suspense>
);
}Skeleton Design Tips
| Element | Skeleton |
|---|---|
| Title | Rectangle, ~60% width |
| Date/metadata | Shorter rectangle, ~30% width |
| Description | Full width, shorter height |
| Avatar | Circle |
| Button | Rectangle with rounded corners |
Use subtle animation (animate-pulse) to indicate loading, but keep it gentle—aggressive animation is distracting.