# Spinner

> A loading indicator with Apple-style ticks or a breathing ring that can wait before showing and stay long enough not to flicker.

Docs: https://hextaui.com/docs/spinner
Markdown: https://hextaui.com/docs/spinner.md

```tsx title="components/examples/spinner/demo.tsx"
import { Spinner } from "@/components/ui/spinner"

export function SpinnerDemo() {
  return (
    <div className="flex items-center gap-10">
      <Spinner size="xl" />
      <Spinner size="xl" variant="ring" />
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/spinner.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/spinner.tsx"
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import {
  useDelayedLoading,
  type DelayedLoadingOptions,
} from "@/hooks/use-delayed-loading"

const spinnerVariants = cva("shrink-0", {
  variants: {
    size: {
      sm: "size-3.5",
      default: "size-4",
      lg: "size-5",
      xl: "size-8",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

type SpinnerVariant = "ticks" | "ring"

type SpinnerProps = Omit<React.ComponentProps<"svg">, "children"> &
  VariantProps<typeof spinnerVariants> & {
    variant?: SpinnerVariant
    label?: string
    animated?: boolean
    loading?: boolean
    delay?: number
    minDuration?: number
  }

const ticks = Array.from({ length: 8 }, (_, index) => index)

function Spinner({
  className,
  variant = "ticks",
  size = "default",
  label = "Loading",
  animated = true,
  loading,
  delay,
  minDuration,
  ...props
}: SpinnerProps) {
  const delayed = useDelayedLoading(loading ?? false, { delay, minDuration })
  const shown = loading === undefined || delayed

  if (!shown) {
    return null
  }

  const hidden =
    props["aria-hidden"] === true || props["aria-hidden"] === "true"

  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      role={hidden ? undefined : "status"}
      aria-label={hidden ? undefined : label}
      data-slot="spinner"
      data-variant={variant}
      className={cn(
        spinnerVariants({ size }),
        animated &&
          (variant === "ticks"
            ? "motion-safe:animate-spinner-ticks"
            : "motion-safe:animate-spinner-rotate"),
        animated && "motion-reduce:animate-spinner-pulse",
        loading !== undefined &&
          "transition-opacity duration-200 ease-out motion-reduce:transition-none starting:opacity-0",
        className
      )}
      {...props}
    >
      {variant === "ticks" ? (
        ticks.map((index) => (
          <line
            key={index}
            x1="12"
            y1="2.75"
            x2="12"
            y2="6.75"
            stroke="currentColor"
            strokeWidth="2.25"
            strokeLinecap="round"
            opacity={1 - index * 0.11}
            transform={`rotate(${-index * 45} 12 12)`}
          />
        ))
      ) : (
        <>
          <circle
            cx="12"
            cy="12"
            r="9"
            stroke="currentColor"
            strokeWidth="2.5"
            opacity="0.2"
          />
          <circle
            cx="12"
            cy="12"
            r="9"
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeDasharray="16 60"
            transform="rotate(-90 12 12)"
            className={cn(animated && "motion-safe:animate-spinner-dash")}
          />
        </>
      )}
    </svg>
  )
}

export { Spinner, spinnerVariants, useDelayedLoading }
export type { DelayedLoadingOptions, SpinnerProps, SpinnerVariant }
```

Update the import paths to match your project setup.

## Usage

```tsx
import { Spinner } from "@/components/ui/spinner"
```

```tsx
<Spinner />
<Spinner variant="ring" size="lg" />
<Spinner loading={isFetching} />
```

The default is the eight-spoke indicator from Apple platforms. `variant="ring"` spins while its arc grows and shrinks, so it reads as working rather than stuck. Both are drawn in the current text color.

## Examples

### Without flicker

Pass `loading` and the spinner waits `delay` (150ms) before showing, so fast loads never flash it, then stays at least `minDuration` (400ms) once it's visible.

