Skeleton
Shimmering placeholders shaped like the content that is loading, with presets for text, rows, tables, cards, stats and charts.
Recent remittances
import { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { SkeletonRows } from "@oration/canon/components/skeletons";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function Hero() { const [loading, setLoading] = React.useState(true); React.useEffect(() => { if (!loading) return; const id = window.setTimeout(() => setLoading(false), 1400); return () => window.clearTimeout(id); }, [loading]); const remittances = [ { id: "RM-20931", supplier: "Northwind Freight", invoices: 14, amount: "$48,210.00", color: "teal", }, { id: "RM-20928", supplier: "Halcyon Logistics", invoices: 6, amount: "$12,904.50", color: "blue", }, { id: "RM-20925", supplier: "Orchard Street Supply", invoices: 22, amount: "$91,377.12", color: "green", }, { id: "RM-20919", supplier: "Brightline Packaging", invoices: 3, amount: "$4,120.00", color: "amber", }, ] as const; return ( <div className="w-full max-w-md rounded-xl bg-card shadow-border"> <div className="flex items-center justify-between gap-3 px-4 pt-3 pb-1"> <h3 className="text-sm font-semibold text-foreground"> Recent remittances </h3> <Button type="button" variant="ghost" size="sm" disabled={loading} onClick={() => setLoading(true)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Reload </Button> </div> <SkeletonReveal loading={loading} label="Loading remittances" className="px-2 pb-2" skeleton={<SkeletonRows rows={4} avatar="square" />} > <ul> {remittances.map((remittance) => ( <li key={remittance.id} className="flex min-h-13 items-center gap-3 px-2 py-2" > <MonogramTile name={remittance.supplier} color={remittance.color} size="lg" /> <div className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium text-foreground"> {remittance.supplier} </span> <span className="truncate text-xs text-muted-foreground"> Remittance {remittance.id},{" "} {remittance.invoices} invoices </span> </div> <span className="shrink-0 text-13 font-medium text-foreground tabular-nums"> {remittance.amount} </span> </li> ))} </ul> </SkeletonReveal> </div> );}Usage#
Skeletons stand in for data while it loads, drawn in the shape of what will replace them so the swap moves nothing. Every data component in Oration owns its own loading state, so a page renders its chrome at once and only the regions still fetching show bones. Reach for a preset first (SkeletonText, SkeletonRows, SkeletonTable, SkeletonCard, SkeletonStat, SkeletonChart, SkeletonList) and compose from SkeletonBone when the shape differs. The common mistake is a generic block or a spinner that has nothing to do with the final layout, so everything jumps when the data lands.
When to use
- For any list, table, card, stat strip or chart whose data arrives after first paint, as the
skeletonof a Data state. - When the final layout is known: rows of suppliers, a grid of invoices, a remittance card, a chart plot.
- For enrichment cells that are queued: an 8px bone whose width varies by row.
- When a load can take longer than about 300ms. Faster than that, render nothing and let the content arrive.
When not to use
- For work the person started, such as saving or testing a connection. Show progress on the control that started it. Use Pending button
- For a small inline wait with no layout to preview, such as checking a slug. Use Spinner
- For an AI model thinking or drafting. It has its own pixel shimmer and elapsed time. Use AI loader
- For a task with a known amount done, such as importing 1,240 suppliers. Use Progress
- For a place that loaded and has nothing in it. Say what it is for and offer the first action. Use Empty
Skeletons match the final layout
Never gate a whole page
Skeletons leave through the skeleton reveal
DataState's skeleton prop, or to SkeletonReveal's skeleton prop when the load isn't a query (a lazy mount, a link check, a loading flag). Never render it from your own loading ? … : …. See The Skeleton Reveal Rule.Anatomy#
- Leading tile. A 32px bone (24px on one-line rows), round for people and
rounded-lgfor companies and agents viaavatar. - Title bone. 12px tall, round-ended, with a width taken from a fixed ragged sequence so rows don't look stamped.
- Secondary bone. 10px tall and shorter than the title. Only on two-line rows (
lines={2}). - Meta bone. A 48px trailing bone for the amount, date or count. Turn it off with
meta={false}. - Row. 52px minimum for two lines and 36px for one, with 8px side padding and a 12px gap: the same box as the real row.
Examples#
Text
SkeletonText draws lines at the 20px body rhythm, or 16px with size="caption". Widths are ragged by default; pass widths when the text has a known length.
import { SkeletonText } from "@oration/canon/components/skeletons";export function TextPreset() { return ( <div className="grid w-full max-w-2xl gap-8 sm:grid-cols-3"> <div className="flex flex-col gap-3"> <SkeletonText /> <span className="text-xs text-muted-foreground"> Body, three lines </span> </div> <div className="flex flex-col gap-3"> <SkeletonText lines={4} size="caption" /> <span className="text-xs text-muted-foreground"> Caption, four lines </span> </div> <div className="flex flex-col gap-3"> <SkeletonText lines={2} widths={[100, "9rem"]} /> <span className="text-xs text-muted-foreground"> Fixed widths </span> </div> </div> );}Rows
SkeletonRows is the list row: a round or square tile, one or two lines and trailing meta, at 52px (two lines) or 36px (one line).
import { SkeletonRows } from "@oration/canon/components/skeletons";export function RowsPreset() { return ( <div className="grid w-full max-w-3xl gap-x-8 gap-y-6 sm:grid-cols-2"> <div className="flex flex-col gap-2"> <SkeletonRows rows={3} /> <span className="px-2 text-xs text-muted-foreground"> Default: circle, two lines, meta </span> </div> <div className="flex flex-col gap-2"> <SkeletonRows rows={3} avatar="square" /> <span className="px-2 text-xs text-muted-foreground"> Square tile for companies and agents </span> </div> <div className="flex flex-col gap-2"> <SkeletonRows rows={3} lines={1} avatar={false} meta={false} /> <span className="px-2 text-xs text-muted-foreground"> One line, no tile, no meta </span> </div> <div className="flex flex-col gap-2"> <SkeletonRows rows={3} lines={1} divided /> <span className="px-2 text-xs text-muted-foreground"> One line with hairline dividers </span> </div> </div> );}Table
SkeletonTable matches the data grid: a 32px header over 36px rows with hairline rules, a wider first column with a tile and a right-aligned numeric last column.
import { SkeletonTable } from "@oration/canon/components/skeletons";export function TablePreset() { return ( <div className="flex w-full flex-col gap-8"> <SkeletonTable rows={5} columns={5} /> <div className="flex w-full max-w-md flex-col gap-2"> <SkeletonTable rows={3} columns={3} header={false} /> <span className="text-xs text-muted-foreground"> Without a header, for a table that keeps its real header row </span> </div> </div> );}Card
SkeletonCard brings its own card. Add footer for tag rows and media for a 16:9 well.
import { SkeletonCard } from "@oration/canon/components/skeletons";export function CardPreset() { return ( <div className="grid w-full max-w-3xl items-start gap-4 sm:grid-cols-3"> <SkeletonCard /> <SkeletonCard lines={3} footer /> <SkeletonCard lines={1} media /> </div> );}Stat strip
SkeletonStat is cell for cell with Stat strip, including the two-column fold below 640px. trend adds a sparkline bone.
import { SkeletonStat } from "@oration/canon/components/skeletons";export function StatPreset() { return ( <div className="flex w-full flex-col gap-6"> <SkeletonStat count={4} /> <SkeletonStat count={3} trend /> </div> );}Chart
SkeletonChart rules the plot's gridlines and draws bars or a flat line, so the chart container keeps its height.
import { SkeletonChart } from "@oration/canon/components/skeletons";export function ChartPreset() { return ( <div className="grid w-full max-w-3xl gap-8 sm:grid-cols-2"> <div className="flex flex-col gap-3"> <SkeletonChart /> <span className="text-xs text-muted-foreground"> Bars, 12 by default, with axis ticks </span> </div> <div className="flex flex-col gap-3"> <SkeletonChart type="line" height={120} axis={false} /> <span className="text-xs text-muted-foreground"> Line, 120px, without the axis </span> </div> </div> );}List
SkeletonList is for 32px rail and menu items with an icon and a trailing count.
import { SkeletonList } from "@oration/canon/components/skeletons";export function ListPreset() { return ( <div className="grid w-full max-w-xl gap-8 sm:grid-cols-2"> <div className="flex flex-col gap-2"> <SkeletonList items={5} /> <span className="px-2 text-xs text-muted-foreground"> With icons, like a rail </span> </div> <div className="flex flex-col gap-2"> <SkeletonList items={5} icon={false} /> <span className="px-2 text-xs text-muted-foreground"> Text only, like a menu </span> </div> </div> );}Custom shapes
When no preset fits, compose SkeletonBones in the real layout. This one stands in for a supplier record header: tile, name, tags and a summary well.
import { SkeletonBone } from "@oration/canon/components/skeletons";export function CustomShape() { return ( <div aria-hidden="true" className="flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 shadow-border" > <div className="flex items-center gap-3"> <SkeletonBone className="size-10 shrink-0 rounded-[10px]" /> <div className="flex min-w-0 flex-1 flex-col gap-2"> <SkeletonBone className="h-3.5 w-1/2" /> <SkeletonBone className="h-2.5 w-1/3" /> </div> </div> <div className="flex gap-1.5"> <SkeletonBone className="h-5 w-16 rounded-md" /> <SkeletonBone className="h-5 w-20 rounded-md" /> <SkeletonBone className="h-5 w-12 rounded-md" /> </div> <SkeletonBone className="h-16 w-full rounded-[10px]" /> </div> );}Base skeleton
The plain Skeleton block pulses instead of shimmering. It is what the bones are built on; use it only where a pulse is wanted.
import { Skeleton } from "@oration/canon/components/skeleton";export function BaseSkeleton() { return ( <div aria-hidden="true" className="flex w-full max-w-sm items-center gap-3" > <Skeleton className="size-10 rounded-full" /> <div className="flex min-w-0 flex-1 flex-col gap-2"> <Skeleton className="h-3.5 w-2/5" /> <Skeleton className="h-3 w-3/5" /> </div> </div> );}Loading a payment run
The skeleton and the loaded grid share the same column template and row heights, so nothing shifts when the invoices arrive. Reload to watch the swap.
Payment run for Friday, Oct 2
import { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { SkeletonTable } from "@oration/canon/components/skeletons";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function PaymentRunTable() { const [loading, setLoading] = React.useState(true); React.useEffect(() => { if (!loading) return; const id = window.setTimeout(() => setLoading(false), 1600); return () => window.clearTimeout(id); }, [loading]); const invoices = [ { supplier: "Northwind Freight", color: "teal", count: 14, due: "Oct 2", amount: "$48,210.00", }, { supplier: "Halcyon Logistics", color: "blue", count: 6, due: "Oct 2", amount: "$12,904.50", }, { supplier: "Orchard Street Supply", color: "green", count: 22, due: "Oct 5", amount: "$91,377.12", }, { supplier: "Brightline Packaging", color: "amber", count: 3, due: "Oct 9", amount: "$4,120.00", }, ] as const; return ( <div className="flex w-full flex-col gap-3"> <div className="flex items-center justify-between gap-3"> <p className="text-13 text-muted-foreground"> Payment run for Friday, Oct 2 </p> <Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => setLoading(true)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Reload </Button> </div> <SkeletonReveal loading={loading} label="Loading invoices" className="w-full text-13" skeleton={<SkeletonTable rows={4} columns={4} />} > <table className="w-full table-fixed"> <caption className="sr-only"> Invoices in this payment run </caption> <colgroup> <col className="w-2/5" /> <col className="w-1/5" /> <col className="w-1/5" /> <col className="w-1/5" /> </colgroup> <thead> <tr className="h-8 border-b border-border text-left text-muted-foreground"> <th scope="col" className="px-2 font-medium"> Supplier </th> <th scope="col" className="px-2 font-medium"> Invoices </th> <th scope="col" className="px-2 font-medium"> Due </th> <th scope="col" className="px-2 text-right font-medium" > Amount </th> </tr> </thead> <tbody> {invoices.map((row) => ( <tr key={row.supplier} className="h-9 border-b border-border" > <td className="px-2"> <span className="flex min-w-0 items-center gap-2 text-foreground"> <MonogramTile name={row.supplier} color={row.color} size="sm" /> <span className="truncate"> {row.supplier} </span> </span> </td> <td className="px-2 tabular-nums"> {row.count} </td> <td className="px-2">{row.due}</td> <td className="px-2 text-right font-medium tabular-nums"> {row.amount} </td> </tr> ))} </tbody> </table> </SkeletonReveal> </div> );}States#
import { Skeleton } from "@oration/canon/components/skeleton";import { SkeletonBone } from "@oration/canon/components/skeletons";export function Motion() { return ( <div className="grid w-full max-w-2xl gap-8 sm:grid-cols-3"> <div className="flex flex-col gap-2"> <SkeletonBone className="h-3 w-4/5" /> <SkeletonBone className="h-3 w-1/2" /> <span className="mt-1 text-xs text-muted-foreground"> Bone: shared shimmer </span> </div> <div className="flex flex-col gap-2"> <Skeleton className="h-3 w-4/5 rounded-full" /> <Skeleton className="h-3 w-1/2 rounded-full" /> <span className="mt-1 text-xs text-muted-foreground"> Base skeleton: opacity pulse </span> </div> <div className="flex flex-col gap-2"> <SkeletonBone className="h-3 w-4/5" style={{ animation: "none" }} /> <SkeletonBone className="h-3 w-1/2" style={{ animation: "none" }} /> <span className="mt-1 text-xs text-muted-foreground"> Reduced motion: still </span> </div> </div> );}| State | Treatment |
|---|---|
| Loading | Bones on Well Gray with a light sweep every 1.6s. background-attachment: fixed lines every bone up to one sweep across the viewport. |
| Reduced motion | The sweep stops (motion-reduce:animate-none plus the global .skeleton-shimmer rule). Bones stay as still Well Gray shapes. |
| Base skeleton | The plain Skeleton pulses its opacity with animate-pulse instead of shimmering, and keeps pulsing under reduced motion. |
| Loaded | The parent swaps bones for content through the skeleton reveal (DataState or SkeletonReveal): the bones pulse, then cross-fade out with a 2px blur over 400ms as the content sharpens in. Data that is already warm renders with no skeleton at all. |
Behavior#
- Presets are server-safe function components with no state. They render bones only; the parent decides when to show them.
- Widths come from a fixed ragged sequence (92%, 70%, 84%, 58%…), so renders are deterministic and never cause hydration mismatches.
SkeletonTableuses the grid templateminmax(0, 2fr) repeat(n - 1, minmax(0, 1fr)): a wider first column with a 20px tile, and the last column right-aligned like a numeric column.SkeletonStatis cell for cell with Stat strip: two columns below 640px, one row of equal cells above, hairlines between cells.SkeletonCarddraws its own card (12px corners, hairline lift). Don't wrap it in another card.SkeletonChartrules four gridlines (three dashed, the baseline solid) and draws bars from a fixed height sequence, or a flat curve at 10% ink fortype="line".- Inside Data state, a sr-only Loading and
aria-busyare added for you. Hand-rolled loading regions must add both.
Do and don't#
Suppliers
Suppliers
Payment runs
SkeletonBone so every bone shares one sweep.Skeleton blocks, shimmering bones and a spinner in one region. Three rhythms read as three problems.skeleton to DataState or SkeletonReveal, so they pulse and then cross-fade into the content.loading ? <SkeletonRows /> : <List />. The content snaps in with no reveal.Content#
- Skeletons carry no visible text. Don't write Loading… inside the bones.
- Name what is loading for screen readers: a sr-only Loading remittances beats a bare Loading.
- If a load runs long, add one quiet line beside the bones after a delay (
useDelayedFlag), such as Still loading invoices, rather than a bigger animation. - When the load fails, replace the skeleton with an error that says what failed and offers Retry, never with an empty state.
Accessibility#
- Every preset and
SkeletonBoneinside them isaria-hidden="true": bones are decoration. - The base
Skeletonis a plain<div>with no ARIA. Mark hand-rolled compositionsaria-hidden="true". - The region that is loading carries
aria-busyand a sr-only status naming what is loading. Data state does both. - Don't move focus into or out of a loading region; when content arrives, focus stays where it was.
- The shimmer stops under
prefers-reduced-motion. The base Skeleton's pulse does not; prefer bones.
Design tokens#
| Token | Used for |
|---|---|
--muted | Bone fill (Well Gray) |
skeleton-shimmer | The sweep: Well Gray to Well Gray plus 5% ink and back, 200% wide |
--animate-shimmer | shimmer 1.6s linear infinite |
animate-pulse | Opacity pulse on the base Skeleton |
--border | Table rules, list dividers and chart gridlines |
--card, shadow-border | The card of SkeletonCard and SkeletonStat |
rounded-full, --radius-lg | Round bones; 10px tiles and wells |
API reference#
Skeleton
The base block from @oration/canon/components/skeleton: Well Gray, 6px corners, opacity pulse. Prefer SkeletonBone.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Size and shape, such as h-3 w-2/5 or size-8 rounded-lg. |
SkeletonBone
One shimmering bone, round-ended by default. The building block for custom shapes.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Size and corner overrides, such as h-5 w-16 rounded-md. |
style | React.CSSProperties | No default | Merged after backgroundAttachment: fixed, for computed widths. |
SkeletonText
Lines of text at the 20px body rhythm (16px for captions).
| Prop | Type | Default | Description |
|---|---|---|---|
lines | number | 3 | Number of lines. The last of several ends at 58%. |
widths | (string | number)[] | No default | Width per line. Numbers are percentages; strings pass through ("9rem"). |
size | "body" | "caption" | "body" | 10px bones on 20px lines, or 8px bones on 16px lines. |
className | string | No default | On the wrapper. |
SkeletonRows
List rows with an optional tile, one or two lines and trailing meta.
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 5 | Row count. |
avatar | boolean | "circle" | "square" | "circle" | Leading tile shape. true is a circle, false removes it. |
lines | 1 | 2 | 2 | One line (36px rows, 24px tile) or two (52px, 32px). |
meta | boolean | true | Trailing 48px bone. |
divided | boolean | false | Hairline dividers between rows. |
className | string | No default | On the wrapper. |
SkeletonTable
A data grid: a 32px header over 36px rows, the first column wider, the last numeric.
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 8 | Body rows. |
columns | number | 5 | Columns, including the wide first one. |
header | boolean | true | Draw the header row. |
className | string | No default | On the wrapper. |
SkeletonCard
A card with a 32px header tile, title, text lines and an optional media well or footer.
| Prop | Type | Default | Description |
|---|---|---|---|
lines | number | 2 | Body lines. 0 draws only the header. |
media | boolean | false | A 16:9 well above the header. |
footer | boolean | false | Two tag bones and a trailing meta bone. |
className | string | No default | On the card. |
SkeletonStat
A stat strip placeholder, cell for cell with StatStrip.
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | 4 | Cells. |
trend | boolean | false | A trailing sparkline bone per cell, hidden below 640px. |
className | string | No default | On the strip. |
SkeletonChart
A chart plot with ruled gridlines and bars or a line.
| Prop | Type | Default | Description |
|---|---|---|---|
type | "bar" | "line" | "bar" | Placeholder bars, or a flat curve at 10% ink. |
height | number | 160 | Plot height in pixels. |
bars | number | 12 | Bar count. |
axis | boolean | true | Five tick bones under the plot. |
className | string | No default | On the wrapper. |
SkeletonList
Compact 32px items with an icon and a trailing count, like a rail or menu.
| Prop | Type | Default | Description |
|---|---|---|---|
items | number | 5 | Items. |
icon | boolean | true | Leading 16px icon bone. |
className | string | No default | On the wrapper. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The base Skeleton pulses with animate-pulse and has no reduced-motion guard, while every preset shimmers and stops. About 18 files in apps/web still use the base block directly, so two loading rhythms coexist. Use SkeletonBone for custom shapes.
The base Skeleton is not aria-hidden. Hand-rolled compositions must add aria-hidden="true" themselves.
Skeleton is exported from @oration/canon/components/skeleton, not from skeletons where the presets live, and SkeletonBone is not in the registry's export list.
SkeletonBone accepts only className and style; other <div> props are dropped.