sveltelean Primitives
Versionv0.2.0 GitHub

Example

A card that is loading
Loading the team
<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>
Tier 0: the two sources differ only by the stylesheet imports. The placeholders are aria-hidden; the spinner outside the busy region carries the text. The shimmer stops, and the placeholders stay flat, when the reader prefers reduced motion. Everything here works with page JavaScript disabled.

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/styles
stylesheets
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: a spinner’s label or a sentence in a status region. A screen reader may skip the content of a busy region.

Anatomy

PartElementdata-slean-partRequiredNotes
root<span data-slean="skeleton" aria-hidden="true">–yesdata-variant="text|circle|block"; data-size="sm|lg" on a circle.
busy region<div aria-busy="true">–yesThe 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

KeyWhenResult
TabanywhereSkips the placeholders: they are not focusable

Platform features

FeatureBaselineOutside the target
aria-hidden, aria-busyARIA 1.1 states, exposed by every current browserNot applicable; screen readers differ in their use of aria-busy
:dir()Baseline 2023The 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:

TokenDefault (light)Applies to
--slean-radius-sm0.375remborder-radius
--slean-mutedvar(--slean-neutral-3)background-color
--slean-muted-hovervar(--slean-neutral-4)background-image
--slean-duration-slow240msanimation
--slean-control-height-md2.25reminline-size, block-size
--slean-control-height-sm2reminline-size, block-size
--slean-radius-md0.625remborder-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.

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

Source