Primitives Feedback
Skeleton
Placeholders shaped like the content that is loading. They are hidden from assistive technology; the region that is loading says so with aria-busy, and a status message says it in words. CSS only. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
aria-hidden,aria-busy,prefers-reduced-motion,:dir(rtl)- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
aria-hidden placeholders in an aria-busy region
On this page
Example
<span data-slean="spinner" role="status">
<span data-slean-part="label">Loading the team</span>
</span>
<!-- The classes are the application's layout: a row for the person, a column of lines. -->
<div class="card" aria-busy="true">
<div class="person">
<span data-slean="skeleton" data-variant="circle" aria-hidden="true"></span>
<div class="lines">
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
</div>
</div>
<span data-slean="skeleton" data-variant="block" aria-hidden="true"></span>
<div class="lines">
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
</div>
</div><script lang="ts">
// Nothing to import at runtime: the placeholders are CSS. The stylesheets are optional.
import '@svelte-lean/styles/spinner.css';
import '@svelte-lean/styles/skeleton.css';
</script>
<span data-slean="spinner" role="status">
<span data-slean-part="label">Loading the team</span>
</span>
<!-- The classes are the application's layout: a row for the person, a column of lines. -->
<div class="card" aria-busy="true">
<div class="person">
<span data-slean="skeleton" data-variant="circle" aria-hidden="true"></span>
<div class="lines">
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
</div>
</div>
<span data-slean="skeleton" data-variant="block" aria-hidden="true"></span>
<div class="lines">
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
</div>
</div>Why this implementation exists
A skeleton keeps the layout in place while content loads, so the page does not jump when it arrives. It is a visual device: an empty shape means nothing to a screen reader, and a list of empty shapes read aloud is noise.
Svelte Lean ships the shapes as CSS, hidden with aria-hidden, and a contract that puts the meaning elsewhere: aria-busy on the region that will hold the content, and the loading state in text outside that region.
The browser owns
- removing the placeholders from the accessibility tree (aria-hidden)
- telling assistive technology that the region is updating (aria-busy)
- running the CSS animation, and reporting the reduced motion preference
Svelte Lean owns
- skeleton.css: the text, circle and block shapes, the shorter last line, the sizes
- the shimmer, flat at rest and under reduced motion
- the contract
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/skeleton.css';Render the placeholders inside the element the content will fill, give it aria-busy="true", and replace them with the content in one update.
Say that the region is loading in text outside the busy region: a spinner’s label or a sentence in a status region. A screen reader may skip the content of a busy region.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <span data-slean="skeleton" aria-hidden="true"> | – | yes | data-variant="text|circle|block"; data-size="sm|lg" on a circle. |
| busy region | <div aria-busy="true"> | – | yes | The element the content will fill. The loading text (a spinner’s label) stays outside it. |
Runtime profile
Tier 0: the skeleton has no behavior module, the Vite plugin maps skeleton to no module, and the page ships no Svelte Lean JavaScript for it. The shimmer is a CSS animation.
Accessibility contract
- Each placeholder is empty and
aria-hidden="true". - The region that is loading carries
aria-busy="true"until the content is in place. - The loading state is said in text outside the busy region.
- Under reduced motion the duration tokens are zero and the placeholders are a flat tint.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | anywhere | Skips the placeholders: they are not focusable |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
aria-hidden, aria-busy | ARIA 1.1 states, exposed by every current browser | Not applicable; screen readers differ in their use of aria-busy |
:dir() | Baseline 2023 | The shimmer moves from left to right under RTL as well |
Without JavaScript
The placeholders are CSS and render; they stay until the application’s script replaces them. A page whose content arrives only through the script needs a server-rendered alternative.
Server rendering
Render the placeholders on the server when the content is fetched in the browser. Content the server renders needs no skeleton.
Before hydration
Nothing is attached. The placeholders rendered on the server stay until the application replaces them.
Styling
skeleton.css gives each shape the muted tint and a band of the hover tint in a background three times the box’s width. A keyframe animation moves the band across; at rest, and under reduced motion, the band sits outside the box. The last of several text lines is shorter. Under forced colors the shapes become outlines. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-radius-sm | 0.375rem | border-radius |
--slean-muted | var(--slean-neutral-3) | background-color |
--slean-muted-hover | var(--slean-neutral-4) | background-image |
--slean-duration-slow | 240ms | animation |
--slean-control-height-md | 2.25rem | inline-size, block-size |
--slean-control-height-sm | 2rem | inline-size, block-size |
--slean-radius-md | 0.625rem | border-radius |
State selectors the stylesheet targets, all from the platform or ARIA: :dir(rtl), :first-child, :last-child.
Variant attributes: data-variant (text, circle, block); data-size (sm, lg).
Controlled integration
The application owns the loading state. With {#await} the placeholders are the pending branch and the content the resolved one, so they are replaced in one update.
<script lang="ts">
import Team from './Team.svelte';
const team = fetch('/api/team').then((response) => response.json());
</script>
{#await team}
<span data-slean="spinner" role="status">
<span data-slean-part="label">Loading the team</span>
</span>
<div aria-busy="true">
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
<span data-slean="skeleton" data-variant="text" aria-hidden="true"></span>
</div>
{:then members}
<div aria-busy="false"><Team {members} /></div>
{/await}Compatibility notes
aria-hidden and aria-busy are ARIA 1.1 states, exposed by every current browser; screen readers differ in what they do with aria-busy. :dir() (Baseline 2023) reverses the shimmer under RTL.
Examples
Variants
text is a line at the height of the text around it, and the last of several lines
is shorter; circle takes data-size="sm" or "lg"; block stands for an image or a card. Other sizes come from the application’s own CSS.
Testing
apps/playground/tests/primitives/feedback-navigation.spec.tsPlaywright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/skeleton/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/skeleton/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/skeleton.cssthe optional stylesheet