# Skeleton Loading Skeleton loaders show the shape of content while it loads, reducing perceived wait time and preventing layout shift. Section: components Docs: https://nebula.kaluza.com/components/skeleton-loading --- ## 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](/components/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 1. **Skeleton shape** (a block with the background colour `colors.borderMuted`) 2. **Gleam** (an animated highlight that sweeps left to right across the shape, pausing on `prefers-reduced-motion`) ## Basic usage ```tsx ``` ## 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. ```tsx ``` ### 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. ```tsx <> ``` ### SkeletonCTA Use to represent a button or call-to-action area. Defaults to `25rem` wide and `space[12]` (48px) tall. ```tsx ``` ### SkeletonCircle Use to represent a circular element such as an avatar or icon container. Defaults to `12rem`. ```tsx ``` ### 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. ```tsx ``` ### 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. ```tsx ``` ## Rules and guidance ### Usage - **Skeleton shape** MUST match the size and position of the real content it represents. Rationale: A mismatch causes layout shift when content loads, which is disorienting and can shift the user's scroll position. [id: skeletonloading.match-content-shape; testability: manual] - **Skeleton loading** MUST NOT be used for content that does not change between renders. Rationale: Static content should be rendered immediately. A skeleton implies the content is dynamic and loading. [id: skeletonloading.static-content; testability: manual] - **Skeleton shape** MUST use colors.borderMuted as its background colour. Rationale: Raw colour values deviate from the design token system and will not respond to theme changes. Exception: using a named variant, which applies colors.borderMuted automatically. [id: skeletonloading.background-color; testability: auto] - **Skeleton base component** SHOULD only be used directly when no named variant (SkeletonText, SkeletonHeading, SkeletonCTA, SkeletonCircle) fits the content shape. [id: skeletonloading.named-variant-first; testability: manual] - **Skeleton loading** SHOULD NOT be shown for operations expected to complete in under 300ms. Rationale: A skeleton that appears and disappears almost immediately creates more visual noise than showing nothing. [id: skeletonloading.min-duration; testability: context] ### Content - **Skeleton shape** MUST NOT contain real visible text content. Rationale: Skeleton shapes are placeholders. Real text implies the content has loaded. [id: skeletonloading.no-real-text; testability: auto] ### 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 } internally. [id: skeletonloading.reduced-motion; testability: auto] - **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 contains. Rationale: SkeletonHeading renders a non-blank heading element. Without aria-hidden, screen readers announce a meaningless whitespace character. [id: skeletonloading.heading-aria-hidden; testability: auto] - **Skeleton shape** MUST NOT be interactive or receive focus. [id: skeletonloading.no-interaction; testability: auto] ### Composition - **SkeletonCircle** MUST NOT have its aspect ratio overridden. Rationale: SkeletonCircle uses a padding-bottom trick to maintain a 1:1 aspect ratio. Overriding padding will break the circle shape. [id: skeletonloading.circle-aspect-ratio; testability: auto] - **SkeletonHeading** SHOULD use a headingComponent prop that matches the semantic level of the real heading it replaces. Rationale: Heading 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. [id: skeletonloading.heading-component-match; testability: manual] - **Skeleton screen** SHOULD use the same layout components as the real view. Rationale: Using the same layout containers prevents reflow when real content loads. [id: skeletonloading.layout-match; testability: manual] ## Accessibility ### 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](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) ### 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](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) #### 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](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) ### 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 each test covers: - 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. What these statuses mean: - 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. 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. ## Properties ### SkeletonCircle Also accepts all props from `HTMLAttributes`. - `size` (type `string`, optional, default `'12rem'`). 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`. - `size` (type `string`, optional, default `'25rem'`). Width of the button placeholder as a CSS length (for example `15rem`). The height is fixed. ### SkeletonHeading Also accepts all props from `HTMLAttributes`. - `size` (type `string`, optional, default `'70%'`). Width of the heading placeholder as a CSS length or percentage (for example `90%`). - `headingComponent` (type `ComponentType`, optional, default `Heading3`). Heading component the placeholder renders as, such as `Heading2`, so its height matches the real heading. ### SkeletonText Also accepts all props from `HTMLAttributes`. - `lines` (type `number`, optional, default `1`). Number of placeholder lines to render. The last line is half width. ## Styles Reference only; you do not need this to use the component. This is the CSS its styled elements use, with theme tokens shown by name. `${...}` is an interpolation (a theme token, a mixin or another value from the code). A `&[prop]` or `@when ` block holds CSS that applies only under that condition; these are notation, not valid CSS. ### skeleton ```css 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 ```css width: ${size}; max-width: 100%; ``` ### skeleton-circle ```css background-color: ${colors.borderMuted}; height: 0; padding-bottom: 100%; border-radius: 50%; ``` ### skeleton-cta ```css background-color: ${colors.borderMuted}; width: ${size}; height: ${space[12]}; ``` ### skeleton-heading ```css background-color: ${colors.borderMuted}; width: ${size}; ``` ### skeleton-text ```css background-color: ${colors.borderMuted}; height: 1em; margin-bottom: ${space[2]}; &:last-child { width: 50%; } ```