Skeleton
A muted placeholder block that stands in for content while it loads.
Vibra's skeleton pulses only for readers who have not asked for less motion — by the OS setting or a [data-motion="reduced"] above it — where shadcn's always does, and a shade quicker, at 1.2s rather than 2s; SkeletonReveal fades the loaded content in over --duration-slow, so a page settling reads as one movement. Keep what you already know — a column title, a card title, a field name — and draw only what is on its way, in its shape. The skeletons are decoration: hide them from screen readers, mark the region that is filling aria-busy, and say what is loading in words, once for the region. LoadingSkeletons has ready shapes for a stat, a table, a chart, a list and a form.
Install
npx shadcn@latest add @vibra/skeletonNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Skeleton } from "@/components/ui/skeleton"
export default function SkeletonDemo() {
return (
// The list that is filling is busy, and says so once in words; the
// placeholders themselves are hidden from screen readers.
<div aria-busy="true" className="flex w-full max-w-sm flex-col gap-4">
<span role="status" className="sr-only">
Loading customers…
</span>
{[0, 1, 2].map((row) => (
<div key={row} aria-hidden="true" className="flex items-center gap-3">
<Skeleton className="size-9 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-3.5 w-2/5" />
<Skeleton className="h-3 w-3/4" />
</div>
<Skeleton className="h-5 w-14 rounded-full" />
</div>
))}
</div>
)
}A row of figures
The card titles stay; the numeral and its delta wait in their own shapes, and the row is one busy region.
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
const TILES = ["Revenue", "Orders", "Refunds"]
// The titles are known before the figures are, so they stay: only the numeral
// and its delta wait, as a bar the numeral's height and a pill the delta's.
// The row is one busy region, said once in words rather than once a tile.
export default function SkeletonStatCards() {
return (
<div aria-busy="true" className="grid w-full max-w-2xl gap-3 sm:grid-cols-3">
<span role="status" className="sr-only">
Loading the figures for September…
</span>
{TILES.map((title) => (
<Card key={title} size="sm">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent aria-hidden="true" className="flex flex-col gap-2">
<Skeleton className="h-8 w-28" />
<Skeleton className="h-4 w-16 rounded-full" />
</CardContent>
</Card>
))}
</div>
)
}Table rows
The column titles are the table's own and only the body waits, the rows hidden from screen readers.
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const COLUMNS = [
{ title: "Order", bar: "w-20" },
{ title: "Customer", bar: "w-28" },
{ title: "Status", bar: "w-16 rounded-full" },
{ title: "Total", bar: "w-14 ms-auto", numeric: true },
]
// Widths that differ row to row, so the rows read as rows of text rather than a
// grid of identical bars; fixed, so the server and the browser draw the same.
const SPREAD = ["", "w-24", "w-16", "w-20", "w-12"]
// The column titles are real and the rows are not yet, so the header is the
// table's own and only the body waits. The rows are hidden from screen
// readers; the table is busy and a status line says what is coming.
export default function SkeletonTable() {
return (
<div className="flex w-full max-w-xl flex-col gap-2">
<Table aria-label="Latest orders" aria-busy="true">
<TableHeader>
<TableRow>
{COLUMNS.map((column) => (
<TableHead key={column.title} className={cn(column.numeric && "text-right")}>
{column.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{[0, 1, 2, 3, 4].map((row) => (
<TableRow key={row} aria-hidden="true" className="hover:bg-transparent">
{COLUMNS.map((column, index) => (
<TableCell key={column.title}>
<Skeleton className={cn("h-3.5", column.bar, index === 1 && SPREAD[row])} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<p role="status" className="text-xs text-muted-foreground">
Loading the latest orders…
</p>
</div>
)
}Paragraphs
A release note's body as lines of prose, each paragraph ending short, under the heading that is already known.
import * as React from "react"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
// Two paragraphs of a release note on their way: full lines, then a short one
// to end each paragraph, and a gap between them, so the shape reads as prose
// before a word has arrived. The heading is already known and stays real.
const PARAGRAPHS = [
["w-full", "w-full", "w-11/12", "w-2/5"],
["w-full", "w-10/12", "w-3/5"],
]
export default function SkeletonText() {
const titleId = React.useId()
return (
<article aria-labelledby={titleId} aria-busy="true" className="flex w-full max-w-md flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="font-mono text-xs text-muted-foreground">4.3.0 · Aug 31, 2026</span>
<h3 id={titleId} className="text-base font-semibold">
Passkeys, and a faster orders table
</h3>
</div>
<span role="status" className="sr-only">
Loading the release note…
</span>
{PARAGRAPHS.map((lines, paragraph) => (
<div key={paragraph} aria-hidden="true" className="flex flex-col gap-2.5">
{lines.map((width, line) => (
<Skeleton key={line} className={cn("h-3", width)} />
))}
</div>
))}
</article>
)
}A record page
A customer's face, name and values in their shapes beside the field names, which are the page's own.
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
const FIELDS = [
["Plan", "w-16"],
["Seats", "w-10"],
["Monthly revenue", "w-20"],
["Account owner", "w-28"],
["Customer since", "w-24"],
]
// A customer's page before the record arrives. The field names are the page's
// own, so they stay; the face, the name and every value wait in the shape they
// will fill — a circle, a title's line, a short bar per value.
export default function SkeletonProfile() {
return (
<section aria-label="Customer" aria-busy="true" className="flex w-full max-w-md flex-col gap-5">
<span role="status" className="sr-only">
Loading the customer…
</span>
<div aria-hidden="true" className="flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-28" />
</div>
</div>
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-6 gap-y-3 text-sm">
{FIELDS.map(([label, width]) => (
<div key={label} className="contents">
<dt className="text-muted-foreground">{label}</dt>
<dd aria-hidden="true">
<Skeleton className={cn("h-3.5", width)} />
</dd>
</div>
))}
</dl>
</section>
)
}Then the content
SkeletonReveal fades the rows in where the skeleton stood; the list is busy only while it waits.
"use client"
import * as React from "react"
import { RefreshCwIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Skeleton, SkeletonReveal } from "@/components/ui/skeleton"
const INVOICES = [
["INV-100008", "Northwind Studio", 168],
["INV-100044", "Alder Robotics", 864],
["INV-100018", "Fathom Media", 776.85],
] as const
// A list that is fetched again on request. The skeleton holds the rows' place
// while it waits, then the rows fade in over it (SkeletonReveal) rather than
// popping in — or simply appear, for a reader who asked for less motion. The
// list is busy only while it waits, and the line under it says what happened.
export default function SkeletonRevealExample() {
const [loading, setLoading] = React.useState(false)
const [loads, setLoads] = React.useState(0)
React.useEffect(() => {
if (!loading) return
const timer = window.setTimeout(() => {
setLoading(false)
setLoads((count) => count + 1)
}, 1200)
return () => window.clearTimeout(timer)
}, [loading])
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<ul aria-label="Invoices due this month" aria-busy={loading} className="flex min-h-32 flex-col divide-y divide-border panel px-3">
{loading
? INVOICES.map(([number]) => (
<li key={number} aria-hidden="true" className="flex items-center justify-between py-3">
<Skeleton className="h-3.5 w-36" />
<Skeleton className="h-3.5 w-14" />
</li>
))
: INVOICES.map(([number, company, amount]) => (
<li key={number}>
<SkeletonReveal className="flex items-center justify-between gap-3 py-2.5 text-sm">
<span>
{company}
<span className="block font-mono text-xs text-muted-foreground">{number}</span>
</span>
<span className="tabular-nums">{formatCurrency(amount)}</span>
</SkeletonReveal>
</li>
))}
</ul>
<div className="flex items-center justify-between gap-2">
<p role="status" className="text-xs text-muted-foreground">
{loading ? "Loading invoices…" : loads ? "3 invoices, just now." : "3 invoices due this month."}
</p>
<Button size="sm" variant="outline" disabled={loading} focusableWhenDisabled onClick={() => setLoading(true)}>
<RefreshCwIcon data-icon="inline-start" aria-hidden="true" />
Reload
</Button>
</div>
</div>
)
}Held still when asked
An app's own Reduce motion switch sets data-motion="reduced", which stops the pulse as the OS setting does.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
// A settings preview for the app's own "Reduce motion" switch. It sets
// data-motion="reduced" on the page, which stops the skeletons' pulse as the
// reader's OS setting does — every motion token in the theme answers the same
// two switches. The list below is loading either way: only the pulse stops.
export default function SkeletonStill() {
const [still, setStill] = React.useState(false)
const id = React.useId()
return (
<div data-motion={still ? "reduced" : undefined} className="flex w-full max-w-sm flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor={id}>Reduce motion</Label>
<p id={`${id}-hint`} className="text-sm text-muted-foreground">
Loaders hold still, and panels appear without sliding.
</p>
</div>
<Switch id={id} checked={still} onCheckedChange={setStill} aria-describedby={`${id}-hint`} />
</div>
<div aria-busy="true" className="flex flex-col gap-3 panel p-4">
<span role="status" className="sr-only">
Loading teammates…
</span>
{["w-32", "w-24", "w-28"].map((width) => (
<div key={width} aria-hidden="true" className="flex items-center gap-3">
<Skeleton className="size-8 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className={cn("h-3", width)} />
<Skeleton className="h-2.5 w-20" />
</div>
</div>
))}
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| SkeletonReveal | React.ComponentProps<"div"> | — | Wraps what replaces a skeleton and fades it in; under reduced motion it simply appears. |
Dependencies
Registry
Source
import { cn } from "@/lib/utils"
/**
* The placeholder plane: the warm recessed track, breathing at 1.2s.
*
* `motion-safe:` rather than an unconditional animation, so a reader who has
* asked the system for less motion gets a still block instead of a page of
* pulsing rectangles — and so the reduced-motion census finds nothing running
* after load. The theme's other switch, a `[data-motion="reduced"]` a page or
* a preview frame sets above its content, stops it too: a loop takes no
* duration token, so it has to not run rather than run in zero time. The pace
* is a shade quicker than Tailwind's 2s default: at two seconds a table of
* forty bars reads as a page that has stalled.
*/
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn(
"rounded-md bg-muted motion-safe:not-in-data-[motion=reduced]:animate-pulse motion-safe:[animation-duration:1.2s]",
className
)}
{...props}
/>
)
}
/**
* What replaces a skeleton, fading in over `--duration-slow` rather than
* appearing outright — the same beat the sheets enter on, so a page settling
* reads as one movement instead of a series of pops.
*/
function SkeletonReveal({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton-reveal"
className={cn(
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-(--duration-slow) motion-safe:ease-(--ease-standard)",
className
)}
{...props}
/>
)
}
export { Skeleton, SkeletonReveal }