Skip to content

Skeleton

Shimmering placeholders shaped like the content that is loading, with presets for text, rows, tables, cards, stats and charts.

Status
Stable
Level
Atom
Category
Feedback
Adoption
Not used yet
import { Skeleton } from "@oration/canon/components/skeletons";
packages/canon/src/components/skeletons.tsx

Recent remittances

Loading 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 skeleton of 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

A skeleton takes the row height, padding, tile size and column template of the content it stands in for, so the reveal to content moves nothing. A 52px supplier row loads as a 52px bone row, a 36px grid row as a 36px bone row.

Never gate a whole page

Each data component owns its loading, empty and error states. Titles, filters and actions render immediately; only the data region shows bones.

Skeletons leave through the skeleton reveal

Hand every loading skeleton to 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#

  1. Leading tile. A 32px bone (24px on one-line rows), round for people and rounded-lg for companies and agents via avatar.
  2. Title bone. 12px tall, round-ended, with a width taken from a fixed ragged sequence so rows don't look stamped.
  3. Secondary bone. 10px tall and shorter than the title. Only on two-line rows (lines={2}).
  4. Meta bone. A 48px trailing bone for the amount, date or count. Turn it off with meta={false}.
  5. 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.

Body, three lines
Caption, four lines
Fixed widths
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).

Default: circle, two lines, meta
Square tile for companies and agents
One line, no tile, no meta
One line with hairline dividers
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.

Without a header, for a table that keeps its real header row
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.

Bars, 12 by default, with axis ticks
Line, 120px, without the axis
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.

With icons, like a rail
Text only, like a menu
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

Loading invoices
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#

Bone: shared shimmer
Base skeleton: opacity pulse
Reduced motion: still
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>    );}
States
StateTreatment
LoadingBones on Well Gray with a light sweep every 1.6s. background-attachment: fixed lines every bone up to one sweep across the viewport.
Reduced motionThe sweep stops (motion-reduce:animate-none plus the global .skeleton-shimmer rule). Bones stay as still Well Gray shapes.
Base skeletonThe plain Skeleton pulses its opacity with animate-pulse instead of shimmering, and keeps pulsing under reduced motion.
LoadedThe 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.
  • SkeletonTable uses the grid template minmax(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.
  • SkeletonStat is cell for cell with Stat strip: two columns below 640px, one row of equal cells above, hairlines between cells.
  • SkeletonCard draws its own card (12px corners, hairline lift). Don't wrap it in another card.
  • SkeletonChart rules four gridlines (three dashed, the baseline solid) and draws bars from a fixed height sequence, or a flat curve at 10% ink for type="line".
  • Inside Data state, a sr-only Loading and aria-busy are added for you. Hand-rolled loading regions must add both.

Do and don't#

Suppliers

Do. Load a list as rows of the same height, tile and padding as the real rows.

Suppliers

Don't. Park a spinner in a box of a made-up height. The card jumps when the rows arrive.

Payment runs

Do. Keep the title, filters and actions real and skeleton only the data region.
Don't. Turn the whole card, headings and buttons included, into bones. People lose their place and can't act.
Do. Build custom shapes from SkeletonBone so every bone shares one sweep.
Don't. Mix pulsing Skeleton blocks, shimmering bones and a spinner in one region. Three rhythms read as three problems.
Do. Pass the bones as skeleton to DataState or SkeletonReveal, so they pulse and then cross-fade into the content.
Don't. Write 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 SkeletonBone inside them is aria-hidden="true": bones are decoration.
  • The base Skeleton is a plain <div> with no ARIA. Mark hand-rolled compositions aria-hidden="true".
  • The region that is loading carries aria-busy and 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#

Design tokens
TokenUsed for
--mutedBone fill (Well Gray)
skeleton-shimmerThe sweep: Well Gray to Well Gray plus 5% ink and back, 200% wide
--animate-shimmershimmer 1.6s linear infinite
animate-pulseOpacity pulse on the base Skeleton
--borderTable rules, list dividers and chart gridlines
--card, shadow-borderThe card of SkeletonCard and SkeletonStat
rounded-full, --radius-lgRound 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>.

Props of Skeleton
PropTypeDefaultDescription
classNamestringNo defaultSize 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.

Props of SkeletonBone
PropTypeDefaultDescription
classNamestringNo defaultSize and corner overrides, such as h-5 w-16 rounded-md.
styleReact.CSSPropertiesNo defaultMerged after backgroundAttachment: fixed, for computed widths.

SkeletonText

Lines of text at the 20px body rhythm (16px for captions).

Props of SkeletonText
PropTypeDefaultDescription
linesnumber3Number of lines. The last of several ends at 58%.
widths(string | number)[]No defaultWidth per line. Numbers are percentages; strings pass through ("9rem").
size"body" | "caption""body"10px bones on 20px lines, or 8px bones on 16px lines.
classNamestringNo defaultOn the wrapper.

SkeletonRows

List rows with an optional tile, one or two lines and trailing meta.

Props of SkeletonRows
PropTypeDefaultDescription
rowsnumber5Row count.
avatarboolean | "circle" | "square""circle"Leading tile shape. true is a circle, false removes it.
lines1 | 22One line (36px rows, 24px tile) or two (52px, 32px).
metabooleantrueTrailing 48px bone.
dividedbooleanfalseHairline dividers between rows.
classNamestringNo defaultOn the wrapper.

SkeletonTable

A data grid: a 32px header over 36px rows, the first column wider, the last numeric.

Props of SkeletonTable
PropTypeDefaultDescription
rowsnumber8Body rows.
columnsnumber5Columns, including the wide first one.
headerbooleantrueDraw the header row.
classNamestringNo defaultOn the wrapper.

SkeletonCard

A card with a 32px header tile, title, text lines and an optional media well or footer.

Props of SkeletonCard
PropTypeDefaultDescription
linesnumber2Body lines. 0 draws only the header.
mediabooleanfalseA 16:9 well above the header.
footerbooleanfalseTwo tag bones and a trailing meta bone.
classNamestringNo defaultOn the card.

SkeletonStat

A stat strip placeholder, cell for cell with StatStrip.

Props of SkeletonStat
PropTypeDefaultDescription
countnumber4Cells.
trendbooleanfalseA trailing sparkline bone per cell, hidden below 640px.
classNamestringNo defaultOn the strip.

SkeletonChart

A chart plot with ruled gridlines and bars or a line.

Props of SkeletonChart
PropTypeDefaultDescription
type"bar" | "line""bar"Placeholder bars, or a flat curve at 10% ink.
heightnumber160Plot height in pixels.
barsnumber12Bar count.
axisbooleantrueFive tick bones under the plot.
classNamestringNo defaultOn the wrapper.

SkeletonList

Compact 32px items with an icon and a trailing count, like a rail or menu.

Props of SkeletonList
PropTypeDefaultDescription
itemsnumber5Items.
iconbooleantrueLeading 16px icon bone.
classNamestringNo defaultOn 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.