EmSpinner
An indeterminate loading ring in three sizes. A visually hidden label tells assistive technology what is loading.
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 Vuetify0
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 Vuetify0 composable underneath — this is the smallest kind of Emerald component there is.
EmButton draws its own ring for the loading state, so the spinner sits in the button layout and uses the variant’s text color. Use the button’s loading prop there. Use EmSpinner everywhere else.
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.