On this page
Skeleton Loading
Skeleton loaders show the shape of content while it loads, reducing perceived wait time and preventing layout shift.
- Overview
- Rules and guidance
- Properties
- Styles
- Accessibility
When to use
- When content takes more than around 300ms to load and the final layout is known in advance
- When the page or section loads all its content at roughly the same time
- To prevent layout shift: show skeleton shapes that match the size and position of the real content
- To reduce perceived wait time by giving users a preview of what is coming
When not to use
- For operations under 300ms - a skeleton that flashes in and out creates more disruption than showing nothing. Use no indicator at all for very fast operations.
- When the final layout is unknown - if you cannot accurately match the real content shape, a spinner is less disorienting. Use the Notification component to surface status messages when a background operation completes instead.
- For the full page on every visit - once a user has seen the page layout, subsequent loads may not need skeletons. Cache what you can and show real content as soon as it is available.
- When content is static - do not skeleton-load content that never changes. Show it immediately.
Anatomy
- Skeleton shape (a block with the background colour colors.borderMuted)
- Gleam (an animated highlight that sweeps left to right across the shape, pausing on prefers-reduced-motion)
Basic usage
Edit live code
<Card> <SkeletonHeading /> <SkeletonText lines={3} /> <SkeletonCircle /> <SkeletonCTA /> </Card>
Variants and features
SkeletonText
Use to represent one or more lines of body text. The last line renders at 50% width to mimic the natural end of a paragraph.
Edit live code
<SkeletonText lines={3} />
SkeletonHeading
Use to represent a heading. Defaults to Heading3 at 70% width. Pass headingComponent to match the real heading level, and size to match the real heading width.
Edit live code
<> <SkeletonHeading /> <SkeletonHeading headingComponent={Heading2} size="90%" /> </>
SkeletonCTA
Use to represent a button or call-to-action area. Defaults to 25rem wide and space[12] (48px) tall.
Edit live code
<SkeletonCTA size="20rem" />
SkeletonCircle
Use to represent a circular element such as an avatar or icon container. Defaults to 12rem.
Edit live code
<SkeletonCircle size="20rem" />
Custom shapes with Skeleton
Skeleton is the base styled component. Use it directly to represent content shapes that none of the named variants cover. Set background-color and dimensions to match your content.
Edit live code
<Skeleton style={{width: 200, height: 40}} />
Reduced motion
The gleam animation is automatically disabled when the user has prefers-reduced-motion set. No additional configuration is needed.
Composing a skeleton screen
Build a skeleton screen by composing the variants in the same layout as the real content. Use the same layout components (Stack, Cluster, etc.) that the real view uses so that content snaps into place without shifting.
Edit live code
<Card> <Stack spaceBetween={4}> <SkeletonHeading headingComponent={Heading2} size="60%" /> <SkeletonText lines={3} /> <Cluster spaceBetween={2}> <ClusterItem> <SkeletonCTA size="14rem" /> </ClusterItem> <ClusterItem> <SkeletonCTA size="14rem" /> </ClusterItem> </Cluster> </Stack> </Card>
usage
- Skeleton shape must match the size and position of the real content it representsA mismatch causes layout shift when content loads, which is disorienting and can shift the user's scroll position.Copy rule ID for skeletonloading.match-content-shape
- Skeleton loading must not be used for content that does not change between rendersStatic content should be rendered immediately. A skeleton implies the content is dynamic and loading.Copy rule ID for skeletonloading.static-content
- Skeleton shape must use colors.borderMuted as its background colourRaw colour values deviate from the design token system and will not respond to theme changes.Exception: using a named variant, which applies colors.borderMuted automaticallyCopy rule ID for skeletonloading.background-color
- Skeleton base component should only be used directly when no named variant (SkeletonText, SkeletonHeading, SkeletonCTA, SkeletonCircle) fits the content shapeCopy rule ID for skeletonloading.named-variant-first
- Skeleton loading should not be shown for operations expected to complete in under 300msA skeleton that appears and disappears almost immediately creates more visual noise than showing nothing.Copy rule ID for skeletonloading.min-duration
content
- Skeleton shape must not contain real visible text contentSkeleton shapes are placeholders. Real text implies the content has loaded.Copy rule ID for skeletonloading.no-real-text
interaction
- Skeleton gleam animation will be disabled automatically when the user has prefers-reduced-motion set. The component applies @media (prefers-reduced-motion) { animation: none } internallyCopy rule ID for skeletonloading.reduced-motion
- SkeletonHeading will render with aria-hidden="true" applied automatically. The component hard-codes this attribute to prevent screen readers announcing the non-semantic whitespace character it containsSkeletonHeading renders a non-blank heading element. Without aria-hidden, screen readers announce a meaningless whitespace character.Copy rule ID for skeletonloading.heading-aria-hidden
- Skeleton shape must not be interactive or receive focusCopy rule ID for skeletonloading.no-interaction
composition
- SkeletonCircle must not have its aspect ratio overriddenSkeletonCircle uses a padding-bottom trick to maintain a 1:1 aspect ratio. Overriding padding will break the circle shape.Copy rule ID for skeletonloading.circle-aspect-ratio
- SkeletonHeading should use a headingComponent prop that matches the semantic level of the real heading it replacesHeading levels affect document outline and screen reader navigation. A skeleton heading that uses a different level than the real heading disrupts the outline structure during loading.Copy rule ID for skeletonloading.heading-component-match
- Skeleton screen should use the same layout components as the real viewUsing the same layout containers prevents reflow when real content loads.Copy rule ID for skeletonloading.layout-match
SkeletonCircle
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
size | string | '12rem' | No | Diameter of the circle as a CSS length (for example `8rem`). Shrinks to fit if the container is narrower. |
SkeletonCTA
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
size | string | '25rem' | No | Width of the button placeholder as a CSS length (for example `15rem`). The height is fixed. |
SkeletonHeading
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
size | string | '70%' | No | Width of the heading placeholder as a CSS length or percentage (for example `90%`). |
headingComponent | ComponentType<any> | Heading3 | No | Heading component the placeholder renders as, such as `Heading2`, so its height matches the real heading. |
SkeletonText
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
lines | number | 1 | No | Number of placeholder lines to render. The last line is half width. |
Styles
skeleton
Defined in SkeletonLoading/Skeleton.tsx
position: relative;
overflow: hidden;
display: block;
&:after {
content: '';
position: absolute;
opacity: 0.6;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient( 90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 50%, rgba(255, 255, 255, 0) 100% );
background-size: 100px;
background-repeat: no-repeat;
animation: ${gleam} 2s infinite;
${mediaQueries.smallAndUp} {
animation-duration: 3s;
}
${mediaQueries.mediumAndUp} {
animation-duration: 4s;
}
@media (prefers-reduced-motion) {
animation: none;
}
}
skeleton-circle-wrapper
Defined in SkeletonLoading/SkeletonCircle.tsx
width: ${size};
max-width: 100%;
skeleton-circle
Defined in SkeletonLoading/SkeletonCircle.tsx
background-color: ${colors.borderMuted};
height: 0;
padding-bottom: 100%;
border-radius: 50%;
skeleton-cta
Defined in SkeletonLoading/SkeletonCTA.tsx
background-color: ${colors.borderMuted};
width: ${size};
height: ${space[12]};
skeleton-heading
Defined in SkeletonLoading/SkeletonHeading.tsx
background-color: ${colors.borderMuted};
width: ${size};
skeleton-text
Defined in SkeletonLoading/SkeletonText.tsx
background-color: ${colors.borderMuted};
height: 1em;
margin-bottom: ${space[2]};
&:last-child {
width: 50%;
}
In brief
Skeleton Loading components are empty placeholders for content that is still loading. They add no role, name or loading state, so a screen reader user gets nothing from them: the consumer announces that content is loading and when it has arrived.
What Nebula provides
- SkeletonHeading is hidden from screen readers, so it does not leave an empty heading in the outline.
- The other placeholders hold no text, so there is nothing for a screen reader to read.
- The gleam animation stops when the user has asked for reduced motion.
Accessible design
Write the loading and loaded messages with the design
The placeholders are silent, so the only thing a screen reader user hears is the message the design specifies. Write the wording for both moments, for example "Loading your usage" and "Your usage has loaded".
WCAG: 4.1.3 Status Messages
Accessible development
Always render the status message region before the skeletons appear
A live region is only announced when its content changes while it is already on the page. Render the region empty, set the loading message when the skeletons show and the loaded message when the content arrives.
WCAG: 4.1.3 Status Messages
Never put text or controls inside a skeleton
Only SkeletonHeading is hidden from screen readers. Anything placed inside a custom Skeleton is read, or reached with Tab, while the user is told the content is loading.
WCAG: 1.3.1 Info and Relationships
Accessibility testing status
Reviewed on 9 October 2026 against v17.0.1.
| Test | Status | Tested with |
|---|---|---|
| Default state | Tested | axe-core via the Storybook test runner (CI) |
| Advanced states | Partially tested | axe-core via the Storybook test runner (CI) |
| Keyboard navigation | Manually tested | keyboard (reported by the owner) |
| Screen reader | Partially tested | VoiceOver with Chrome (reported by the owner) |
What these tests and statuses mean
The table records which checks were run on this component on its own. It is not a statement of WCAG conformance, and it does not cover how the component is used.
The tests
- Default state: The component as it first appears, before anything is clicked or changed.
- Advanced states: Every other state and variant: error, open, selected, each size and style, and hovering.
- Keyboard navigation: Whether every function can be reached and used with the keyboard alone, with a visible focus ring.
- Screen reader: Whether a screen reader announces what is on screen correctly: the name, the role and any state.
The statuses
- Tested: Every check in this row passed in an automated run that repeats on every pull request.
- Manually tested: Every check in this row was run once, by a person or with a tool they started, and all of them passed. It is not re-run on every change.
- Partially tested: Some checks in this row were run once and passed; the rest are still to do.
- Issue found: A check in this row failed. The ticket is shown beside the status.
- Not tested: No check in this row has been run yet.
- Not applicable: This row does not apply to the component, for the reason given beside it.