Skip to content
UI Kit
Accent color

Loading spinner

Six drawn loaders on one grid, in five sizes and three tones, inside buttons and fields, with a live status text and a ring that fills with the percentage.

5 variantsOne of 5 components in Progress and states

Work in progress, right where it happens

A spinner marks work in progress at the exact spot where it happens, in a button, a field or a panel. Unlike a progress bar or a skeleton it says something is busy right here, and every loader is announced as a live status for screen readers.

Six loaders shows ring, dots, bars, pulse, orbit and spokes, each with the job it suits, and with reduced motion every one stands still as a clear picture. Sizes and tones covers five sizes from 14 to 48 px with a stroke that follows the size, in the text color, the accent or quiet. In buttons and fields shows a button that says what it does while it works and keeps its width, a search field that is looking and a small sync badge. Status panel sets the loader above its message for longer work, the current step is announced politely and a list shows what is done. Progress ring fills with the percentage once the total is known.

Show a spinner only after a short delay, quick actions should not flicker. Say in words what is happening, saving or searching, a bare circle explains nothing. Switch to the progress ring as soon as you know how much is left.

Variants

Six loadersNew in 1.2

Ring, dots, bars, pulse, orbit and spokes, each with the job it suits. With reduced motion every one stands still as a clear picture.

Sizes and tonesNew in 1.2

Five sizes from 14 to 48 px, the stroke follows the size. In the text color, the accent or quiet, and inline in a sentence.

In buttons and fieldsNew in 1.2

A button that says what it does while it works and keeps its width, a search field that is looking, a small sync badge and an icon button. Press save.

Status panelNew in 1.2

For work that takes a while. The loader stands above its message, the current step is announced politely, and the list beside it shows what is done.

Progress ringNew in 1.2

When the total is known, the ring fills and writes the percentage in its middle. Until then it runs like a spinner. A progressbar with value text for screen readers.

Details

What Spinner brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/spinner.tsx
Brings along
utilskit
npm packages
None
Languages
English and German, 2 texts of its own
Studio
Adjust in code

Props

The SpinnerProps interface from components/ui/spinner.tsx, with the comments from the code.

  • typeoptional
    SpinnerType

    ringdotsbarspulseorbitspokes

  • sizeoptional
    SpinnerSize

    xssmmdlgxl

  • toneoptional
    SpinnerTone

    currentaccentmuted

  • labeloptional
    string

    What is happening, e.g. "Saving draft". Read out by screen readers, shown when showLabel is set.

  • showLabeloptional
    boolean

    Write the label next to the loader

  • stackedoptional
    boolean

    Label below the loader instead of beside it, for empty panels

  • announceoptional
    boolean

    Announce the loader as a live status. Turn it off inside a button or a region that already says it is busy (aria-busy), so nothing is read twice.

Built-in texts

Spinner writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 2 texts
loadingEnglish LoadingGerman Wird geladen
progressEnglish ProgressGerman Fortschritt

Accessibility

Read from the source of the component.

  • Sets aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles announce, progressbar and status.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 226 lines
components/ui/spinner.tsx
'use client';

import { cn, formatPercent, round, useLocale, useTexts } from '@/lib/kit';

/*
 * Spinners: small signs that something is working. Six drawn loaders in one
 * family, every one built on the same 24 px grid so they swap without
 * shifting the layout. With reduced motion each one stands still as a
 * readable picture (an open ring, three dots in rising strength, bars of
 * different height) and the label carries the message.
 */

export type SpinnerType = 'ring' | 'dots' | 'bars' | 'pulse' | 'orbit' | 'spokes';
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type SpinnerTone = 'current' | 'accent' | 'muted';

const PX: Record<SpinnerSize, number> = { xs: 14, sm: 18, md: 24, lg: 36, xl: 48 };
const TEXT: Record<SpinnerSize, string> = { xs: 'text-[12px]', sm: 'text-[13px]', md: 'text-sm', lg: 'text-[15px]', xl: 'text-base' };
const TONE: Record<SpinnerTone, string> = {
  current: 'text-current',
  accent: 'text-primary',
  muted: 'text-muted-foreground',
};

const TEXTS = {
  en: { loading: 'Loading', progress: 'Progress' },
  de: { loading: 'Wird geladen', progress: 'Fortschritt' },
};
type SpinnerTexts = (typeof TEXTS)['en'];

