DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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/ui

Usage

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

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:

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

<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. 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 rendered screen-reader-only; 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.

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
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
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, declares no geometry
skeletonTextstringA 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.
skeletonVariantscvaThe radius variants, for composing elsewhere
SidebarSlider

On this page

PlaygroundInstallationUsageExamplesDefaultVariantsRoundedCardListVariantsWrapping contentinline — which components need itrounded and delayIn running textComposing a component skeletonAccessibilityAPI ReferenceSkeletonExports