# Skeleton

> Placeholders for content that is loading, hidden from assistive technology.

Page: https://svelte-lean.vercel.app/primitives/skeleton · Markdown: https://svelte-lean.vercel.app/primitives/skeleton.md · Index: https://svelte-lean.vercel.app/llms.txt

Placeholders shaped like the content that is loading, so the layout holds still until it arrives. CSS only.

```svelte
<script lang="ts">
	// Nothing to import at runtime: the placeholders are CSS. The stylesheets are optional.
	import '@svelte-lean/styles/progress.css';
	import '@svelte-lean/styles/skeleton.css';
</script>

<!-- The loading state in words, outside the busy region. -->
<p class="status" role="status">
	<progress data-slean="progress" data-variant="circular" data-size="small" aria-hidden="true"></progress>
	Loading the team
</p>
<div class="card" aria-busy="true">
	<div class="person">
		<span data-slean="skeleton" data-variant="circular" 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="rounded" 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>

<style>
	.status {
		display: flex;
		align-items: center;
		gap: var(--slean-space-2);
		margin-block: 0 var(--slean-space-3);
	}

	.card {
		display: grid;
		gap: var(--slean-space-4);
		max-inline-size: 24rem;
	}

	.person {
		display: flex;
		align-items: center;
		gap: var(--slean-space-3);
	}

	.lines {
		flex: 1;
	}
</style>
```

The placeholders are aria-hidden; the status line outside the busy region carries the text. Under reduced motion they stay flat.

## Examples

### Swapping in the content

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/progress.css';
	import '@svelte-lean/styles/skeleton.css';

	let loading = $state(false);

	function reload() {
		loading = true;
		// Stands in for a request.
		setTimeout(() => (loading = false), 1800);
	}
</script>

<div class="toolbar">
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" onclick={reload}>Reload</button>
	<!-- In the page from the start and empty: the text written into it is read. -->
<p class="status" role="status">
	{#if loading}
		<progress data-slean="progress" data-variant="circular" data-size="small" aria-hidden="true"></progress>
		Loading the profile
	{/if}
</p>
</div>
<div class="card" aria-busy={loading}>
	{#if loading}
		<div class="person">
			<span data-slean="skeleton" data-variant="circular" 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>
		<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>
	{:else}
		<div class="person">
			<span class="avatar" aria-hidden="true">AO</span>
			<div class="lines">
				<strong>Amara Okafor</strong>
				<p>Design lead, Istanbul</p>
			</div>
		</div>
		<p>Works on the design system and the documentation site. Reviews every new primitive.</p>
	{/if}
</div>

<style>
	.toolbar {
		display: flex;
		align-items: center;
		gap: var(--slean-space-3);
		margin-block-end: var(--slean-space-4);
	}

	.card {
		display: grid;
		gap: var(--slean-space-4);
		max-inline-size: 24rem;
	}

	.person {
		display: flex;
		align-items: center;
		gap: var(--slean-space-3);
	}

	.lines {
		flex: 1;
	}

	.card p {
		margin: 0;
	}

	.lines p {
		color: var(--slean-fg-muted);
		font-size: var(--slean-text-sm);
	}

	.avatar {
		display: grid;
		place-items: center;
		inline-size: 2.5rem;
		block-size: 2.5rem;
		border-radius: var(--slean-radius-full);
		background: var(--slean-muted);
		font-size: var(--slean-text-sm);
		font-weight: var(--slean-font-weight-medium);
	}
</style>
```

The placeholders and the content are replaced in one update; the status line says what is loading.

### A list

```svelte
<script lang="ts">
	import '@svelte-lean/styles/skeleton.css';
</script>

<ul class="rows" aria-busy="true" aria-label="Messages">
	{#each [1, 2, 3] as row (row)}
		<li>
			<span data-slean="skeleton" data-variant="circular" data-size="small" 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>
		</li>
	{/each}
</ul>

<style>
	.rows {
		display: grid;
		gap: var(--slean-space-4);
		max-inline-size: 24rem;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.rows li {
		display: flex;
		align-items: center;
		gap: var(--slean-space-3);
	}

	.lines {
		flex: 1;
	}
</style>
```

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/skeleton.css';
</script>

<div class="shapes" aria-hidden="true">
	<div class="circles">
		<span data-slean="skeleton" data-variant="circular" data-size="small"></span>
		<span data-slean="skeleton" data-variant="circular"></span>
		<span data-slean="skeleton" data-variant="circular" data-size="large"></span>
	</div>
	<div>
		<span data-slean="skeleton" data-variant="text"></span>
		<span data-slean="skeleton" data-variant="text"></span>
	</div>
	<!-- rectangular and rounded take their size from the application's CSS. -->
	<div class="circles">
		<span data-slean="skeleton" data-variant="rectangular" class="thumbnail"></span>
		<span data-slean="skeleton" data-variant="rounded" class="thumbnail"></span>
	</div>
</div>

<style>
	.shapes {
		display: grid;
		gap: var(--slean-space-4);
		max-inline-size: 24rem;
	}

	.circles {
		display: flex;
		align-items: center;
		gap: var(--slean-space-3);
	}

	.thumbnail {
		inline-size: 8rem;
		block-size: 5rem;
	}
</style>
```

The last of several text lines is shorter; a circular one takes the small or large size.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@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, in a status region that is in the page before the wait begins: 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\|rectangular\|rounded\|circular"; data-size="small\|large" on a circular one. |
| busy region | `<div aria-busy="true">` | – | yes | The element the content will fill. The loading text stays outside it. |
| status | `<p role="status">` | – | yes | Says what is loading, in words, outside the busy region; in the page before the wait begins. A small circular progress beside the text may be aria-hidden. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | anywhere | Skips the placeholders: they are not focusable |

## 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` | `calc(var(--slean-radius) * 0.6)` | 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` | `var(--slean-radius)` | border-radius |

State selectors the stylesheet targets, all from the platform or ARIA: `:dir(rtl)`, `:first-child`, `:last-child`.

Variant attributes: `data-variant` (`text`, `circular`, `rectangular`, `rounded`); `data-size` (`small`, `large`).

## 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.

`team.svelte`

```svelte
<script lang="ts">
	import Team from './Team.svelte';

	const team = fetch('/api/team').then((response) => response.json());
</script>

{#await team}
	<p class="status" role="status">
	<progress data-slean="progress" data-variant="circular" data-size="small" aria-hidden="true"></progress>
	Loading the team
</p>
	<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}
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **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`

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.

Why this implementation

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, rectangular, rounded and circular shapes, the shorter last line, the sizes
- the shimmer, flat at rest and under reduced motion
- the contract
- documentation

Accessibility

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

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 and hydration

Render the placeholders on the server when the content is fetched in the browser. Content the server renders needs no skeleton.

Nothing is attached. The placeholders rendered on the server stay until the application replaces them.

Compatibility

`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.

Tests and source

- `apps/playground/tests/primitives/feedback-navigation.spec.ts` Playwright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/skeleton/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/skeleton/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/skeleton.css` the optional stylesheet
