← ui

spinner

ui

work under way: a ring with a turning arc, still under reduced motion

npx shadcn add @ag/spinner
Already installed? Preview an update
npx shadcn add @ag/spinner --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-10
  • New: work under way, a ring with a turning arc in the text's colour (sm, md, lg), still with reduced motion, a status a screen reader hears once by its label; on glass the arc catches the rim's light.
Updated 2026-10-10 · release notes

also called loader, loading

shelf controls

uses material

used by components, button, form, toast, deployment

the demo above, verbatim

spinner.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Spinner } from "@/registry/base-nova/ui/spinner"

// #region spinners
function Spinners() {
  return (
    <div className="flex items-center gap-4 text-sm">
      <Spinner size="sm" />
      <Spinner />
      <Spinner size="lg" label="saving" />
      <span className="flex items-center gap-2 text-current/70">
        <Spinner size="sm" label="reading frames" /> reading frames
      </span>
    </div>
  )
}
// #endregion

export default function SpinnerDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="three sizes, plain and glass"
        with="spinners"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <Spinners />
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6 text-white">
              <Spinners />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="sizes"
        glance
        title="three sizes, one with words"
        with="spinners"
      >
        <Spinners />
      </Sample>
    </div>
  )
}

the source, every file an install writes

spinner.tsx
"use client"

import type * as React from "react"
import { cn } from "@/lib/utils"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"

// Work under way: a ring with a turning arc, in the text's colour, so it reads
// wherever it sits (a button, a row, a field). It turns only while it is shown, which
// is only while the work runs: nothing here spins on its own. With reduced motion
// the arc holds still. A screen reader hears its `label` once (role status).
// Glass: the arc catches the glass's rim light, a soft glow round it.

export type SpinnerSize = "sm" | "md" | "lg"

const SIDE: Record<SpinnerSize, string> = {
  sm: "size-3.5",
  md: "size-4",
  lg: "size-5",
}

/** The ring's radius in a 16-unit box and its arc's length: a quarter turn. */
const R = 6.5
const ARC = (2 * Math.PI * R) / 4

export type SpinnerProps = Omit<React.ComponentProps<"span">, "children"> & {
  size?: SpinnerSize
  /** What is happening, for a screen reader ("saving"). */
  label?: string
  material?: Material
}

export function Spinner({
  size = "md",
  label = "loading",
  material,
  className,
  ...props
}: SpinnerProps) {
  const m = useMaterial(material)
  return (
    <span
      role="status"
      aria-label={label}
      data-slot="spinner"
      data-material={m}
      className={cn(
        "inline-block shrink-0",
        SIDE[size],
        m === "glass" &&
          "[filter:drop-shadow(0_0_2px_color-mix(in_oklab,var(--glass-rim-ink,white)_60%,transparent))]",
        className,
      )}
      {...props}
    >
      <svg
        viewBox="0 0 16 16"
        aria-hidden
        className="size-full animate-spin [animation-duration:800ms] motion-reduce:animate-none"
      >
        <circle
          cx={8}
          cy={8}
          r={R}
          fill="none"
          stroke="currentColor"
          strokeWidth={1.75}
          opacity={m === "glass" ? 0.28 : 0.18}
        />
        <circle
          cx={8}
          cy={8}
          r={R}
          fill="none"
          stroke="currentColor"
          strokeWidth={1.75}
          strokeLinecap="round"
          strokeDasharray={`${ARC} ${2 * Math.PI * R}`}
        />
      </svg>
    </span>
  )
}