Skeletonv0.5.4Ready for Production
Loading state placeholders with pulse animation to indicate content is loading
Prefer AI-assisted development?
Catalyst Skeleton provides animated placeholders for content that is loading, preserving the page's layout structure while data is being fetched — improving perceived performance with zero configuration.
Installation
npm install @pmi/catalyst-skeletonimport { Skeleton } from '@pmi/catalyst-skeleton';Variants
Foreground
The default variant. Use for text, buttons, and interactive elements.
Background
Use for cards, containers, and large content blocks. Typically used as the outer wrapper with nested foreground skeletons inside. Set animated={false} on the background wrapper to prevent double-pulsing.
Common Patterns
Text Content
Wrap heading and paragraph elements to preserve the full typographic layout while loading.
Custom Size
Apply arbitrary dimensions with Tailwind utility classes when the skeleton has no child content.
Component Children
Wrap interactive components — the skeleton inherits the child's shape automatically using the Radix Slot.
Precomposed Card
A complete card layout with skeleton states applied to every content region.
Precomposed Hero
A hero section with multiple nested skeleton regions spanning headings, body copy, and CTAs.
Precomposed Tabs
A tabbed layout with skeleton states on both tab labels and tab panel content. Because TabsList and TabsContent carry their own ARIA roles (role="tablist" and role="tabpanel"), the entire skeleton structure is wrapped in aria-hidden + inert to remove those roles from the accessibility tree and tab order while loading. See Skeleton inside structural ARIA roles for details.
Loading Lifecycle
Demonstrates the complete loading lifecycle with proper screen reader support. The inert + aria-hidden wrapper hides skeleton placeholders from AT and keyboard navigation while loading. Once content arrives, the wrapper is removed, focus is moved to the first meaningful element, and the aria-live="polite" region announces the new content to screen readers.
This example also includes the long-running loading announcement pattern: after 3 seconds a separate visually-hidden live region begins periodically announcing "Loading, please wait…" every 10 seconds, keeping screen reader users informed that the application is still working.
Lead with confidence
Manage timelines and budgets, align teams and stakeholders, and deliver on scope with proven project management techniques.
API Reference
Prop
Type
The component also accepts all standard HTML span element attributes and forwards ref.
Accessibility
Skeleton is designed to meet WCAG 2.2 AA standards.
Screen Reader Behaviour
aria-hidden="true"is applied so skeleton placeholders are invisible to assistive technologies — they are purely decorative.tabIndex={-1}removes skeleton elements from the keyboard tab order.- When toggling between skeleton and real content via the
loadingprop, wrap the region in anaria-livecontainer so screen readers announce when content becomes available:
<div aria-live="polite" aria-busy={isLoading}>
<Skeleton loading={isLoading}>{content}</Skeleton>
</div>Skeleton inside structural ARIA roles
The Skeleton component applies aria-hidden, tabIndex={-1}, and inert to the element it directly wraps. This is sufficient for most content. However, when skeleton placeholders are placed inside structural components that carry their own ARIA roles — such as TabsList (role="tablist"), TabsContent (role="tabpanel"), or TabsTrigger (role="tab") — those outer role-bearing elements remain exposed to assistive technologies and keyboard navigation even though their content is empty. Screen readers will announce an empty tablist, and the tabpanel containers will be reachable via keyboard, violating WCAG 4.1.2 Name, Role, Value (role-bearing elements with no meaningful content expose misleading state to AT) and WCAG 2.4.3 Focus Order (empty containers should not receive keyboard focus).
Wrap the entire structure in an aria-hidden + inert container, and place aria-busy on a separate outer wrapper that remains visible to AT. Both attributes must not share the same element — placing aria-busy on an aria-hidden element means AT never sees it and the loading signal has no effect:
// ✅ aria-busy visible to AT on the outer wrapper;
// aria-hidden + inert remove role="tablist" / role="tabpanel" from the AT tree and tab order
<div aria-busy="true">
<div aria-hidden="true" {...{ inert: true }}>
<TabsRoot>…skeleton content…</TabsRoot>
</div>
</div>This ensures no empty tab roles are announced and no skeleton container receives keyboard focus. The same principle applies to any component that renders role="grid", role="listbox", role="menu", or other composite ARIA roles around skeleton content.
Motion
The pulse animation uses the motion-safe:animate-pulse modifier, which automatically disables animation for users who have enabled Reduce Motion in their system preferences (WCAG 2.2 SC 2.3.3 — Level AAA).
Colour Contrast
Skeleton text is visually hidden via !text-transparent and removed from the accessibility tree via aria-hidden="true", so the WCAG 2.2 AA text contrast requirement (SC 1.4.3, 4.5:1) does not apply to the hidden text inside a skeleton. The default placeholder colours (pmi-neutral-50 / pmi-neutral-100 in light mode and their dark counterparts) are chosen to look clearly different from real content — making the loading state visually obvious — without implying any readable text. When overriding colours, choose placeholder tones that remain visually distinct from actual content so users can tell at a glance that the page is still loading.
Choosing the Right Component
Use Skeleton when the shape of the incoming content is known in advance. Choose a different primitive when:
| If you need… | Use instead |
|---|---|
| An indeterminate spinner (unknown content shape) | Spinner |
| A determinate progress indicator with a percentage | Progress Bar |
| A page-level loading bar | Loader |
| A completion notification after the task finishes | Toast |