Skeleton
Loading placeholder that mimics a piece of content's shape while it's still loading, to prevent layout shift.
Overview
<Skeleton> renders a loading placeholder shaped like the content it stands in for, holding a
layout's size steady while data loads. It always renders whatever is passed to it — swapping a
skeleton for its real content once data arrives is left to application state, not a prop on the
component itself. It renders a <span> by default, and every other prop is optional: width,
color, and animation are opt-in on top of the bare shape.
spansizes a skeleton in twelfths of its parent, at every width or per breakpoint, with the width utilities of@chassis-ui/css.coloroverrides the defaultcurrentColorbackground with one of the context colors.animationaddsskeleton-gloworskeleton-wavealongside the baseskeletonclass on the element it's set on, so that element animates itself — nesting plain<Skeleton>children inside an animated one animates the whole group together.componentrenders the skeleton as another element or component, for matching the shape of whatever it's standing in for.
A Tailwind build of @chassis-ui/css has the classes of span only with its safelist loaded: see
Tailwind.
Examples
Basic usage
A single <Skeleton span={n}> mimics one line of content. Stack several inside a flowing
container for a paragraph-shaped block — each one wraps like an inline word once a row fills, so
the ragged trailing edge falls out naturally.
import { Skeleton } from '@chassis-ui/react'
export const Example = () => {
return (
<div className="vstack gap-md" aria-hidden="true">
<Skeleton span={12} />
<p>
<Skeleton span={7} />
<Skeleton span={4} />
<Skeleton span={4} />
<Skeleton span={6} />
<Skeleton span={8} />
</p>
</div>
)
}aria-hidden="true" only hides a skeleton from assistive technology — it doesn't manage the
loading state itself. Toggle between the skeleton and its real content with application code, and
pair the swap with an aria-live announcement (or a live region already present in the real
content) so assistive technology users learn when the data has finished loading.
Width
Set span to a width in twelfths of the parent: span={6} is half (.w-6/12) and span={12}
the full width (.w-100). span="auto" is the natural width of the content, and span alone
fills the rest of a flex row. An object changes the width from a breakpoint up, with the same
values: span={{ base: 12, md: 6 }}. Reach for className or an inline style for any other width.
import { Skeleton } from '@chassis-ui/react'
export const Example = () => {
return (
<>
<Skeleton span={6} />
<Skeleton className="w-75" />
<Skeleton style={{ width: '30%' }} />
</>
)
}Color
A skeleton's background is currentColor by default. Set color to any context color to match a
branded or role-specific loading state.
import { Skeleton } from '@chassis-ui/react'
export const Example = () => {
return (
<>
<Skeleton span={12} />
<Skeleton color="default" span={12} />
<Skeleton color="alternate" span={12} />
<Skeleton color="primary" span={12} />
<Skeleton color="secondary" span={12} />
<Skeleton color="neutral" span={12} />
<Skeleton color="success" span={12} />
<Skeleton color="danger" span={12} />
<Skeleton color="warning" span={12} />
<Skeleton color="info" span={12} />
</>
)
}Animation
Set animation on a wrapping <Skeleton component="p"> (or any other element) to convey that
content is actively loading rather than merely present. The animated element gets
skeleton-glow/skeleton-wave alongside its own skeleton class, so it animates itself too, and
plain <Skeleton> children nested inside it inherit the effect.
import { Skeleton } from '@chassis-ui/react'
export const Example = () => {
return (
<>
<Skeleton component="p" animation="glow" className="w-100">
<Skeleton span={12} />
</Skeleton>
<Skeleton component="p" animation="wave" className="w-100">
<Skeleton span={12} />
</Skeleton>
</>
)
}Custom elements
<Skeleton> is polymorphic: pass component to render it as another element or component instead
of the default <span>, and the props checked at the call site follow whatever component is —
<Skeleton component={Button} variant="outline"> type-checks variant against Button's own
props, not Skeleton's. Skeleton's own background/opacity/animation styling wins over the
target's, while the target's own CSS still supplies the shape (button padding, avatar
border-radius, etc.), so the result is a correctly-shaped, correctly-animated stand-in with no
extra work. Pass the target's own disabled prop, as shown below for both Button and Avatar,
to hold its height steady and get its existing disabled styling for free — no manual .disabled
class or aria-disabled needed.
import { Avatar, Button, Skeleton } from '@chassis-ui/react'
export const Example = () => {
return (
<div className="d-flex align-items-center gap-md">
<Skeleton component={Avatar} size="lg" aria-hidden="true" />
<Skeleton component={Button} span={3} disabled aria-label="Loading" />
<Skeleton component={Button} variant="outline" span={3} disabled aria-label="Loading" />
</div>
)
}chassis-css applies extra styling to .button elements via ::before so a button's height holds
steady once a skeleton replaces its label. Extend the same ::before pattern for other
components, or add a non-breaking space inside an element to preserve its height once real content
renders in place of the skeleton.
Don't reach for <Placeholder> as component here despite
the name — it solves a different problem. <Placeholder> is a permanent stand-in for image content
that's genuinely missing, not a temporary loading state, and it defaults to a visible title/text
label with role="img"/aria-label, which would nest a screen-reader-announced element inside
what's meant to be a decorative skeleton. Size a bare <Skeleton> to match the real image instead —
see Loading states for a worked example swapping a
<CardImage> for one while loading.
Swapping in real content
<Skeleton> never manages the swap to real content itself — every example above still needs
application code deciding when to render the real thing instead. <SkeletonLoader> does exactly
that swap: pass the eventual content as children, and it renders a matching skeleton while
loading is true, or children unchanged — no wrapper element at all — once it's false.
For a run of text, spans sizes one skeleton line per entry (a bare value like spans={6} is
shorthand for a single line, equivalent to spans={[6]}).
import { useState } from 'react'
import { Button, SkeletonLoader } from '@chassis-ui/react'
export const Example = () => {
const [loading, setLoading] = useState(true)
return (
<div className="vstack gap-md">
<p className={loading ? 'skeleton-glow mb-0' : 'mb-0'}>
<SkeletonLoader loading={loading} spans={[12, 9, 5]}>
Chassis is a design system and component library built for teams who need to move fast
without sacrificing consistency or accessibility.
</SkeletonLoader>
</p>
<Button onClick={() => setLoading(!loading)}>
{loading ? 'Mark as loaded' : 'Reset to loading'}
</Button>
</div>
)
}<SkeletonLoader> is polymorphic too, exactly like <Skeleton> — its component prop (and that
component's own props, type-checked the same way) work for non-text content, not just text runs.
Leave spans unset for a target that already sizes itself, like Avatar's size prop. See
Loading states's profile example for <Skeleton component={Avatar}>/<Skeleton component={Button}> used through <SkeletonLoader> this way.
Unlike <Skeleton>'s own animation prop, <SkeletonLoader> has no element of its own to put
skeleton-glow/skeleton-wave on. Apply it to whatever container already wraps the swap — a
<p>, a <Card>, a <Table> — and it reaches every generated skeleton inside automatically,
regardless of nesting depth, whether via skeleton-glow's descendant selector or skeleton-wave's
mask over the container's whole subtree. See Loading states
for full worked examples combining both components into a card, a profile row, and a table's
loading state — and for where to put the aria-live announcement when several <SkeletonLoader>s
belong to one loading operation (see the callout above): once on the shared container, not once per
<SkeletonLoader>, so a form or table with many fields doesn't announce itself several times over.
Scope
No reduced-motion handling. The glow and wave animations run at a fixed rate regardless of
a visitor's reduced-motion preference — chassis-css doesn't gate either behind
prefers-reduced-motion today. Wrap an animated <Skeleton> in that media query, or an equivalent
JavaScript check, to disable the animation where it matters.
API
Skeleton
| Prop | Type | Default |
|---|---|---|
animation? | "glow" | "wave" | — |
Adds skeleton-{animation} alongside the base skeleton class, so the element animates
itself as well as any nested <Skeleton> children. Nest plain <Skeleton> children inside
it for the glow pulse to reach them too, or apply wave to a container of one or more
<Skeleton> children for a directional sweep across the whole group. | ||
asChild? | boolean | — |
Render the single child element in place of this component's own element, with this
component's classes, props and ref merged onto it: <Button asChild><Link href="/login">Log
in</Link></Button>. The alternative to component for React Server Components — an element
can be passed from the server to this package's client components, a component reference
(component={Link}) can't. | ||
className? | string | — |
| A string of all className you want applied to the component. | ||
color? | ContextColor | — |
| Sets the color of the component to one of Chassis context colors. | ||
component? | ElementType | 'span' |
Component used for the root node. Either a string to use an HTML element or a component —
e.g. Avatar or Button. Its own props are type-checked at the call site once passed here. | ||
ref? | any | — |
span? | { Responsive<'auto' | number | string | boolean> } | — |
Width of the skeleton in twelfths of its parent (span={6} is half, mapped to the
w-{n}/12 class; span={12} is w-100), 'auto' for its natural width (w-auto), or
true to fill the rest of a flex row (flex-fill). Unset by default, so the rendered
element's own intrinsic width applies — set it explicitly (e.g. span={12}) for a
full-width text line; leave it unset when component is something that sizes itself, like
Avatar or Button. An object sets the width from a breakpoint up: { base: 12, md: 6 }.
Any other number or string (0, 13, '50%') has no width class: it sets no width, and
warns in development. | ||
SkeletonLoader
| Prop | Type | Default |
|---|---|---|
children | ReactNode | — |
The real content, rendered unchanged (no wrapping element) once loading is false. | ||
color? | ContextColor | — |
| Sets the color of the generated skeleton lines to one of Chassis context colors. | ||
component? | ElementType | 'span' |
Component used for the generated skeleton(s) — e.g. Avatar or Button — for swapping
non-text content, not just text runs. Its own props are type-checked at the call site once
passed here, same as Skeleton's own component prop. | ||
loading | boolean | — |
Swaps children for one <Skeleton> per entry in spans while true. | ||
ref? | any | — |
spans? | { 'auto' | number | string | boolean | Array<'auto' | number | string | boolean> } | — |
One skeleton line per entry, using the same values as Skeleton's span prop — e.g.
[12, 6] for a full-width line followed by a half-width one. A single value (e.g. 6) is
shorthand for a single line, equivalent to [6]. Leave unset for a single skeleton sized by
component's own intrinsic width instead — e.g. an Avatar's size prop. Rendered instead
of children while loading is true; ignored once loading is false. | ||