/* All motion lives in one style sheet, keyed by the kitfx-sp prefix. Reduced motion stops every loop. */
const STYLES =
  '.kitfx-sp-spin{animation:kitfx-sp-spin .85s linear infinite}' +
  '.kitfx-sp-steps{animation:kitfx-sp-spin .9s steps(8) infinite}' +
  '.kitfx-sp-orbit{animation:kitfx-sp-spin 1.3s cubic-bezier(.45,.05,.55,.95) infinite}' +
  '@keyframes kitfx-sp-spin{to{transform:rotate(360deg)}}' +
  '.kitfx-sp-dot{transform-box:fill-box;transform-origin:center;animation:kitfx-sp-dot 1.05s ease-in-out infinite;animation-delay:calc(var(--i) * 150ms)}' +
  '@keyframes kitfx-sp-dot{0%,80%,100%{opacity:.3;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}' +
  '.kitfx-sp-bar{transform-box:fill-box;transform-origin:center;animation:kitfx-sp-bar 1s ease-in-out infinite;animation-delay:calc(var(--i) * 120ms)}' +
  '@keyframes kitfx-sp-bar{0%,70%,100%{transform:scaleY(.4);opacity:.45}35%{transform:scaleY(1);opacity:1}}' +
  '.kitfx-sp-wave{transform-box:fill-box;transform-origin:center;animation:kitfx-sp-wave 1.6s cubic-bezier(.2,.7,.2,1) infinite;animation-delay:calc(var(--i) * 800ms)}' +
  '@keyframes kitfx-sp-wave{0%{transform:scale(.45);opacity:.7}100%{transform:scale(1);opacity:0}}' +
  '.kitfx-sp-core{transform-box:fill-box;transform-origin:center;animation:kitfx-sp-core 1.6s ease-in-out infinite}' +
  '@keyframes kitfx-sp-core{50%{transform:scale(.82)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-sp-spin,.kitfx-sp-steps,.kitfx-sp-orbit,.kitfx-sp-dot,.kitfx-sp-bar,.kitfx-sp-core{animation:none}.kitfx-sp-wave{animation:none;opacity:.35;transform:scale(.8)}.kitfx-sp-wave[style*="--i:1"]{opacity:0}}';

function Styles() {
  return (
    <style href="kitfx-sp" precedence="default">
      {STYLES}
    </style>
  );
}

/** The drawing of one loader, 24 by 24, in currentColor */
function Drawing({ type, px }: { type: SpinnerType; px: number }) {
  const common = { width: px, height: px, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true as const, className: 'block shrink-0 overflow-visible' };
  // Thin strokes look heavy at small sizes and faint at large ones, so the stroke follows the size
  const sw = px <= 18 ? 2.6 : px >= 40 ? 1.8 : 2.2;
  switch (type) {
    case 'ring':
      return (
        <svg {...common} className={cn(common.className, 'kitfx-sp-spin')}>
          <circle cx="12" cy="12" r="9.5" stroke="currentColor" strokeOpacity="0.18" strokeWidth={sw} />
          <path d="M12 2.5a9.5 9.5 0 0 1 9.5 9.5" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" />
        </svg>
      );
    case 'dots':
      return (
        <svg {...common}>
          {[5, 12, 19].map((cx, i) => (
            <circle key={cx} cx={cx} cy="12" r="2.4" fill="currentColor" className="kitfx-sp-dot" style={{ '--i': i, opacity: [0.35, 0.65, 1][i] } as React.CSSProperties} />
          ))}
        </svg>
      );
    case 'bars':
      return (
        <svg {...common}>
          {[3.5, 8.5, 13.5, 18.5].map((x, i) => {
            const h = [10, 16, 13, 7][i];
            return <rect key={x} x={x - 0.5} y={12 - h / 2} width="3" height={h} rx="1.5" fill="currentColor" className="kitfx-sp-bar" style={{ '--i': i, transform: 'none' } as React.CSSProperties} />;
          })}
        </svg>
      );
    case 'pulse':
      return (
        <svg {...common}>
          {[0, 1].map((i) => (
            <circle key={i} cx="12" cy="12" r="10.5" stroke="currentColor" strokeWidth={px <= 18 ? 2 : 1.6} className="kitfx-sp-wave" style={{ '--i': i } as React.CSSProperties} />
          ))}
          <circle cx="12" cy="12" r="4.2" fill="currentColor" className="kitfx-sp-core" />
        </svg>
      );
    case 'orbit':
      return (
        <svg {...common} className={cn(common.className, 'kitfx-sp-orbit')}>
          <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.18" strokeWidth={sw * 0.8} />
          <circle cx="12" cy="3" r="2.6" fill="currentColor" />
          <circle cx={round(12 + 9 * Math.sin(-0.62))} cy={round(12 - 9 * Math.cos(-0.62))} r="1.7" fill="currentColor" opacity="0.5" />
          <circle cx={round(12 + 9 * Math.sin(-1.12))} cy={round(12 - 9 * Math.cos(-1.12))} r="1.1" fill="currentColor" opacity="0.25" />
        </svg>
      );
    case 'spokes':
      return (
        <svg {...common} className={cn(common.className, 'kitfx-sp-steps')}>
          {Array.from({ length: 8 }, (_, i) => {
            const a = (i * Math.PI) / 4;
            return (
              <line
                key={i}
                x1={round(12 + 5 * Math.sin(a))}
                y1={round(12 - 5 * Math.cos(a))}
                x2={round(12 + 9.5 * Math.sin(a))}
                y2={round(12 - 9.5 * Math.cos(a))}
                stroke="currentColor"
                strokeWidth={sw}
                strokeLinecap="round"
                opacity={round(1 - ((8 - i) % 8) * 0.105, 3)}
              />
            );
          })}
        </svg>
      );
  }
}

