Spinner

An indicator that shows a loading state.

Processing payment...$100.00
import * as stylex from "@stylexjs/stylex"
import { Spinner } from "@/components/ui/spinner"
import { HStack } from "@/components/ui/stack"
import { color, leading, radius, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  row: {
    width: "100%",
    maxWidth: "20rem",
    borderRadius: radius.xl,
    backgroundColor: color.muted,
    fontSize: text.sm,
    lineHeight: leading.sm,
  },
  amount: { marginInlineStart: "auto", fontVariantNumeric: "tabular-nums" },
})

export default function SpinnerDemo() {
  return (
    <HStack align="center" gap={3} padding={4} xstyle={styles.row}>
      <Spinner />
      <span>Processing payment...</span>
      <span {...stylex.props(styles.amount)}>$100.00</span>
    </HStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation.

Copy and paste the following code into your project.

components/ui/spinner.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { Loader2 } from "lucide-react"

import { radius, space } from "@/theme/tokens.stylex"

const spin = stylex.keyframes({ to: { transform: "rotate(360deg)" } })

const blink = stylex.keyframes({
  "0%": { opacity: 0.25, transform: "scale(0.8)" },
  "40%": { opacity: 1, transform: "scale(1)" },
  "80%": { opacity: 0.25, transform: "scale(0.8)" },
  "100%": { opacity: 0.25, transform: "scale(0.8)" },
})

const styles = stylex.create({
  base: {
    width: `var(--carbon-icon-size, ${space.s4})`,
    height: `var(--carbon-icon-size, ${space.s4})`,
    flexShrink: 0,
    animationName: spin,
    animationDuration: "1s",
    animationTimingFunction: "linear",
    animationIterationCount: "infinite",
  },
  ring: {
    display: "inline-block",
    borderRadius: radius.full,
    backgroundImage: "conic-gradient(from 0deg, transparent 15%, currentColor)",
    maskImage: "radial-gradient(farthest-side, transparent 72%, black 75%)",
    animationDuration: "0.8s",
  },
  dots: {
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "space-between",
    animationName: "none",
  },
  dot: {
    width: "26%",
    height: "26%",
    borderRadius: radius.full,
    backgroundColor: "currentColor",
    animationName: blink,
    animationDuration: "1.2s",
    animationTimingFunction: "ease-in-out",
    animationIterationCount: "infinite",
  },
  dot2: { animationDelay: "0.15s" },
  dot3: { animationDelay: "0.3s" },
})

const variants = { default: null, ring: styles.ring, dots: styles.dots }

const spinnerStyles = {
  base: styles.base,
  variants,
  dot: styles.dot,
  dotDelays: [null, styles.dot2, styles.dot3],
}

type SpinnerVariant = keyof typeof variants

type SpinnerProps = useRender.ComponentProps<"svg"> & {
  variant?: SpinnerVariant
  xstyle?: StyleXStyles
  className?: string
}

function Spinner({
  variant = "default",
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: SpinnerProps) {
  const sx = stylex.props(styles.base, variants[variant], xstyle)
  const glyph = variant === "default"

  return useRender({
    defaultTagName: glyph ? "svg" : "span",
    render: render ?? (glyph ? <Loader2 /> : undefined),
    ref,
    props: {
      "data-slot": "spinner",
      "data-variant": variant,
      role: "status",
      "aria-label": "Loading",
      ...(variant === "dots" && {
        children: spinnerStyles.dotDelays.map((delay, index) => (
          <span key={index} {...stylex.props(styles.dot, delay)} />
        )),
      }),
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

export { Spinner, spinnerStyles, type SpinnerVariant }

Update the import paths to match your project setup.

Usage

import { Spinner } from "@/components/ui/spinner"
<Spinner />

The spinner keeps turning under prefers-reduced-motion: reduce, because a stopped spinner looks like a stalled one.

Variants

default turns a glyph. ring is a ring with one bright head and a tail that fades behind it. dots lights three dots one after another. All three take the same size and colour, and all keep moving under reduced motion.

import * as stylex from "@stylexjs/stylex"
import { Spinner } from "@/components/ui/spinner"
import { HStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  spinner: { width: space.s6, height: space.s6 },
})

export default function SpinnerVariants() {
  return (
    <HStack align="center" gap={8}>
      <Spinner xstyle={styles.spinner} />
      <Spinner variant="ring" xstyle={styles.spinner} />
      <Spinner variant="dots" xstyle={styles.spinner} />
    </HStack>
  )
}

Size

A spinner takes its size from --carbon-icon-size, the property a control sets for its icons, so a spinner inside a small button or a badge shrinks with the icons beside it. Give a standalone spinner a width and height through xstyle.

import * as stylex from "@stylexjs/stylex"
import { Spinner } from "@/components/ui/spinner"
import { HStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  xs: { width: space.s3, height: space.s3 },
  sm: { width: space.s4, height: space.s4 },
  md: { width: space.s6, height: space.s6 },
  lg: { width: space.s8, height: space.s8 },
})

export default function SpinnerSize() {
  return (
    <HStack align="center" gap={6}>
      <Spinner xstyle={styles.xs} />
      <Spinner xstyle={styles.sm} />
      <Spinner xstyle={styles.md} />
      <Spinner xstyle={styles.lg} />
    </HStack>
  )
}

Custom icon

Spread spinnerStyles.base onto any icon to give it the spinner's size and rotation. Add role="status" and aria-label yourself; only Spinner sets them for you.

import * as stylex from "@stylexjs/stylex"
import { spinnerStyles } from "@/components/ui/spinner"
import { HStack } from "@/components/ui/stack"
import { Loader, LoaderPinwheel, RefreshCw } from "lucide-react"

export default function SpinnerCustom() {
  return (
    <HStack align="center" gap={6}>
      <Loader role="status" aria-label="Loading" {...stylex.props(spinnerStyles.base)} />
      <LoaderPinwheel role="status" aria-label="Loading" {...stylex.props(spinnerStyles.base)} />
      <RefreshCw role="status" aria-label="Loading" {...stylex.props(spinnerStyles.base)} />
    </HStack>
  )
}

Button

Place the spinner before the label with iconPosition="start", or after it with iconPosition="end".

import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { HStack } from "@/components/ui/stack"

export default function SpinnerButton() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Button size="sm" disabled iconPosition="start">
        <Spinner aria-hidden="true" />
        Loading...
      </Button>
      <Button variant="outline" size="sm" disabled iconPosition="start">
        <Spinner aria-hidden="true" />
        Please wait
      </Button>
      <Button variant="secondary" size="sm" disabled iconPosition="end">
        Processing
        <Spinner aria-hidden="true" />
      </Button>
    </HStack>
  )
}

Badge

Place the spinner before the label with iconPosition="start", or after it with iconPosition="end".

SyncingUpdatingProcessing
import { Badge } from "@/components/ui/badge"
import { Spinner } from "@/components/ui/spinner"
import { HStack } from "@/components/ui/stack"

export default function SpinnerBadge() {
  return (
    <HStack align="center" gap={2} wrap="wrap">
      <Badge iconPosition="start">
        <Spinner aria-hidden="true" />
        Syncing
      </Badge>
      <Badge variant="secondary" iconPosition="start">
        <Spinner aria-hidden="true" />
        Updating
      </Badge>
      <Badge variant="outline" iconPosition="end">
        Processing
        <Spinner aria-hidden="true" />
      </Badge>
    </HStack>
  )
}

Input group

Put the spinner in an InputGroupAddon and position it with the addon's align.

Validating...
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText } from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"
import { VStack } from "@/components/ui/stack"
import { Textarea, textareaStyles } from "@/components/ui/textarea"
import { ArrowUp } from "lucide-react"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "24rem" },
  send: { marginInlineStart: "auto" },
})

