Skip to content
Drivn
6 min read

React Skeleton Component Examples

Copy-paste React Skeleton examples: avatar with two text lines, product card grid, table-row placeholders, Suspense fallback, and a full-page profile loader.

A skeleton is the right placeholder when the layout of the content you are loading is already decided — a profile card with an avatar and two text lines, a product grid where every cell is the same size, a table whose row height never changes. The Skeleton component in Drivn is the one-element div that fills that shape with a slow opacity fade until the real content swaps in. The entire file lives at @/components/ui/skeleton.tsx, imports only React and the local cn() utility, and renders a single <div> with bg-muted/80 rounded-md animate-skeleton plus whatever className you pass.

Every example on this page imports the component from @/components/ui/skeleton — the path the Drivn CLI installs it under — and uses className to set the size and shape. The Skeleton has no size prop, no variant union, and no shape API: width, height, and rounding all live in Tailwind utilities you pass through className. Every snippet is TypeScript because Drivn ships TypeScript-only, and every snippet is server-safe — the component has no 'use client' directive, so any of these can render inside a server component or a Suspense fallback without crossing a hydration boundary.

The examples below cover an avatar with two text lines, a four-card product grid skeleton, table-row placeholders, a Suspense fallback in a server component, a full-page profile loader, and the verbatim component source. The full props table sits on the Skeleton docs page, and the shadcn/ui comparison lives on Drivn vs shadcn Skeleton.

An avatar with two text lines

The canonical Skeleton example — and the one the Skeleton docs preview uses — is a circular avatar next to two stacked text lines. The avatar slot is size-12 rounded-full (a 48-pixel circle), the heading line is h-5 w-40 (five units tall by forty units wide), and the body line beneath it is h-4 w-60. The shapes match what a user card looks like once it loads, so the layout does not shift when the real data arrives.

The wrapping flex row uses items-center to vertically align the avatar with the text stack, and gap-4 for the gap between them. The inner flex column uses gap-2 for the line spacing. The rounded-md already in the Skeleton's base className governs the corner radius on the text lines — pass rounded-full to override it on the avatar. This is the pattern to reach for whenever the loaded content is a person, an account, or any leading-icon + label row.

1import { Skeleton } from "@/components/ui/skeleton"
2
3export default function UserCardSkeleton() {
4 return (
5 <div className="flex items-center gap-4">
6 <Skeleton className="size-12 rounded-full" />
7 <div className="flex flex-col gap-2">
8 <Skeleton className="h-5 w-40" />
9 <Skeleton className="h-4 w-60" />
10 </div>
11 </div>
12 )
13}

A four-card product grid

For a product grid that streams in, the right pattern is a Skeleton per card with the same internal shape the real card uses: an image area on top, a title line, and a price line. The image area is the tallest part — h-40 w-full here, with rounded-md inherited from the base className so the corners match the real card. Underneath, a title line (h-4 w-3/4) and a price line (h-4 w-1/3) stack with gap-2.

The grid wrapper uses Tailwind's grid grid-cols-2 md:grid-cols-4 gap-4 to match the real product grid's layout. Mapping over an array of four lets the placeholder count match what the route is about to render. Pair this with the Card compound for the real grid — the skeleton lives in the route segment's loading.tsx (or inside a Suspense fallback) and the real Card renders once the data fetch resolves.

1import { Skeleton } from "@/components/ui/skeleton"
2
3export default function ProductGridSkeleton() {
4 return (
5 <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
6 {Array.from({ length: 4 }).map((_, i) => (
7 <div key={i} className="flex flex-col gap-3">
8 <Skeleton className="h-40 w-full" />
9 <Skeleton className="h-4 w-3/4" />
10 <Skeleton className="h-4 w-1/3" />
11 </div>
12 ))}
13 </div>
14 )
15}

Table-row placeholders

A loading state for a Table reads better as five short bars in a row rather than one big block. The pattern: render the real <table> shell with its real <thead>, then map over a placeholder array to render <tr> elements whose cells each contain a Skeleton. The Skeleton width inside each cell is tuned to the kind of data the cell will eventually hold — narrow for IDs, wider for names, medium for dates, narrow for amounts, very narrow for status.

Using the real table shell means the column widths the browser computes for the real data are the same widths the placeholder uses, so when the real rows arrive the layout does not shift. The Skeleton height (h-4) matches a single-line cell. For a data-table with sortable columns and filters, the shell renders normally and only the body rows get the Skeleton treatment.

1import { Skeleton } from "@/components/ui/skeleton"
2
3export default function InvoiceTableSkeleton() {
4 return (
5 <table className="w-full text-sm">
6 <thead>
7 <tr className="text-left text-muted-foreground">
8 <th className="py-2">ID</th>
9 <th>Customer</th>
10 <th>Date</th>
11 <th>Amount</th>
12 <th>Status</th>
13 </tr>
14 </thead>
15 <tbody>
16 {Array.from({ length: 5 }).map((_, i) => (
17 <tr key={i} className="border-t border-border">
18 <td className="py-3"><Skeleton className="h-4 w-12" /></td>
19 <td><Skeleton className="h-4 w-32" /></td>
20 <td><Skeleton className="h-4 w-20" /></td>
21 <td><Skeleton className="h-4 w-16" /></td>
22 <td><Skeleton className="h-4 w-14" /></td>
23 </tr>
24 ))}
25 </tbody>
26 </table>
27 )
28}

