Components / Feedback
Skeleton A placeholder that mirrors the shape of content while it loads, so the layout doesn't jump when data arrives. Any size or shape via classes, with pulse, shimmer or static animation.
import { Skeleton } from "@/components/ui/skeleton" ;
export default function SkeletonDemo () {
return (
< div className = "flex items-center gap-4" >
< Skeleton className = "size-12 rounded-full" />
< div className = "space-y-2" >
< Skeleton className = "h-4 w-56" />
< Skeleton className = "h-4 w-40" />
</ div >
</ div >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/skeleton
The CLI installs dependencies and any other components this one uses.
import { Skeleton } from "@/components/ui/skeleton" ;
< Skeleton className = "h-4 w-48" />
A skeleton is an empty, rounded, muted <div>. Give it the size of the content it stands in for with h-*, w-* or size-*, and change the shape with rounded-*.
Shimmer keyframes
animation="shimmer" uses the animate-shimmer keyframes from the theme
CSS, which the registry adds on install. pulse uses Tailwind's built-in
animate-pulse.
Skeleton — the layout of the incoming content is known (a card, a list, a table) and loading takes long enough to notice.
Spinner — a short wait in a small space, or when you can't predict the shape of the result.
Progress Bar — a long task with measurable progress.
Show nothing for waits under ~300ms; a flash of placeholders feels slower than a brief pause.
A skeleton is a single element. Compose several to sketch the content they replace.
< div aria-busy = "true" > { /* the region that is loading */ }
< Skeleton className = "size-10 rounded-full" /> { /* avatar */ }
< Skeleton className = "h-4 w-32" /> { /* text line */ }
</ div >
Part Renders Notes Skeleton<div aria-hidden>rounded-md bg-muted plus the animation. Carries data-slot="skeleton". Server-component safe.Shimmer highlight ::beforeA gradient that sweeps across when animation="shimmer".
Match the real element's dimensions: rounded-full for avatars and pills, rounded-lg for thumbnails and media, the default rounded-md for text and buttons.
Heading
Text
Avatar
Thumbnail
Button
Badge
import { Skeleton } from "@/components/ui/skeleton" ;
export default function SkeletonShapes () {
return (
< div className = "grid w-full max-w-sm grid-cols-[auto_1fr] items-center gap-x-6 gap-y-4 text-muted-foreground text-xs" >
< span >Heading</ span >
< Skeleton className = "h-6 w-40" />
< span >Text</ span >
< Skeleton className = "h-4 w-full" />
< span >Avatar</ span >
< Skeleton className = "size-10 rounded-full" />
< span >Thumbnail</ span >
< Skeleton className = "size-16 rounded-lg" />
< span >Button</ span >
< Skeleton className = "h-8 w-24" />
< span >Badge</ span >
< Skeleton className = "h-5 w-14 rounded-full" />
</ div >
);
}
pulse (default) fades the placeholder in and out. shimmer sweeps a soft highlight across, which reads well on large media blocks. none is static, for dense screens with many placeholders. Under prefers-reduced-motion, pulse and shimmer fall back to the static block automatically.
import { Skeleton } from "@/components/ui/skeleton" ;
const animations = [ "pulse" , "shimmer" , "none" ] as const ;
export default function SkeletonAnimations () {
return (
< div className = "grid w-full max-w-lg gap-4 sm:grid-cols-3" >
{animations. map (( animation ) => (
< div key = {animation} className = "flex flex-col gap-2" >
< div className = "flex flex-col gap-2 rounded-xl border p-3" >
< Skeleton animation = {animation} className = "h-20 w-full" />
< Skeleton animation = {animation} className = "h-3 w-3/4" />
< Skeleton animation = {animation} className = "h-3 w-1/2" />
</ div >
< code className = "text-center text-muted-foreground text-xs" >
{animation}
</ code >
</ div >
))}
</ div >
);
}
Vary line widths and end on a shorter line so a block reads as a paragraph rather than a grid of bars.
import { Skeleton } from "@/components/ui/skeleton" ;
const widths = [ "w-full" , "w-[97%]" , "w-11/12" , "w-[99%]" , "w-2/3" ];
export default function SkeletonText () {
return (
< div className = "flex w-full max-w-md flex-col gap-4" >
< Skeleton className = "h-7 w-2/3" />
< div className = "flex flex-col gap-2" >
{widths. map (( w ) => (
< Skeleton key = {w} className = { `h-4 ${ w }` } />
))}
</ div >
</ div >
);
}
Repeat a row template for lists. A few rows are enough to suggest the content; you don't need to match the final count.
import { Skeleton } from "@/components/ui/skeleton" ;
export default function SkeletonList () {
return (
< div className = "w-full max-w-sm divide-y rounded-xl border bg-card" >
{Array. from ({ length: 4 }, ( _ , i ) => i). map (( i ) => (
< div key = {i} className = "flex items-center gap-3 px-4 py-3" >
< Skeleton className = "size-9 shrink-0 rounded-full" />
< div className = "flex flex-1 flex-col gap-1.5" >
< Skeleton className = "h-3.5 w-1/2" />
< Skeleton className = "h-3 w-3/4" />
</ div >
< Skeleton className = "h-5 w-12 rounded-full" />
</ div >
))}
</ div >
);
}
For values inside text, make the skeleton inline-block and size it to the line with h-[1lh] or h-[1em], so the surrounding text doesn't shift when the value arrives.
import { Skeleton } from "@/components/ui/skeleton" ;
export default function SkeletonInline () {
return (
< div className = "flex w-full max-w-xs flex-col gap-2 rounded-xl border bg-card p-4 text-sm" >
< div className = "flex justify-between" >
< span className = "text-muted-foreground" >Balance</ span >
< Skeleton className = "inline-block h-[1lh] w-20" />
</ div >
< div className = "flex justify-between" >
< span className = "text-muted-foreground" >Next payout</ span >
< Skeleton className = "inline-block h-[1lh] w-24" />
</ div >
< div className = "text-muted-foreground" >
Last synced{ " " }
< Skeleton className = "inline-block h-[1em] w-14 align-middle" /> ago.
</ div >
</ div >
);
}
Render skeletons while loading and the real content once it's ready, keeping the container the same. Mark the container with aria-busy while it loads.
import { RefreshCwIcon } from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { Avatar } from "@/components/ui/avatar" ;
import { Button } from "@/components/ui/button" ;
import { Skeleton } from "@/components/ui/skeleton" ;
export default function SkeletonLoadingState () {
const [ loading , setLoading ] = useState ( true );
useEffect (() => {
if ( ! loading) return ;
const timer = setTimeout (() => setLoading ( false ), 1500 );
return () => clearTimeout (timer);
}, [loading]);
return (
< div className = "flex w-full max-w-sm flex-col items-start gap-4" >
< div
aria-busy = {loading}
aria-live = "polite"
className = "flex w-full items-center gap-3 rounded-xl border bg-card p-4"
>
{loading ? (
<>
< Skeleton className = "size-10 shrink-0 rounded-full" />
< div className = "flex flex-1 flex-col gap-2" >
< Skeleton className = "h-4 w-32" />
< Skeleton className = "h-3 w-48" />
</ div >
</>
) : (
<>
< Avatar alt = "Maya Chen" fallback = "MC" colorful />
< div className = "min-w-0" >
< p className = "font-medium text-sm" >Maya Chen</ p >
< p className = "truncate text-muted-foreground text-xs" >
Product designer · Joined March 2023
</ p >
</ div >
</>
)}
</ div >
< Button
size = "sm"
variant = "outline"
isDisabled = {loading}
onPress = {() => setLoading ( true )}
>
< RefreshCwIcon /> Reload
</ Button >
</ div >
);
}
Keep the Table header and swap each row's cells for skeletons of similar width. Cells get a textValue so the table stays valid while loading.
Refresh Invoice Customer Status Amount
import { useEffect, useState } from "react" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import { Skeleton } from "@/components/ui/skeleton" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const invoices = [
{
id: "INV-1042" ,
customer: "Acme Corp" ,
status: "Paid" ,
amount: "$2,500.00" ,
},
{
id: "INV-1043" ,
customer: "Globex" ,
status: "Pending" ,
amount: "$1,150.00" ,
},
{
id: "INV-1044" ,
customer: "Initech" ,
status: "Overdue" ,
amount: "$3,420.00" ,
},
{ id: "INV-1045" , customer: "Umbrella" , status: "Paid" , amount: "$980.00" },
];
const tone = {
Paid: "success" ,
Pending: "warning" ,
Overdue: "danger" ,
} as const ;
export default function SkeletonRecipeTable () {
const [ loading , setLoading ] = useState ( true );
useEffect (() => {
if ( ! loading) return ;
const timer = setTimeout (() => setLoading ( false ), 1800 );
return () => clearTimeout (timer);
}, [loading]);
return (
< div className = "flex w-full max-w-xl flex-col items-end gap-3" >
< Button
size = "sm"
variant = "outline"
isDisabled = {loading}
onPress = {() => setLoading ( true )}
>
Refresh
</ Button >
< div aria-busy = {loading} className = "w-full" >
< Table aria-label = "Invoices" className = "min-w-[480px]" >
< TableHeader >
< Column isRowHeader >Invoice</ Column >
< Column >Customer</ Column >
< Column >Status</ Column >
< Column className = "text-right" >Amount</ Column >
</ TableHeader >
{loading ? (
< TableBody >
{invoices. map (( inv ) => (
< Row key = {inv.id} id = {inv.id}>
< Cell textValue = "Loading" >
< Skeleton className = "h-4 w-20" />
</ Cell >
< Cell textValue = "Loading" >
< Skeleton className = "h-4 w-28" />
</ Cell >
< Cell textValue = "Loading" >
< Skeleton className = "h-5 w-16 rounded-full" />
</ Cell >
< Cell textValue = "Loading" >
< Skeleton className = "ml-auto h-4 w-16" />
</ Cell >
</ Row >
))}
</ TableBody >
) : (
< TableBody items = {invoices}>
{( inv ) => (
< Row id = {inv.id}>
< Cell className = "font-medium" >{inv.id}</ Cell >
< Cell >{inv.customer}</ Cell >
< Cell >
< Badge color = {tone[inv.status as keyof typeof tone]}>
{inv.status}
</ Badge >
</ Cell >
< Cell className = "text-right tabular-nums" >{inv.amount}</ Cell >
</ Row >
)}
</ TableBody >
)}
</ Table >
</ div >
</ div >
);
}
Static labels stay visible while only the numbers load, inside Card s. Sizing each skeleton to the line height of the final text avoids layout shift.
import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import { Card, CardContent } from "@/components/ui/card" ;
import { Skeleton } from "@/components/ui/skeleton" ;
const stats = [
{ label: "Revenue" , value: "$48,290" , change: 12.4 },
{ label: "Active users" , value: "3,812" , change: 4.1 },
{ label: "Churn" , value: "1.9%" , change: - 0.6 },
];
export default function SkeletonRecipeStats () {
const [ loading , setLoading ] = useState ( true );
useEffect (() => {
if ( ! loading) return ;
const timer = setTimeout (() => setLoading ( false ), 1500 );
return () => clearTimeout (timer);
}, [loading]);
return (
< div className = "flex w-full max-w-2xl flex-col gap-3" >
< div aria-busy = {loading} className = "grid gap-3 sm:grid-cols-3" >
{stats. map (( s ) => (
< Card key = {s.label}>
< CardContent className = "flex flex-col gap-2" >
< span className = "text-muted-foreground text-xs" >{s.label}</ span >
{loading ? (
<>
< Skeleton animation = "shimmer" className = "h-8 w-24" />
< Skeleton animation = "shimmer" className = "h-4 w-16" />
</>
) : (
<>
< span className = "font-semibold text-2xl tabular-nums leading-8" >
{s.value}
</ span >
< span
className = { `flex h-4 items-center gap-1 text-xs ${ s . change >= 0 ? "text-success" : "text-destructive"}` }
>
{s.change >= 0 ? (
< ArrowUpRightIcon className = "size-3.5" />
) : (
< ArrowDownRightIcon className = "size-3.5" />
)}
{Math. abs (s.change)}% vs last month
</ span >
</>
)}
</ CardContent >
</ Card >
))}
</ div >
< Button
size = "sm"
variant = "ghost"
className = "self-start"
isDisabled = {loading}
onPress = {() => setLoading ( true )}
>
Reload
</ Button >
</ div >
);
}
A grid of shimmering thumbnails with author and title lines, for galleries and template pickers.
import { Skeleton } from "@/components/ui/skeleton" ;
export default function SkeletonRecipeMediaGrid () {
return (
< section
aria-busy = "true"
aria-label = "Templates"
className = "grid w-full max-w-2xl grid-cols-2 gap-4 sm:grid-cols-3"
>
{Array. from ({ length: 6 }, ( _ , i ) => i). map (( i ) => (
< div key = {i} className = "flex flex-col gap-2.5" >
< Skeleton
animation = "shimmer"
className = "aspect-video w-full rounded-lg"
/>
< div className = "flex items-center gap-2" >
< Skeleton
animation = "shimmer"
className = "size-6 shrink-0 rounded-full"
/>
< Skeleton animation = "shimmer" className = "h-3.5 flex-1" />
</ div >
< Skeleton animation = "shimmer" className = "h-3 w-2/3" />
</ div >
))}
</ section >
);
}
Every skeleton is aria-hidden, so placeholders are never read out.
Put aria-busy="true" on the region that is loading and remove it when content arrives. Give the region a label (or keep a visible heading) so users know what is loading.
If users need to know when loading finishes, announce it with a live region (aria-live="polite") or a toast .
Respects prefers-reduced-motion: the pulse stops (motion-reduce:animate-none) and the shimmer highlight is removed (motion-reduce:before:hidden), leaving a static bg-muted block with the same shape, so the loading layout stays clear without motion.
Slot Element data-slot="skeleton"Every skeleton
Size and shape come entirely from className: h-*, w-*, size-*, aspect-* and rounded-*.
Change the color with a background class, e.g. bg-foreground/10 on muted surfaces.
The shimmer highlight is via-foreground/[0.06] on the ::before element; override it with before:via-….
Also accepts every prop of <div>. aria-hidden is set by default.
Spinner — a compact loading indicator.
Progress Bar — measurable progress for long tasks.
Card and Table — common containers for skeleton layouts.
PreviousProgress Circle Next Spinner