export interface SpinnerProps {
  type?: SpinnerType;
  size?: SpinnerSize;
  tone?: SpinnerTone;
  /** What is happening, e.g. "Saving draft". Read out by screen readers, shown when showLabel is set. */
  label?: string;
  /** Write the label next to the loader */
  showLabel?: boolean;
  /** Label below the loader instead of beside it, for empty panels */
  stacked?: boolean;
  /**
   * Announce the loader as a live status. Turn it off inside a button or a
   * region that already says it is busy (aria-busy), so nothing is read twice.
   */
  announce?: boolean;
  labels?: Partial<SpinnerTexts>;
  className?: string;
}

/**
 * A loader that shows that something is working, with a label for screen readers (role="status").
 *   <Spinner type="dots" label="Saving draft" showLabel />
 */
export function Spinner({ type = 'ring', size = 'md', tone = 'current', label, showLabel, stacked, announce = true, labels, className }: SpinnerProps) {
  const t = useTexts(TEXTS, labels);
  const text = label ?? t.loading;
  return (
    <span
      role={announce ? 'status' : undefined}
      aria-live={announce ? 'polite' : undefined}
      className={cn('inline-flex items-center', stacked ? 'flex-col gap-3 text-center' : 'gap-2.5', TONE[tone], className)}
    >
      <Styles />
      <Drawing type={type} px={PX[size]} />
      <span className={cn(showLabel ? cn('font-medium text-muted-foreground', TEXT[size]) : 'sr-only')}>{text}</span>
    </span>
  );
}

export interface ProgressSpinnerProps {
  /** 0 to 100 */
  value: number;
  /** Diameter in px, 32 to 120 */
  size?: number;
  tone?: Exclude<SpinnerTone, 'current'>;
  /** Write the percentage in the middle (from 40 px on) */
  showValue?: boolean;
  /** Accessible name, e.g. "Upload of plan.pdf" */
  label?: string;
  /** Turn into a running ring while the total is still unknown */
  indeterminate?: boolean;
  labels?: Partial<SpinnerTexts>;
  className?: string;
}

/**
 * A ring that fills with the progress and writes the percentage in its middle.
 * Before the total is known it runs like a spinner (indeterminate), then it fills.
 */
export function ProgressSpinner({ value, size = 56, tone = 'accent', showValue = true, label, indeterminate, labels, className }: ProgressSpinnerProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const v = Math.min(100, Math.max(0, value));
  const stroke = size >= 72 ? 5 : size >= 48 ? 4 : 3;
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  return (
    <span
      role="progressbar"
      aria-label={label ?? t.progress}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={indeterminate ? undefined : round(v, 0)}
      aria-valuetext={indeterminate ? t.loading : formatPercent(round(v, 0), locale)}
      className={cn('relative inline-grid shrink-0 place-items-center', className)}
      style={{ width: size, height: size }}
    >
      <Styles />
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="none" aria-hidden className={cn('absolute inset-0 -rotate-90', indeterminate && 'kitfx-sp-spin')}>
        <circle cx={size / 2} cy={size / 2} r={round(r)} stroke="var(--track)" strokeWidth={stroke} />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={round(r)}
          stroke="currentColor"
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={round(c)}
          strokeDashoffset={round(indeterminate ? c * 0.72 : c * (1 - v / 100))}
          className={cn(TONE[tone], 'transition-[stroke-dashoffset] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
        />
      </svg>
      {showValue && size >= 40 && !indeterminate && (
        <span className={cn('relative font-semibold tabular-nums tracking-[-0.02em] text-foreground', size >= 88 ? 'text-xl' : size >= 64 ? 'text-[15px]' : size >= 52 ? 'text-[12px]' : 'text-[10px] tracking-[-0.04em]')}>
          {formatPercent(round(v, 0), locale)}
        </span>
      )}
    </span>
  );
}

Frequent questions

How do I install Spinner?

With aiw add @ai-website/spinner. The command puts components/ui/spinner.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Spinner have?

5 variants, Six loaders, Sizes and tones, In buttons and fields, Status panel and Progress ring.

Can I translate the texts of Spinner?

Yes. Spinner carries 2 texts of its own in English and German, such as “Loading” and “Progress”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Spinner?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Spinner with aiw_components and install it with aiw_add @ai-website/spinner.

Often searched

Jump to

Copied to the clipboard