export default function SpinnerInputGroup() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup disabled>
        <Input placeholder="Send a message" aria-label="Message" />
        <InputGroupAddon align="inline-end">
          <Spinner />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup disabled>
        <Textarea
          placeholder="Send a message"
          aria-label="Message"
          data-slot="input-group-control"
          xstyle={textareaStyles.inGroup}
        />
        <InputGroupAddon align="block-end">
          <Spinner aria-hidden="true" />
          <InputGroupText>Validating...</InputGroupText>
          <InputGroupButton variant="default" size="icon-xs" aria-label="Send" xstyle={styles.send}>
            <ArrowUp />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}

Spinner renders role="status" with aria-label="Loading", so a screen reader announces it on its own. Pass aria-hidden when the text beside it already says the same thing, such as a button labelled "Loading...".

API reference

Pass render to replace a part's element or compose it with another component.

Spinner renders an <svg> for default and a <span> for ring and dots, and accepts every attribute of its element. Anything you pass overrides its own defaults, including role and aria-label.

Spinner

PropTypeDefault
variant"default" | "ring" | "dots""default"
xstyleStyleXStyles—
classNamestring—
rolestring"status"
aria-labelstring"Loading"

spinnerStyles

The style map behind the component. Spread it onto any element.

KeyWhat it is
baseThe inherited icon size and the rotation
variantsThe ring's mask and the dots' row, by name
dotOne dot and its blink
dotDelaysThe second and third dots' offsets, in order

Data attributes

AttributeOnEffect
data-slot="spinner"the spinnerTargets the glyph from a parent
data-variantthe spinnerMirrors the variant prop