sveltelean Primitives
Versionv0.2.0 GitHub

Example

Sizes
Loading the comments Loading the results Loading the report
<span data-slean="spinner" data-size="sm" role="status">
	<span data-slean-part="label">Loading the comments</span>
</span>
<span data-slean="spinner" role="status">
	<span data-slean-part="label">Loading the results</span>
</span>
<span data-slean="spinner" data-size="lg" role="status">
	<span data-slean-part="label">Loading the report</span>
</span>
Tier 0: the two sources differ only by the stylesheet import. Each ring is the root element’s border; the label inside is hidden visually and read by assistive technology. Everything here works with page JavaScript disabled.

Why this implementation exists

A spinner is a picture of waiting, and the picture is the part a screen reader cannot use. What matters is the text: a label that says what is loading, inside a status region that announces it.

Svelte Lean ships a ring drawn from one element’s border, a label part the stylesheet hides visually, and a rule for the announcement: an empty spinner draws nothing, so it can wait in the page and show the ring when the application writes the label.

The browser owns

  • the status role and handing a changed label to assistive technology
  • running the CSS animation, and reporting the reduced motion preference

Svelte Lean owns

  • spinner.css: the ring, its rotation from the duration tokens, the sizes
  • the hidden label and the empty spinner that draws nothing
  • 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/spinner.css';

Write what is loading in the label (“Loading the results”), not only “Loading”. Mark the region being loaded with aria-busy="true" until it is filled.

Inside a button, use the button’s own aria-busy="true" state instead: a status region inside a button adds its label to the button’s name.

Anatomy

PartElementdata-slean-partRequiredNotes
root<span data-slean="spinner" role="status">–yesThe ring. data-size="sm|lg". Takes currentColor.
label<span> with textlabelyesHidden visually, read by assistive technology. Without it the ring is not drawn.

Runtime profile

Tier 0: the spinner has no behavior module, the Vite plugin maps spinner to no module, and the page ships no Svelte Lean JavaScript for it. The ring turns through a CSS animation.

Accessibility contract

  • role="status" with the label as text content: a live region announces its content, not an aria-label.
  • A spinner inserted together with its label may not be announced; keep it in the page and write the label when the wait begins.
  • The label is clipped to one pixel and stays in the accessibility tree.
  • Under reduced motion the indicator duration token (--slean-duration-indicator) grows instead of dropping to zero: the ring keeps turning, slower, and the label is unchanged.

Keyboard

KeyWhenResult
TabanywhereSkips the spinner: it is not focusable

Platform features

FeatureBaselineOutside the target
role="status"ARIA 1.1 live region role, exposed by every current browserNot applicable; speech timing differs between screen readers
rotateBaseline 2022The ring stands still
color-mix() for the trackBaseline 2023The track uses the strong border token

Without JavaScript

The ring is CSS and turns without JavaScript. Nothing ends the wait without the application’s script.

Server rendering

A page rendered in a loading state renders the spinner with its label; it is read in order and not announced. A spinner for a later wait is rendered empty.

Before hydration

Nothing is attached. An empty spinner rendered on the server is the status region the application writes into after hydration.

Styling

spinner.css draws the ring as the root’s border: a color-mix() tint of currentColor with the block-start side in currentColor, turned by a keyframe animation on the rotate property whose duration comes from the tokens. The spinner takes the text color around it. An empty spinner keeps its box and draws no ring. Under forced colors the moving side is CanvasText on a GrayText track. The tokens it reads:

TokenDefault (light)Applies to
--slean-border-strongvar(--slean-neutral-8)border
--slean-duration-indicator750msanimation

State selectors the stylesheet targets, all from the platform or ARIA: :empty.

Variant attributes: data-size (sm, lg).

Compatibility notes

role="status" is an ARIA 1.1 live region role, exposed by every current browser; screen readers differ in when they speak a change. The rotate property is Baseline 2022 and color-mix() Baseline 2023; without the first the ring stands still, without the second the track uses the strong border token.

Examples

Announced wait

The spinner is in the page from the start and empty, so it draws nothing. Refreshing writes the label into it: the ring appears and a screen reader announces the label. When the wait ends the label is removed and the ring with it.

refresh.svelte
<script lang="ts">
	let loading = $state(false);

	async function refresh() {
		loading = true;
		await fetch('/api/results');
		loading = false;
	}
</script>

<button type="button" data-slean="button" onclick={refresh}>Refresh</button>

<!-- In the page from the start: empty, it draws nothing; the label brings the ring and is read. -->
<span data-slean="spinner" role="status">
	{#if loading}<span data-slean-part="label">Loading the results</span>{/if}
</span>

Testing

Source