Spinner

An indeterminate loading indicator for buttons, panels and inline content.

Loading

Installation

Terminal
pnpm dlx @dowel-ui/cli add spinner

npm packages installed: class-variance-authority.

Accessibility

Renders aria-hidden by default so it does not announce inside controls that already expose a busy state. Pass `label` to announce it standalone via role="status".

Props

Spinner

PropTypeDefault
label

Announced to assistive technology while the spinner is visible. Omit it when the spinner sits inside a control that already communicates its busy state (a loading Button, for example) — announcing twice is worse than not announcing at all.

string
size"xs" | "sm" | "md" | "lg" | "xl""md"

Plus every attribute of <svg> except children.

Quality

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Source

This is exactly what dowel add spinner writes into your project, with imports rewritten to your own path alias.

ui/spinner.tsx
import { cva, type VariantProps } from "class-variance-authority";
import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils";

const spinnerVariants = cva("animate-spin text-current", {
  variants: {
    size: {
      xs: "size-3",
      sm: "size-3.5",
      md: "size-4",
      lg: "size-5",
      xl: "size-6",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

export interface SpinnerProps
  extends Omit<ComponentPropsWithRef<"svg">, "children">, VariantProps<typeof spinnerVariants> {
  /**
   * Announced to assistive technology while the spinner is visible.
   *
   * Omit it when the spinner sits inside a control that already communicates
   * its busy state (a loading Button, for example) — announcing twice is worse
   * than not announcing at all.
   */
  label?: string;
}

/** Indeterminate loading indicator. */
export function Spinner({ className, size, label, ...props }: SpinnerProps) {
  return (
    <>
      <svg
        viewBox="0 0 24 24"
        fill="none"
        aria-hidden="true"
        // Exempt from the reduced-motion blanket: this is not decoration. A
        // spinner that stops turning reports that the application has hung,
        // which is a worse experience than the motion it was avoiding. It is
        // slowed instead — see --motion-scale-indicator.
        data-motion="indicator"
        className={cn(spinnerVariants({ size }), className)}
        {...props}
      >
        <circle
          cx="12"
          cy="12"
          r="9.5"
          stroke="currentColor"
          strokeWidth="2.5"
          opacity="0.22"
        />
        <path
          d="M12 2.5a9.5 9.5 0 0 1 9.5 9.5"
          stroke="currentColor"
          strokeWidth="2.5"
          strokeLinecap="round"
        />
      </svg>
      {label ? (
        <span role="status" className="sr-only">
          {label}
        </span>
      ) : null}
    </>
  );
}

export { spinnerVariants };