A Suspense fallback in a server component

The Skeleton has no 'use client' directive, so it can sit directly inside a server component's Suspense boundary. Wrap the slow data-fetching component in <Suspense fallback={…}>, pass a Skeleton (or a small layout of Skeletons) as the fallback, and React renders the placeholder while the real component awaits its data. When the data resolves, the real markup streams in and replaces the fallback in place.

The example below is the same user-card shape from the first example, used as a fallback for an async ProfileCard server component. Nothing in this snippet crosses a client boundary — the Suspense boundary, the Skeleton, and the awaited component all run on the server. The animation plays via CSS in the browser; the JavaScript cost is zero beyond the bytes of the component file itself. Use this pattern for above-the-fold content where the LCP element depends on a database call.

1// app/profile/page.tsx — server component
2import { Suspense } from "react"
3import { Skeleton } from "@/components/ui/skeleton"
4import { ProfileCard } from "./profile-card"
5
6export default function ProfilePage() {
7 return (
8 <Suspense
9 fallback={
10 <div className="flex items-center gap-4">
11 <Skeleton className="size-12 rounded-full" />
12 <div className="flex flex-col gap-2">
13 <Skeleton className="h-5 w-40" />
14 <Skeleton className="h-4 w-60" />
15 </div>
16 </div>
17 }
18 >
19 <ProfileCard />
20 </Suspense>
21 )
22}

A full-page profile loader

For a route that loads several panels at once, drop a loading.tsx next to the route's page.tsx. Next.js renders loading.tsx while the page's data fetch is in flight, then swaps in the real page. The file is a server component by default, which is fine — the Skeleton runs there.

The example below is a profile route loader: a header strip with an avatar and two text lines, a bio block, and a two-column grid of stat cards. Every block is a Skeleton sized to match what the real page renders. The wrapper container width (max-w-3xl) and the spacing utilities mirror the real page, so when the swap happens the user does not see anything move. For a multi-step form skeleton or a wizard-style loading state, reach for the Stepper component once the data arrives.

1// app/profile/[id]/loading.tsx
2import { Skeleton } from "@/components/ui/skeleton"
3
4export default function ProfileLoading() {
5 return (
6 <div className="max-w-3xl mx-auto py-10 space-y-8">
7 <div className="flex items-center gap-4">
8 <Skeleton className="size-16 rounded-full" />
9 <div className="flex flex-col gap-2">
10 <Skeleton className="h-6 w-48" />
11 <Skeleton className="h-4 w-32" />
12 </div>
13 </div>
14 <Skeleton className="h-20 w-full" />
15 <div className="grid grid-cols-2 gap-4">
16 <Skeleton className="h-24 w-full" />
17 <Skeleton className="h-24 w-full" />
18 </div>
19 </div>
20 )
21}

The verbatim component source

Every example above maps back to the same six-line component file. The function spreads HTMLAttributes<HTMLDivElement> onto a single div, merges bg-muted/80 rounded-md animate-skeleton with whatever className you passed, and stops. No state, no refs, no hooks. The full source is reproduced below from packages/drivn/src/registry/components/skeleton.ts so the entire component is visible at once.

The animate-skeleton utility comes from the CSS in src/styles/globals.css: a custom keyframe that fades opacity between 1 and 0.4 over two seconds with ease-in-out, looping forever. Tweaking the timing or the opacity floor is a one-line change to that keyframe, and every Skeleton in the project picks up the new animation. After the Drivn CLI writes the file into your project, the component is yours — rename it, change the colour token, swap the animation, add a pulse variant of your own.

1import * as React from 'react'
2import { cn } from '@/utils/cn'
3
4export function Skeleton({
5 className,
6 ...props
7}: React.HTMLAttributes<HTMLDivElement>) {
8 return (
9 <div
10 className={cn(
11 'bg-muted/80 rounded-md animate-skeleton',
12 className
13 )}
14 {...props}
15 />
16 )
17}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

The Skeleton has no size prop. Width, height, and rounding all come from the className you pass, which cn() merges with the base bg-muted/80 rounded-md animate-skeleton classes using later-class precedence. Use Tailwind utilities — h-4 w-40 for a single text line, size-12 rounded-full for an avatar, h-40 w-full for a media area. The className you pass wins, so rounded-full overrides the base rounded-md.

Yes. The component has no 'use client' directive, no hooks, and no event handlers — it spreads HTMLAttributes<HTMLDivElement> onto a single div with the animation class. You can render it inside server components, Next.js layouts, statically generated pages, Suspense fallbacks, and route-level loading.tsx files without crossing a client boundary or adding to the JavaScript bundle for that route.

The animate-skeleton utility is a custom keyframe declared in src/styles/globals.css. It runs for two seconds with ease-in-out, looping forever, and fades opacity from 1 down to 0.4 at the midpoint and back to 1 at the end. The animation is pure CSS — the browser drives it, not React — so the JavaScript cost for the animation itself is zero.

Use a Skeleton when the layout of the loading content is already decided — a card with an avatar and two lines, a row of stat tiles, a table row. The placeholder reserves the exact shape so the layout does not shift when real data arrives. Use a spinner for ambiguous waits where the result shape is unknown — modal submissions, background syncs, or one-off long actions where the user already knows what triggered the wait.