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

ElementSkeleton
TitleRectangle, ~60% width
Date/metadataShorter rectangle, ~30% width
DescriptionFull width, shorter height
AvatarCircle
ButtonRectangle with rounded corners

Use subtle animation (animate-pulse) to indicate loading, but keep it gentle—aggressive animation is distracting.

October 9, 2025Updated February 13, 2026289 words