EmSpinner
An indeterminate loading ring in three sizes, with a visually hidden label so assistive technology hears what sighted readers see spinning.
Usage
EmSpinner is a fixed-anatomy single span: a rotating ring for the eyes and a hidden text label for everyone else. It has exactly two props — size steps the ring through three diameters, and label sets the announced text. There are no slots and no models; a spinner either exists or it does not, and v-if is how you say which.
It is indeterminate only. There is no value, no percentage, no track — it says “something is happening”, not “how much is left”. When you can measure progress, reach for a determinate control instead; a spinner that runs for thirty seconds tells the reader less each second it keeps spinning.
The two spinners below look identical on purpose: label changes what a screen reader announces, not what renders. Default it says “Loading”; pass something more specific whenever you know what is loading.
Anatomy
<script setup lang="ts">
import { EmSpinner } from '@paper/emerald'
</script>
<template>
<EmSpinner />
</template>Composed on v0
EmSpinner renders v0’s Atom — the polymorphic primitive at the bottom of the component system — pinned to as="span". Atom’s other modes are not exposed: the component forwards neither as nor renderless, so the anatomy is fixed. A spinner is always one span wrapping a ring and a hidden label.
What the Atom base contributes is attribute fallthrough. Anything you put on EmSpinner that is not a prop — class, style, an id, extra ARIA — lands on the rendered span, which is how the recoloring pattern below works without the component needing a color prop. The role="status" and aria-live="polite" on that span are static attributes Emerald places on the Atom, not behavior v0 adds.
The rest is the usual Emerald split: the component publishes data-size and the stylesheet hangs every dimension off it, while the ring’s color is simply currentcolor against a root that defaults to Emerald’s primary green. There is no context, no namespace, and no v0 composable underneath — this is the smallest kind of Emerald component there is.
One relative worth knowing about: EmButton’s loading state draws its own ring rather than nesting EmSpinner, so it can sit inside the button’s layout and inherit the variant’s text color. Use the button’s loading prop there; EmSpinner is for everywhere that is not a button.
Examples
Props
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'sm' | 'md' | 'lg' | 'md' | Ring geometry: 14px, 20px, or 28px diameter, with a heavier stroke at lg |
label | string | 'Loading' | Accessible name — rendered as visually hidden text inside the live region |
There are no slots, no models, and no emits. There is no namespace prop — the spinner binds to nothing — and non-prop attributes fall through to the rendered span.
Accessibility
The root span is role="status" with an explicit aria-live="polite" — a polite live region whose content is the hidden label. The ring itself is aria-hidden; the label text is the entire accessible surface.
The label is the announcement
.emerald-spinner__label is visually hidden but present in the accessibility tree, so the region’s text is “Loading” — or whatever label says. Because the region is polite, updating label while the spinner is mounted announces the new text without interrupting whatever the reader was hearing.
Be aware that a live region inserted into the DOM together with its content is not reliably announced — some screen readers only report changes to regions they already knew about. If the announcement matters, either keep a region mounted and change its text, or surface the outcome elsewhere when the wait ends: a spinner tells the reader a wait started, not that it finished. Say what happened when it does.
What the spinner does not set
EmSpinner sets no aria-busy — that attribute belongs on the region that is busy, not on the indicator inside it. When a whole panel is loading, put aria-busy="true" on the panel and let the spinner be its visible sign. It also claims no relationship to any control: pair it with EmButton’s own loading prop for in-button waits rather than composing the two.
Motion
The ring’s rotation is a plain CSS animation with no reduced-motion override — it spins regardless of prefers-reduced-motion. The animation is small and contained, which is usually acceptable under WCAG’s essential-motion carve-out for loading indicators, but if your product honors the preference strictly, pause the emerald-spinner-spin animation yourself in a reduced-motion media query.