Spinner

Signals an indeterminate wait where the resulting content has no known shape.

Feedback Spinner.jsx Spinner.d.ts Spinner.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Spinner is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Spinner } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Signals an indeterminate wait where the resulting content has no known shape.

Rules

  • Use a spinner for actions: saving, submitting, connecting. Use Skeleton for content that is about to fill a known layout.
  • Below roughly 300ms, show nothing. A flash of spinner reads as a glitch.
  • The label is announced via role="status". Make it specific: "Saving changes" beats "Loading".
  • Never centre a spinner in an empty page for more than a few seconds without a cancel path or a progress estimate.

Tradeoffs

Spinners give no sense of duration, so long waits feel longer under one. If you know the progress, use Progress instead.

Props

The typed contract, from Spinner.d.ts.

import * as React from "react";

/** Indeterminate loading indicator. */
export interface SpinnerProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** @default "md" */
  size?: "sm" | "md" | "lg";
  /** Announced to screen readers. @default "Loading" */
  label?: string;
  /** Render inline with text rather than as a block. @default false */
  inline?: boolean;
}

export declare function Spinner(props: SpinnerProps): React.JSX.Element;

Tokens it reads

Every custom property Spinner.jsx resolves, read from the source: 4 semantic, 1 undeclared. Component tier first, because that is the one to reach for.

  • semantic Shared roles. Override one and everything using that role moves with it.
  • none Referenced by the source but declared by no tier, so it resolves to nothing.
TokenTierDeclared as
--dt-border-defaultsemanticvar(--dt-color-neutral-200)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-surface-actionsemanticvar(--dt-color-neutral-900)
--dt-motion-duration-slownonenot declared

Source

React only, no dependencies, every value a token.

import React from "react";

const SIZES = { sm: 14, md: 18, lg: 24 };

export function Spinner({ size = "md", label = "Loading", inline = false, style, ...rest }) {
  const px = SIZES[size] || SIZES.md;
  return (
    <span role="status" aria-live="polite" style={{ display: inline ? "inline-flex" : "flex", alignItems: "center", gap: "var(--dt-space-inline-xs)", ...style }} {...rest}>
      <span aria-hidden="true" style={{
        width: px, height: px, flex: "none", borderRadius: "var(--dt-radius-pill)",
        border: "2px solid var(--dt-border-default)", borderTopColor: "var(--dt-surface-action)",
        animation: "dt-spin var(--dt-motion-duration-slow, 700ms) linear infinite",
      }} />
      <span style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap", border: 0 }}>{label}</span>
    </span>
  );
}

In context

Spinner also appears on the feedback reference cards, alongside the rest of its family.