```tsx title="components/examples/spinner/delayed.tsx"
"use client"

import * as React from "react"

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

export function SpinnerDelayed() {
  const [loading, setLoading] = React.useState(false)

  const load = (ms: number) => {
    setLoading(true)
    setTimeout(() => setLoading(false), ms)
  }

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-4">
      <div className="flex size-8 items-center justify-center">
        <Spinner loading={loading} size="lg" />
      </div>
      <div className="flex flex-wrap justify-center gap-2">
        <Button variant="outline" size="sm" onClick={() => load(100)}>
          Fast load (100ms)
        </Button>
        <Button variant="outline" size="sm" onClick={() => load(250)}>
          Load (250ms)
        </Button>
        <Button variant="outline" size="sm" onClick={() => load(2000)}>
          Slow load (2s)
        </Button>
      </div>
    </div>
  )
}
```

### Sizes

`sm`, `default`, `lg` and `xl` for both variants.

```tsx title="components/examples/spinner/sizes.tsx"
import { Spinner } from "@/components/ui/spinner"

const sizes = ["sm", "default", "lg", "xl"] as const

export function SpinnerSizes() {
  return (
    <div className="flex flex-col gap-6">
      <div className="flex items-center gap-6">
        {sizes.map((size) => (
          <Spinner key={size} size={size} />
        ))}
      </div>
      <div className="flex items-center gap-6">
        {sizes.map((size) => (
          <Spinner key={size} size={size} variant="ring" />
        ))}
      </div>
    </div>
  )
}
```

### Inline

Next to text, mark the spinner `aria-hidden` so the words do the talking. On its own, it announces its `label`.

```tsx title="components/examples/spinner/inline.tsx"
import { Badge } from "@/components/ui/badge"
import { Spinner } from "@/components/ui/spinner"

export function SpinnerInline() {
  return (
    <div className="flex flex-col items-start gap-4 text-sm">
      <p className="flex items-center gap-2 text-muted-foreground">
        <Spinner size="sm" aria-hidden />
        Saving changes…
      </p>
      <Badge>
        <Spinner variant="ring" aria-hidden />
        Deploying
      </Badge>
      <p className="flex items-center gap-2 text-primary">
        <Spinner size="sm" label="Syncing" />
        Spinners use the text color around them.
      </p>
    </div>
  )
}
```

### In buttons

Button and Command use this spinner for their loading states, sized to the button's icons.

```tsx title="components/examples/spinner/button.tsx"
"use client"

import { IconRefresh } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

function save() {
  return new Promise((resolve) => setTimeout(resolve, 1600))
}

export function SpinnerButton() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button feedback loadingLabel="Saving…" onClick={save}>
        Save changes
      </Button>
      <Button
        variant="outline"
        size="icon"
        aria-label="Refresh"
        feedback
        onClick={save}
      >
        <IconRefresh />
      </Button>
    </div>
  )
}
```

## Accessibility

- On its own, the spinner is a `status` named by `label` (“Loading”).
- With `aria-hidden`, it drops its role, for use beside visible text or inside a busy button.
- With reduced motion, it gently pulses instead of spinning, so it still shows that something is happening.

## API reference

### Spinner

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"ticks" \| "ring"` | `"ticks"` |  |
| `size` | `"sm" \| "default" \| "lg" \| "xl" \| null` | `"default"` | null leaves sizing to the parent. |
| `label` | `string` | `"Loading"` |  |
| `loading` | `boolean` | – | Turns on delayed showing. Leave it out to always show. |
| `delay` | `number` | `150` |  |
| `minDuration` | `number` | `400` |  |
| `animated` | `boolean` | `true` | Pause the animation without hiding it. |

| Attribute | Description |
| --- | --- |
| `data-slot="spinner"` | The SVG, with data-variant. |

### useDelayedLoading

```tsx
const visible = useDelayedLoading(isFetching, { delay: 150, minDuration: 400 })
```

The same timing as a hook, for skeletons, overlays or anything else that shouldn't flash. See the [useDelayedLoading guide](https://hextaui.com/docs/use-delayed-loading).

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
