Skeleton
Animated placeholder for loading states. Wave shimmer (default) and pulse variants, five rounded options, and composable with any layout.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Skeleton } from "@tessinaui/ui";<Skeleton className="h-10 w-40" />Examples
Default
The simplest placeholder — a single sized skeleton with the default wave shimmer.
Variants
Two animation styles — wave (default) sweeps a shimmer gradient, pulse fades opacity.
Rounded
Five border-radius options, from none to full.
Card
Compose multiple skeletons to mirror the shape of a loading card.
List
A repeated row layout for loading feeds, comments, or lists.
Variants
| Variant | Description |
|---|---|
wave | A shimmer gradient sweeps across the element. Default. |
pulse | Opacity fades in and out using Tailwind's animate-pulse. |
Wrapping content
When the content already exists, don't measure it — wrap it. loading renders a
wrapper around the real element, hides the element inside it, and paints the
placeholder on the wrapper. The box is therefore the content's own box at any
size, variant or breakpoint:
<Skeleton loading={isPending} inline rounded="full">
<Button size="lg" rounded="full">Save changes</Button>
</Skeleton>loading={false}renders the children untouched — no wrapper, no attributes, no layout change when the content arrives.- While loading the wrapper is
inertandaria-hidden, so nothing inside can be clicked, focused or announced. - The whole subtree is hidden, text nodes included, so no label or caption leaks through.
- It works for every component, including ones that don't forward props to their root.
inline — which components need it
A wrapper has to choose how to size itself, and no CSS value means "size me the
way my child wants". The default fills the container, which is right for
components that take their width from their container. Set inline when the
component takes its width from its content:
| Components | |
|---|---|
inline | Button, IconButton, Fab, Chip, Badge, Avatar, Status, Switch, Checkbox, Radio, Link, Label, Shortcut |
| default | Field, Textarea, Select, Card, Table, Alert, Banner, Form, Fieldset, Accordion |
Getting it wrong is visible, not silent: a Button without inline stretches to
the full row. A text child always shrink-wraps.
rounded and delay
The wrapper draws the corners, and CSS cannot inherit a radius upward. rounded
falls back to the child's own rounded prop when you pass one, then to md — so
for a component whose default differs (Button defaults to full), pass
rounded to match.
delay holds the box from the first frame but shows nothing for N milliseconds,
so a fast response leaves neither a flash nor a layout shift:
<Skeleton loading={isPending} delay={300}>…</Skeleton>Spinner has the same prop and meaning but renders nothing at all — a spinner
holds no layout, so its failure mode is a flash; a skeleton's whole job is to
hold layout. Below Chrome 117 / Safari 17.5 / Firefox 129 the delay is ignored
and the placeholder appears immediately.
This is the model Mantine, Chakra and MUI use.
Mirror the structure. A component's skeleton shows every part the real
component renders — each nav item, each crumb and separator, the avatar, the
lines of a message, the label and description of a control — count-exact,
with each part's geometry taken from the component's own classes. That is
what BottomNavSkeleton does: pass it items={4} and it draws four icon
blocks with four label lines in the real item boxes. Every composed
XSkeleton takes the props that change its structure (items, lines,
showLabel, showDescription, labelPosition, collapsed…) so the
placeholder can follow the configuration the content will load into.
Wrapping is for the consumer case where the content already exists; the
placeholder a component ships is the composed one.
In running text
A skeleton is phrasing content, so it goes where a word goes — mid-sentence
inside a <p>, in a <label>, in a <figcaption>:
<p>
Read the <Skeleton className="inline-block h-4 w-[12ch] align-middle" /> for details.
</p>Every element the component renders is a <span>, and the base class states
display: block explicitly, so the box is the same one a <div> produced —
h-* and w-* behave exactly as before, and any display you pass still wins.
This matters more than it sounds. <p> has an unusual content model: the HTML
parser closes an open paragraph the moment it meets a <div> start tag. A
<div>-rooted placeholder inside a <p> was therefore a parse error, not a
style nit — the server's HTML and the client's tree disagreed about the shape of
the document, and React reported a hydration error:
In HTML, <div> cannot be a descendant of <p>. This will cause a hydration error.Wrap mode is the exception, and inline is the switch. A block wrapper
holds a whole component — legally a <table> or a Card — so it stays a <div>
and cannot go in a paragraph. inline is you stating the child is inline-level
(Button, Chip, Badge, Link, Label…), so that wrapper is a <span> and is safe
in prose:
<p>
Read <Skeleton loading={isPending} inline><Link href="/guide">the guide</Link></Skeleton> first.
</p>One element is still not phrasing content: the <style> holding the shimmer's
@keyframes. There is no phrasing-content way to declare a keyframe and the
package ships no stylesheet to import. It is inert in practice — React 19 hoists
it into <head>, and <style> is not one of the start tags that closes a <p>,
so it breaks neither the parse nor hydration.
Composing a component skeleton
A skeleton stands in for something real, so it should be the same box as the thing it replaces — at every size, and on phones, where components grow to meet the 44px touch floor. Derive the frame from the component's own variants rather than re-typing its heights:
import { Skeleton, skeletonShell, skeletonText } from "@tessinaui/ui";
import { buttonVariants } from "@tessinaui/ui/button";
// The frame: height, padding, radius and the mobile touch floor all come from
// buttonVariants. `skeletonShell` only neutralises paint — it declares no size.
<Skeleton
label="Loading"
className={cn(buttonVariants({ size, rounded }), skeletonShell)}
>
<span aria-hidden="true" className="invisible inline-block w-[8ch]" />
</Skeleton>skeletonText is the same idea for text rows: it is one line box tall
(h-[1em] plus a centring margin), so a placeholder line matches the real text
line at whatever type size its container sets — no per-size height map.
<div className={labelTextSizeMap[size]}>
<Skeleton aria-hidden="true" className={cn(skeletonText, "w-[12ch]")} />
</div>Announce once per loading region: put label on the wrapper and leave the bars
aria-hidden, or a single card announces eight times.
Every component that ships an XSkeleton is checked against this — the docs
qa:parity suite mounts the component and its skeleton side by side at 375px
and 1280px and fails when the boxes differ.
Accessibility
- A placeholder is decorative by default:
aria-hidden, no role, nothing announced. Passinglabel(or your ownrole) opts back in — that is what the per-componentXSkeletons do for their wrapper. - With
labelit becomesrole="status"and announces that text on insertion. The text is rendered screen-reader-only;role="status"takes no accessible name fromaria-label, so a label-only region would announce nothing. - Both animations stop under
prefers-reduced-motion: reduce, leaving a static block. - Under Windows High Contrast the painted fill is replaced with the
GrayTextsystem colour, so the placeholder stays perceivable (WCAG 1.4.11), and underprefers-contrast: morethe fill darkens against its surface. - In wrap mode the wrapper carries
inertandaria-hidden, so the hidden content is unreachable by keyboard and invisible to assistive tech.labelrenders its status region outside the wrapper — a live region inside anaria-hiddensubtree announces nothing. delayis not motion: it changes when the placeholder appears, never how it moves, so it is unaffected byprefers-reduced-motion.- The markup is phrasing content, so a placeholder in running text produces a valid document rather than a paragraph the parser silently splits in two.
API Reference
Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | — | Wrap mode. Pass the real content as children: true paints over it, false renders it untouched. Leave unset to size a standalone block yourself |
variant | "wave" | "pulse" | "wave" | Animation style. Both stop under reduced motion |
inline | boolean | false | Wrap mode: shrink-wrap to the child's box, and render the wrapper as a <span> so it is legal in prose. Set it for content-sized components (Button, Chip, Avatar…) |
delay | number | 0 | Milliseconds before anything is visible. The box is held from the first frame, so a fast response leaves no flash and no shift |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Border radius. In wrap mode it draws the wrapper's corners, falling back to the child's rounded prop |
label | string | — | Announcement for assistive tech. Sets role="status" and renders the text screen-reader-only. One per loading region, never per bar |
className | string | — | Extra classes — typically h-* and w-*, or a component's own variants plus skeletonShell |
children | ReactNode | — | Rendered inside the block — width sizers, or nested bars for a composite placeholder |
The component extends all standard HTML attributes. It renders a <span> — see In running text — except for a block-mode wrapper (loading without inline), which is a <div>. The prop and ref types stay HTMLDivElement.
Exports
| Export | Type | Description |
|---|---|---|
skeletonShell | string | Frame classes to append after a component's own variants — neutralises paint, declares no geometry |
skeletonText | string | A bar one line box tall, for text rows |
skeletonTextWidth(chars) | Inline style for a text bar standing in for chars characters — ch scaled to the average proportional glyph, so an XSkeleton bar measures what the real label does. Pass the real text's length. | |
skeletonVariants | cva | The radius variants, for composing elsewhere |