Loading States
Swap each piece of a component's real content for a Skeleton via SkeletonLoader, keeping the same structure and props for the loading state.
Neither <Card> nor <Table> has a loading prop of its own, and
<Skeleton> doesn't wrap or replace a whole component either
— it's a content-level primitive, the same way a real card's title is just text. A loading state
is built the same way as the real one, using the same props: <Card title={…} subtitle={…} text={…}>, where each value is either the real thing or a
<SkeletonLoader> rendering a matching skeleton in its place.
Application state (here, a loading boolean) decides which each one renders — no separate
skeleton tree, and no manual composition, to keep in sync by hand.
Card
The card example passes a SkeletonLoader straight into Card's title, subtitle, and text
shorthand props, so the same prop-driven markup renders both the loading and loaded states.
import { useEffect, useState } from 'react'
import { Button, Card, CardImage, Skeleton, SkeletonLoader } from '@chassis-ui/react'
const FETCH_DELAY = 1500
export const Example = () => {
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!loading) return
const timeout = setTimeout(() => setLoading(false), FETCH_DELAY)
return () => clearTimeout(timeout)
}, [loading])
return (
<div className="vstack gap-md" style={{ maxWidth: '20rem' }}>
<span className="visually-hidden" aria-live="polite">
{loading ? 'Loading product…' : 'Product loaded'}
</span>
<Card
className={loading ? 'skeleton-glow' : undefined}
aria-hidden={loading || undefined}
// `image` accepts a `src` string (auto-wrapped in `CardImage`) or a full element — here,
// a `Skeleton`-shaped `CardImage` while loading, since `CardImage` always needs a real
// `src` and can't be swapped through `Skeleton`'s own `component` the way text can.
image={
loading ? (
<CardImage component={Skeleton} orientation="top" style={{ aspectRatio: '2 / 1' }} />
) : (
'https://placehold.co/800x400'
)
}
imageAlt=""
title={
<SkeletonLoader loading={loading} spans={6}>
Denim Jacket
</SkeletonLoader>
}
subtitle={
<SkeletonLoader loading={loading} spans={4}>
$89.00
</SkeletonLoader>
}
text={
<SkeletonLoader loading={loading} spans={[12, 9, 5]}>
Classic fit, stonewashed denim, available in three colors.
</SkeletonLoader>
}
/>
<Button onClick={() => setLoading(true)} disabled={loading}>
Reload
</Button>
</div>
)
}Profile
<SkeletonLoader> isn't text-only — its component prop works exactly like <Skeleton>'s own,
so the same swap-in-place pattern covers an avatar and a button too, not just the two text runs.
import { useEffect, useState } from 'react'
import { Avatar, Button, SkeletonLoader } from '@chassis-ui/react'
const FETCH_DELAY = 1500
const user = {
name: 'Jordan Lee',
avatar: 'https://i.pravatar.cc/80'
}
export const Example = () => {
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!loading) return
const timeout = setTimeout(() => setLoading(false), FETCH_DELAY)
return () => clearTimeout(timeout)
}, [loading])
return (
<div className="vstack gap-md" style={{ maxWidth: '20rem' }}>
<span className="visually-hidden" aria-live="polite">
{loading ? 'Loading profile…' : 'Profile loaded'}
</span>
<div
className={`d-flex align-items-center gap-md${loading ? ' skeleton-glow' : ''}`}
aria-hidden={loading || undefined}
>
<SkeletonLoader loading={loading} component={Avatar} size="lg">
<Avatar src={user.avatar} alt={user.name} size="lg" />
</SkeletonLoader>
{/* The growable wrapper stays in place across both states — a class passed straight to
`SkeletonLoader` would vanish once `loading` is `false`, since it renders `children`
with no wrapper of its own then. */}
<div className="font-lg flex-grow-1">
<SkeletonLoader loading={loading} spans={6}>
{user.name}
</SkeletonLoader>
</div>
<SkeletonLoader loading={loading} component={Button} spans={3} disabled>
<Button>Follow</Button>
</SkeletonLoader>
</div>
<Button onClick={() => setLoading(true)} disabled={loading} className="align-self-start">
Reload
</Button>
</div>
)
}Table
The table example applies the same swap-in-place pattern per cell, rendering one SkeletonLoader
in each TableCell in place of that cell's real value.
| Order | Customer | Amount | Status |
|---|---|---|---|
import { useEffect, useState } from 'react'
import {
Badge,
Button,
SkeletonLoader,
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell
} from '@chassis-ui/react'
const FETCH_DELAY = 1500
const orders = [
{ id: '#1042', customer: 'Alice Martin', amount: '$120.00', status: 'Paid' },
{ id: '#1043', customer: 'Bob Chen', amount: '$85.50', status: 'Pending' },
{ id: '#1044', customer: 'Carol White', amount: '$240.00', status: 'Paid' }
] as const
const columns = [
{ key: 'id', label: 'Order', width: 2, span: 4 },
{ key: 'customer', label: 'Customer', width: 4, span: 8 },
{ key: 'amount', label: 'Amount', width: 2, span: 5 },
{ key: 'status', label: 'Status', width: 2, span: 4 }
]
const statusColor = { Paid: 'success', Pending: 'warning' } as const
export const Example = () => {
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!loading) return
const timeout = setTimeout(() => setLoading(false), FETCH_DELAY)
return () => clearTimeout(timeout)
}, [loading])
return (
<div className="vstack gap-md">
<span className="visually-hidden" aria-live="polite">
{loading ? 'Loading recent orders…' : 'Recent orders loaded'}
</span>
{/* `key` forces a remount when `loading` flips — `Table` builds its row/cell collection
once via `useTableState` and won't otherwise notice a value that changed only inside a
cell's render closure. */}
<Table
aria-label="Recent orders"
className={`align-middle${loading ? ' skeleton-glow' : ''}`}
key={String(loading)}
>
<TableHeader columns={columns}>
{(column) => (
<TableColumn className={`w-${column.width}/12`} key={column.key}>
{column.label}
</TableColumn>
)}
</TableHeader>
<TableBody items={orders}>
{(row) => (
<TableRow key={row.id}>
{(columnKey) => {
const column = columns.find((c) => c.key === columnKey)!
return (
<TableCell>
<SkeletonLoader
loading={loading}
spans={column.span}
component={columnKey === 'status' ? Badge : undefined}
color={columnKey === 'status' ? 'default' : undefined}
>
{columnKey === 'status' ? (
<Badge color={statusColor[row.status]}>{row.status}</Badge>
) : (
row[columnKey as keyof typeof row]
)}
</SkeletonLoader>
</TableCell>
)
}}
</TableRow>
)}
</TableBody>
</Table>
<Button onClick={() => setLoading(true)} disabled={loading} className="align-self-start">
Reload
</Button>
</div>
)
}Key points
<SkeletonLoader loading={loading} spans={[…]}>{realValue}</SkeletonLoader>replaces one run of text in place — same position in the tree either way, no wrapping element, no parallel structure to maintain. The card example passes one straight intoCard'stitle/subtitle/textshorthand props, so the whole card stays a single prop-driven component with no manualCardBody/CardTitlecomposition.- For non-text content, pass
component(and that component's own props, e.g.sizefor<Avatar>ordisabledfor<Button>) straight to<SkeletonLoader>, same as<Skeleton>— see the profile example. The one exception is<CardImage>: it always needs a realsrc, so it can't be swapped throughSkeletonLoader/Skeleton'scomponentthe same way — the card example instead passes a ternary straight intoCard'simageprop, which accepts either asrcstring or a full element (a<CardImage component={Skeleton}>, a<Placeholder>, anything) rendered as-is. animation="glow"/.skeleton-glowgoes on a container you already have (<Card>, the profile row,<Table>itself), not onSkeletonLoader— it has no element of its own to put a class on..skeleton-glowpulses via a descendant selector (.skeleton-glow .skeleton { animation }), so one class on an ancestor reaches every generated skeleton inside, at any depth..skeleton-waveworks differently — it masks the container's own rendered output directly, sweeping across whatever.skeletondescendants it contains — but the practical instruction is the same either way: put the class on the container, not on individual skeletons.- Pick
spansvalues that roughly match the eventual text's length per line — exact precision doesn't matter, just enough to avoid a jarring width jump once real data replaces it. Leavespansunset entirely for non-text swaps whose target already sizes itself, like the profile example's avatar. - The table example still keys
<Table>onString(loading)to force a remount when it flips —Tablebuilds its row/cell collection once viauseTableStateand won't otherwise notice a value that only changed inside a cell's render closure. This is aTable-specific quirk, not somethingSkeletonLoaderneeds elsewhere. - All three examples simulate a fetch with
setTimeoutand a "Reload" button. Swap that forloadingstate driven by your actual data-fetching hook (useQuery,useEffect+fetch, etc.). aria-hiddenon the loading container only hides the skeleton from assistive technology — it doesn't announce when the real content shows up. All three examples pair it with a.visually-hiddenaria-live="polite"span that reports the state change out loud, since nothing else in any of them does. It's placed once per example, not once per<SkeletonLoader>— the table has one<SkeletonLoader>per cell but only one announcer for the whole table, so reloading doesn't fire a burst of duplicate announcements.