HextaUI

Spinner

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

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>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/spinner.json

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

import { Spinner } from "@/components/ui/spinner"
<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.

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.

"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.

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.

Saving changes…

Deploying

Spinners use the text color around them.

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.

"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>
  )
}
  • 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.
PropTypeDefault
variant
"ticks" | "ring""ticks"
sizenull leaves sizing to the parent.
"sm" | "default" | "lg" | "xl" | null"default"
label
string"Loading"
loadingTurns on delayed showing. Leave it out to always show.
boolean–
delay
number150
minDuration
number400
animatedPause the animation without hiding it.
booleantrue
AttributeDescription
data-slot="spinner"The SVG, with data-variant.
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.