Skip to main content
Vuetify0 v1.0 is here
Vuetify0 Logo
Theme
Mode
Palettes
Accessibility
Vuetify One
Sign in to Vuetify One

Access premium tools across the Vuetify ecosystem — Bin, Play, Studio, and more.

Not a subscriber? See what's included

EmSpinner

Edit this page
Report a Bug
Open issues
Copy Markdown

Renders elementBeginnerAug 14, 2026

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.

<script setup lang="ts">
  import { EmSpinner } from '@paper/emerald'
</script>

<template>
  <div class="emerald-docs-row">
    <EmSpinner />

    <EmSpinner label="Loading messages" />
  </div>
</template>

<style>
  .emerald-docs-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--emerald-spacing-m, 16px);
  }
</style>

Anatomy

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

Sizes

size picks one of three fixed ring geometries: sm is a 14px ring with a 2px stroke, md — the default — is 20px with the same stroke, and lg is 28px with a heavier 3px stroke so the ring does not look wiry at the larger diameter. There is nothing between the steps and no numeric escape hatch; the three sizes are the vocabulary.

Match the spinner to the thing it stands in for. sm sits inline with text and inside dense chrome — a table cell refreshing, a status line. md is the general-purpose choice beside buttons and form rows. lg is for when the spinner is the content: the placeholder for a panel or page that has nothing else to show yet.

The root span is inline-flex with vertical-align: middle, so a spinner dropped into a line of text sits on the line rather than on the baseline — no wrapper needed to keep it from jittering the typography around it.

Color

The ring is drawn in currentcolor, and the root sets its own color to Emerald’s primary green. That one CSS decision is the entire theming API: set color on the spinner — through a class or a style, both of which fall through to the span — and the ring follows.

The middle spinner here uses --emerald-on-surface-variant to read as quiet chrome rather than a brand accent; the last uses --emerald-danger-600 for a wait that belongs to a destructive flow. Stick to Emerald’s tokens rather than raw hex so the spinner keeps agreeing with the theme it sits in — the tokens move when the scheme does, a hardcoded green does not.

Color here is decoration, not information. The label does not change with the color, and a reader who cannot distinguish the ring’s hue loses nothing — which is exactly how it should stay. If the color means something, say that something in label or in nearby text.

Standing in for pending content

The working pattern for most spinners: render the spinner while a request is in flight, and replace it with the result when the answer arrives. v-if/v-else is the whole mechanism — the spinner has no open or active state of its own.

Give the in-context spinner a specific label. The default “Loading” is fine for a page-level spinner where there is only one thing it could mean; next to one widget among many, “Loading activity” tells a screen-reader user which region is busy while the rest of the page is not.

Note what this example does not do: it does not keep a visible “Loading…” text beside the spinner. The label already carries that text for assistive tech, so pairing the spinner with the same words visibly would announce twice. Show visible text or rely on the hidden label — pick per surface, not both.

Props

PropTypeDefaultDescription
size'sm' | 'md' | 'lg''md'Ring geometry: 14px, 20px, or 28px diameter, with a heavier stroke at lg
labelstring'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.

Was this page helpful?

© 2016-1970 Vuetify, LLC
Services
Ctrl+/