DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ButtonIconButtonLinkLabelBadgeStatusChipAvatarShortcutSpinnerSkeletonProgressMeter
AI agentsContributing
  1. Components
  2. Skeleton

Skeleton

Animated placeholder for loading states. Wave shimmer (default) and pulse variants, five rounded options, and composable with any layout.

$ pnpm add @tessinaui/ui

When to use

  • Content whose layout is known before the data arrives: a card, a list row, a table, a form. The placeholder holds the exact box, so nothing shifts when the content lands.
  • A wait long enough to notice. Set delay (300ms is a good start) so a fast response leaves no flash.
  • Content that already exists as a component. Wrap it with loading, or use its shipped XSkeleton, so the placeholder is the real box rather than bars sized by eye.
  • Mirroring structure: every part the real content renders appears in the placeholder, so the swap reads as the same thing filling in.
  • One announcement per region. Put label on the loading region once, not on every bar.

When not to use

  • When you don't know the shape of what is loading. Use Spinner, which holds no layout and so cannot misrepresent a shape.
  • When you can report progress. Use Progress.
  • For a wait that is usually instant. Without delay a fast response flashes a placeholder.
  • When loading has finished and there is nothing to show. Use EmptyState, not a placeholder that implies content is on the way.
  • For a placeholder that looks different from the content. Wrap the real component or use its XSkeleton instead.
  • To announce every bar. One card would read out eight times; label the region once.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Skeleton } from "@tessinaui/ui";
example.tsx
<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.

On color

Inside a coloured <Surface> every bar inherits tone="on-color" and paints a wash of the plate's own ink instead of the page's grey, wrap mode included. Nothing needs to be set; pass tone="default" to keep your own paint.

Every XSkeleton follows the ground it paints, the way its component does: one that paints its own light panel (a card, a popup, a field) keeps page bars on that panel, and one that paints a plate (an inverse Toast, a primary TopHeader) takes that plate's ink.

API Reference

Skeleton

PropTypeDefaultDescription
loadingboolean—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
inlinebooleanfalseWrap 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…)
delaynumber0Milliseconds before anything is visible. The box is held from the first frame, so a fast response leaves no flash and no shift
tone"default" | "on-color"inheritedInherited from the nearest <Surface>. On a coloured plate the bar is a wash of the plate's ink and replaces any bg-* in className, skeletonShell and skeletonInk included. "default" keeps the page paint
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
labelstring—Announcement for assistive tech. Sets role="status" and renders the text screen-reader-only. One per loading region, never per bar
classNamestring—Extra classes — typically h-* and w-*, or a component's own variants plus skeletonShell. Every bar is capped at max-w-full, so a fixed width shrinks inside a narrower container instead of spilling; pass your own max-w-* to change the cap
childrenReactNode—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

ExportTypeDescription
skeletonShellstringFrame classes to append after a component's own variants — neutralises paint in both themes, declares no geometry. A paint you append after it must be a light and dark pair (bg-x dark:bg-x), as skeletonInk is
skeletonTextstringA bar one line box tall, for text rows
skeletonInkstringBar paint for a placeholder on a surface the real component fills (a track, a soft plate), where the skeleton token would vanish. On a coloured plate the skeleton's own ink replaces it
skeletonInkWithinstringskeletonInk for every bar inside a frame, set once on a frame whose fill equals the skeleton token (bg-secondary, or bg-muted in dark). Reaches the bars of a nested XSkeleton, which no className can. Import from @tessinaui/ui/skeleton
skeletonInkWithinDarkstringThe dark half of skeletonInkWithin alone, for a frame that meets the skeleton token in dark only (bg-card). Import from @tessinaui/ui/skeleton
skeletonTextWidth(chars)(chars: number) => CSSPropertiesInline 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
skeletonVariantscvaThe radius variants, for composing elsewhere

Variants

VariantDescription
waveA shimmer gradient sweeps across the element. Default.
pulseOpacity 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:

example.tsx
<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 inert and aria-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
inlineButton, IconButton, Fab, Chip, Badge, Avatar, Status, Switch, Checkbox, Radio, Link, Label, Shortcut
defaultField, 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:

example.tsx
<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 prevailing model across component libraries.

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>:

example.tsx
<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:

example.plaintext
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:

example.tsx
<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:

example.tsx
import { cn, 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.

example.tsx
<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. Passing label (or your own role) opts back in — that is what the per-component XSkeletons do for their wrapper.
  • With label it becomes role="status" and announces that text on insertion. The text is written screen-reader-only 150ms after mount, with no aria-busy on the region — VoiceOver + Safari ignores a status region that mounts with its text, or one marked busy (verified on ProgressSkeleton and Spinner, 2026-09-14); role="status" takes no accessible name from aria-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 GrayText system colour, so the placeholder stays perceivable (WCAG 1.4.11), and under prefers-contrast: more the fill darkens against its surface.
  • In wrap mode the wrapper carries inert and aria-hidden, so the hidden content is unreachable by keyboard and invisible to assistive tech. label renders its status region outside the wrapper — a live region inside an aria-hidden subtree announces nothing.
  • delay is not motion: it changes when the placeholder appears, never how it moves, so it is unaffected by prefers-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.
SpinnerProgress

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsRoundedCardListOn colorAPI ReferenceSkeletonExportsVariantsWrapping contentinline — which components need itrounded and delayIn running textComposing a component skeletonAccessibility