Skip to content

Spinner

Preview

Loading invoices
<div class="flex flex-wrap items-center gap-6">
  <div class="flex items-center gap-2 text-sm text-muted-foreground">
    <svg data-slot="spinner" role="status" aria-label="Loading" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class=${spinnerClass()}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
    Loading invoices
  </div>
  <button class=${buttonClass()} type="button" disabled aria-busy="true">
    <svg data-slot="spinner" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class=${spinnerClass()}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
    Saving
  </button>
</div>

Installation

webjs ui add spinner

API Reference

Parts

Name Description
spinner({ class, label, decorative }) The inline SVG as an html template, role="status" and aria-label="Loading" by default.
spinnerClass() size-4 animate-spin, for your own <svg>.

Props

Prop Type Default Description
class string Merged over spinnerClass(), so a size utility replaces size-4.
label string "Loading" The accessible name.
decorative boolean false aria-hidden and no role, for beside visible text such as a busy button label.

Source: components/ui/spinner.ts

/**
 * Spinner: an animated busy indicator. Tier-1 helpers, `spinnerClass()` for
 * the classes and `spinner()` for the whole inline SVG (the Lucide
 * loader-circle path, so no icon package is needed). It inherits the text
 * colour through `currentColor`, so it matches whatever it sits in.
 *
 * shadcn parity:
 *   Spinner  → spinner({ class, label })   (the inline SVG)
 *            → spinnerClass()              (the classes, for your own SVG)
 *
 * Size it with a size utility: `spinner({ class: 'size-6' })` replaces the
 * default `size-4`.
 *
 * Design tokens used: none (it draws in `currentColor`).
 *
 * A11y (required for accessible output):
 *   `spinner()` renders `role="status"` + `aria-label="Loading"`, so a
 *   standalone spinner is announced. Pass `label` for a more specific name
 *   (`spinner({ label: 'Loading invoices' })`).
 *   Inside a BUTTON while a save is running, mark the button itself busy:
 *   `disabled` (so a second click cannot submit twice) plus `aria-busy="true"`,
 *   and keep a visible text label ("Saving"). The button's text already says
 *   what is happening, so pass `decorative: true`, which renders the SVG with
 *   `aria-hidden="true"` and no role, rather than nesting a second live status
 *   inside the button's name. Re-enable the button and drop `aria-busy` when
 *   the save settles, and report the outcome somewhere that is announced (a
 *   toast, or the form's error text).
 *   A spinner that replaces a whole region is better as a skeleton when the
 *   final layout is known, with `aria-busy="true"` on the region.
 *
 * @example
 * ```html
 * <div class="flex items-center gap-2 text-sm text-muted-foreground">
 *   ${spinner()} Loading invoices
 * </div>
 *
 * <button class=${buttonClass()} type="submit" disabled aria-busy="true">
 *   ${spinner({ decorative: true })} Saving
 * </button>
 * ```
 */
import { html } from '@webjsdev/core';
import { cn } from '../lib/utils.ts';

/** Spinner classes: `size-4` plus `animate-spin`. Apply to your own `<svg>`. */
export const spinnerClass = (): string => 'size-4 animate-spin';

/**
 * The spinner as an inline SVG template. `class` is merged over
 * `spinnerClass()` (a later size utility wins), `label` names it (default
 * "Loading"), and `decorative` hides it from assistive tech for use beside
 * visible text such as a busy button's label.
 */
export function spinner(opts: { class?: string; label?: string; decorative?: boolean } = {}) {
  const cls = cn(spinnerClass(), opts.class);
  // Branched rather than a nullish `role=${...}` hole: the server renderer
  // stringifies a null attribute value, so a single template would serve
  // `role=""` on the decorative form.
  if (opts.decorative) {
    return html`<svg data-slot="spinner" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class=${cls}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>`;
  }
  return html`<svg data-slot="spinner" role="status" aria-label=${opts.label ?? 'Loading'} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class=${cls}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>`;
}