Skeleton
Use a skeleton when a wait replaces content the user was already looking at, or
is about to. It answers a different question from a
Spinner: a spinner says something is happening, a
skeleton says this is what is coming, and here is where it will sit. If you
know how far along the work is, show a Progress bar
instead — that answers "how much longer", which neither of the other two can.
Base UI has no skeleton primitive, so this is one of the few components in the library not built on one — there is no interaction to model and no state machine to get right. What is left is the part that is usually got wrong: metrics that genuinely match the content, one announcement for one wait, and an animation that still says "this is coming" when the user has asked for less motion.
Import
import { Skeleton } from "@forte-ui/react";Anatomy
<Skeleton.Group loading={pending} label="Loading profile">
<Skeleton.Root variant="circle" className="size-10" />
<Skeleton.Text lines={3} />
</Skeleton.Group>Skeleton.Root is one placeholder shape. Skeleton.Text is a paragraph of
them, measured like type. Skeleton.Group is the region they belong to.
What the group is for
Every placeholder is aria-hidden, and that is not an oversight — a card of
them announced individually is "blank blank blank", which is worse than
silence. But it means a screenful of bare skeletons tells a screen reader user
nothing at all. That is the state most skeleton components ship in.
The obvious fix, a role="status" on each placeholder, is the mistake the
Spinner page already documents: nine placeholders
becomes nine live regions politely queueing to say "Loading". So the
announcement has to live one level up, and something has to own it. That is the
group's job, and the only one it cannot be talked out of.
It also has to stay mounted across both states, which is the second thing the
shape enforces — see the note under Accessibility for why a
live region that appears at the same moment as its content is announced
unreliably. Writing it as a wrapper you keep, with loading flipping inside,
makes the dependable version the one that is easiest to type.
Two conveniences ride along. The group hands down animation as a default, so
shimmer is written once per card rather than on all nine placeholders; and it
is a display: grid with a gap, because stacked placeholder rows are the
common shape. Both are ordinary defaults — a skeleton that sets its own
animation wins, and any className beats the layout on layer order.
Using skeletons without a group
The group is optional and nothing degrades except the announcement. Both parts
read their context with ??, so a skeleton outside one falls through to its
own props and then to the defaults:
<Skeleton.Root className="h-32 w-full" />
<Skeleton.Text lines={3} />Reach for a bare skeleton when something else already owns the
announcement — a panel that carries its own role="status" and aria-busy,
which is the pattern the Spinner page recommends and which a group inside would
only duplicate — or for a single placeholder inside a sentence that a live
region already covers, or for something decorative like the demos further down
this page, none of which uses a group at all.
Reach for the group when a whole screen is loading and nothing else is
speaking. And note it is not magic: it is a <div> with aria-busy, a hidden
role="status" and a context provider. Putting those on your own container
works exactly as well — you are writing the same thing by hand.
Examples
Variants
Three silhouettes, and no more — a skeleton is a shape, not a component
library. Everything else is a matter of width and height, which className
already does better than a prop could.
rectBlocks and thumbnailstextOne line of prosecircleAvatars and dotstext is inline-block, so a short one can sit inside a real sentence —
"posted by ▓▓▓▓ an hour ago" — and share its baseline row. circle stays a
circle under data-forte-radius="none", because round is what that shape is
rather than how it is styled; it is the same test Radio
and the Spinner ring apply.
Text, measured like type
This is the part that is usually approximated. Real text does not fill its line box — the glyphs occupy roughly the cap height and the rest is leading, split half above and half below. A stack of full-line-box bars is therefore both too heavy to read as prose and the wrong total height.
So a line paints only --forte-skeleton-ink, the leading becomes the flex gap,
and the container carries half a leading on each side. The sum is exact:
n × ink + (n − 1) × leading + leading == n × (ink + leading)
== n × 1em × line-heightwhich is the height of the paragraph it replaces, to the pixel. In the demo below the bars are drawn over the text they stand in for, so you can see the alignment rather than take it on trust.
Base UI ships the behaviour. forte-ui ships the pixels. Nothing here is hardcoded.
Every measurement is in em, relative to the inherited font size. Set
className="text-4" on a Skeleton.Text and the whole block rescales —
heading placeholders need no second prop, and no numbers to keep in sync with
the stylesheet.
This is why the card at the top of the page uses Skeleton.Text for its
one-line placeholders too. Watch the button above it while the article
reloads: it does not move, because the two states are the same height to
within a rounding error.
Sizing from the content
The other half of "no layout shift". Hand the real content to a skeleton and it
is laid out and then hidden with visibility, so the placeholder is exactly
the box that content will occupy. No width guessed by eye, and nothing moves
when the data lands.
When loading turns false the skeleton renders its children with no wrapper
of its own — nothing of the placeholder is left in the DOM or in the layout.
That is also the catch: the content is mounted while it is invisible, so pass a
string or a cheap node, not a subtree you would rather not render twice. For
anything larger, keep the ordinary {loading ? <Skeleton … /> : <Content />}
swap and let Skeleton.Group carry the flag.
Animations
pulseShallow opacity breatheshimmerBand crossing the fillnoneStatic — nothing movespulse is the default: a shallow opacity breathe, quiet enough to put fifty of
on one screen. shimmer sends a band of light across the fill — faster-reading,
and better when a whole page is loading at once. It follows the reading
direction, multiplying its travel by --forte-direction, so it sweeps
right-to-left in RTL. none is a static fill, for a screenshot test or a page
where something else already owns the movement.
Reduced motion
Neither animation switches off, and that is deliberate. A skeleton that stops moving entirely reads as content that failed — a dead grey box is a worse message than the one it replaced. So both reduce instead:
pulsegets shallower. The dip iscalc(0.55 + 0.25 * var(--forte-motion-off)), so a 45% swing becomes a 20% one. There was never any geometry to collapse: it is opacity from end to end.shimmerdegrades intopulse. The band's travel is gated on--forte-motion-okand the band itself is faded out by--forte-motion-off— a highlight frozen mid-sweep on every placeholder reads as a rendering defect, not as restraint. A second animation on the root, always running and a no-op at full motion, takes over as the visible cue — and it bottoms out at the same--forte-skeleton-dimthe pulse uses, so the two animations reach one depth under reduced motion instead of shimmer swinging deeper than anything the page shows at full motion.
Theming
Accessibility
Every placeholder is aria-hidden. A skeleton is a picture of absence, and
there is nothing in it to read — a card of them announced individually is
"blank blank blank", which is worse than silence.
Skeleton.Group is what speaks, once. It sets aria-busy on the region and
renders a visually hidden role="status" carrying label, plus doneLabel
when the wait ends. Say what is coming: "Loading invoices" tells someone
whether the wait concerns them; "Loading" makes them guess.
The status role is on a hidden span rather than on the group's wrapper, and
that is not tidiness. role="status" carries an implicit aria-atomic="true",
so on the wrapper it would make every arriving row re-read the entire
region — a table of forty invoices announced in full the moment it lands.
Confined to the message, it says one sentence and stops; aria-busy on the
wrapper covers the rest.
Under forced colours a placeholder is painted Canvas with a GrayText
outline. Left alone, the fill is remapped to a system colour that is not ours
to choose — either invisible against the page or a solid black brick — and
neither is a placeholder. .forte-hc-decorative is the wrong tool even though a
skeleton is decorative: it is display: none, and a placeholder that stops
occupying space has abandoned its only job. The shimmer band is dropped there
too, because forced colours does not remap background-image and it would keep
its own colour against a system-coloured page.
There is nothing to focus and nothing to press, so a skeleton is not in the tab
order, takes cursor: default per the pointer rules, and sets pointer-events: none — which also stops a link nobody can see from being clickable in sizing
mode.
Theming
Everything below is declared on the skeleton root itself, which is also why an
ancestor is the wrong place to set one — the element's own declaration beats an
inherited value. Override them through className or a style object, or
re-point the global tokens in the Default column to move every skeleton at once.
| Property | Controls | Default |
|---|---|---|
--forte-skeleton-color | The placeholder fill. | var(--forte-color-panel-active) |
--forte-skeleton-highlight | Colour of the band that travels across animation="shimmer". | light-dark(var(--forte-gray-1), var(--forte-gray-7)) |
--forte-skeleton-radius | Corner radius. variant="text" drops to --forte-radius-2, circle is a literal circle and ignores this. | var(--forte-radius-control) |
--forte-skeleton-line-height | Line box the placeholder is measured against. Everything below is a multiple of it and of the inherited font-size. | var(--forte-line-height-normal) |
--forte-skeleton-ink | Height of the painted band for variant="text" — how much ink a line of real text puts down, as a fraction of the line box. | 0.72em |
--forte-skeleton-width | Width. Overridden by the width prop, and by any consumer utility — className="w-24" wins on layer order alone. | 100% |
--forte-skeleton-height | Height. Defaults to one line box, so a bare <Skeleton.Root /> is exactly as tall as the line of text it replaces. | calc(1em * var(--forte-skeleton-line-height)) |
--forte-skeleton-duration | One full animation cycle, for both pulse and shimmer. | var(--forte-duration-loop-sweep) |
--forte-skeleton-dim | How far animation="pulse" fades at the bottom of its cycle — and the depth shimmer's fallback pulse reaches under reduced motion, so the two animations stay one knob. Rises to 0.8 under reduced motion, so the pulse gets shallower rather than disappearing. | calc(0.55 + 0.25 * var(--forte-motion-off)) |
Skeleton.Group has one of its own, on the group element.
| Property | Controls | Default |
|---|---|---|
--forte-skeleton-group-gap | Space between stacked rows of a Skeleton.Group. | var(--forte-space-3) |
--forte-skeleton-highlight is the one token here that is not a plain semantic
reference, and the reason is worth knowing: "brighter" is not a direction on
this scale. --forte-gray-4 is a light grey in light mode and a dark one in
dark, so a single ramp step would shimmer darker in one of the two — which
looks broken rather than subtle. It is a light-dark() pair instead, each
branch a step of lightness away from the fill in its own mode, resolved against
the color-scheme that tokens.css sets on :root and on every data-theme
scope.
Skeleton.Text re-declares --forte-skeleton-ink and --forte-skeleton-line-height
on the container, because that is where the gap and the padding are measured
from, and passes the ink down to its lines with --forte-skeleton-ink: inherit.
Without that the lines would use their own declared default — an element's own
declaration beats an inherited one — and retuning the ink on a Skeleton.Text
would move the gaps while leaving the bars where they were.
State is on data-variant and data-animation, plus data-loading on the
group, all reachable from plain CSS or a Tailwind arbitrary variant —
data-[variant=circle]:... — without a wrapper element.
API reference
None of the three parts has a Base UI counterpart to forward to.
Skeleton.Root and Skeleton.Text are <span>s, Skeleton.Group is a
<div>, and each takes that element's whole surface — id, style, data-*,
ref — but no render prop.
Skeleton.Root
| Prop | Type | Default | Description |
|---|---|---|---|
animation | SkeletonAnimation | pulse | How the placeholder signals that it is a placeholder. `pulse` is a shallow opacity breathe — quiet, and identical whichever way it is laid out. `shimmer` sends a band of light across the fill, which reads as faster and suits a screenful of placeholders; it follows the reading direction, and degrades into `pulse` under reduced motion. `none` is a static fill, for a screenshot test or a page where something else already owns the movement. Falls back to the enclosing `Skeleton.Group`, then to `pulse`. |
children | ReactNode | The real content, used only to SIZE the placeholder. While loading it is laid out and then hidden with `visibility`, so the skeleton is exactly the box the content will occupy and nothing shifts when it arrives. Note that this renders the content — pass a string or a cheap node, not a subtree you would rather not mount twice. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
height | SkeletonLength | Height of the placeholder. A number is pixels; a string is any CSS length. The default is one line box at the inherited font size, so a skeleton standing in for a line of text needs no measurement at all. | |
loading | boolean | true | Whether the placeholder is showing. When it flips to `false` the skeleton renders `children` — with no wrapper of its own, so nothing of the placeholder is left in the DOM or in the layout. Falls back to the enclosing `Skeleton.Group`, then to `true`. |
variant | SkeletonVariant | rect | The silhouette. `rect` is a block — a thumbnail, a card, a button. `circle` is an avatar or a status dot, and stays round under `data-forte-radius="none"`. `text` is a thin bar the height of the ink a line of text actually puts down, and it is `inline-block`, so it can sit inside a real sentence — "posted by ▓▓▓▓ an hour ago". Note that it paints the ink and nothing else: standing on its own in a column it is SHORTER than the line it replaces, because the leading is missing. Reach for `Skeleton.Text` there, `lines={1}` included — reserving the whole line box is exactly what it is for. |
width | SkeletonLength | Width of the placeholder. A number is pixels; a string is any CSS length, including a percentage. Optional in every sense — `className="w-24"` does the same job and wins over this, and with `children` present the content decides. |
Skeleton.Text
| Prop | Type | Default | Description |
|---|---|---|---|
animation | SkeletonAnimation | pulse | See `Skeleton.Root`. Falls back to the enclosing group, then `pulse`. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
lastLineWidth | SkeletonLength | 60% | Width of the last line. Real paragraphs end mid-line, and a block of equal-length bars reads as a table rather than as prose. Any CSS length; a number is pixels. |
lines | number | 3 | How many lines to draw. The block is exactly as tall as that many lines of real text at the inherited font size, so swapping it for the paragraph moves nothing below it. |
loading | boolean | true | See `Skeleton.Root`. Falls back to the enclosing group, then `true`. |
Skeleton.Group
| Prop | Type | Default | Description |
|---|---|---|---|
animation | SkeletonAnimation | pulse | Default animation for every skeleton in the group. A skeleton that sets its own still wins. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
doneLabel | string | What to announce once `loading` turns false. Left unset, the region simply goes quiet — which is the right default when the content that arrives is itself obvious, and the wrong one when the user is waiting on a background refresh they cannot see. | |
label | string | Loading | What is being waited for. This is the only thing a screen reader gets — the placeholders themselves are `aria-hidden`, because a picture of absence is not information. Say what is coming rather than that something is: "Loading invoices" tells someone whether the wait concerns them; "Loading" makes them guess. |
loading | boolean | true | Whether the region is still loading. Sets `aria-busy`, swaps the announced message, and becomes the default for every skeleton inside. |