Skip to content
  1. Feedback
  2. Spinner

Spinner

A busy indicator: a dotted track with an arc spinning on top of it.

import { Spinner } from '@/components/spinner'

export default function Example() {
  return <Spinner />
}
<script setup lang="ts">
import Spinner from '@/components/spinner.vue'
</script>

<template>
  <Spinner />
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import clsx from 'clsx'
import type React from 'react'
import { useEffect, useState } from 'react'

export function Spinner({
  label,
  className,
  ...props
}: {
  label?: string
} & React.ComponentPropsWithoutRef<'span'>) {
  let [announcement, setAnnouncement] = useState('')

  useEffect(() => setAnnouncement(label ?? ''), [label])

  return (
    <span
      role={label ? 'status' : undefined}
      aria-hidden={label ? undefined : 'true'}
      {...props}
      className={clsx(className, 'relative inline-block size-4 shrink-0 align-middle')}
    >
      {label && <span className="sr-only">{announcement}</span>}
      <svg className="size-full" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" aria-hidden="true">
        <path
          stroke="currentColor"
          strokeWidth="2"
          strokeDasharray="3 3"
          strokeOpacity="0.24"
          d="M8 14A6 6 0 108 2a6 6 0 000 12z"
        />
      </svg>
      <svg
        className="absolute inset-0 size-full animate-spin motion-reduce:animate-[spin_1.5s_linear_infinite]"
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 16 16"
        aria-hidden="true"
      >
        <path stroke="currentColor" strokeWidth="2" d="M11.346 3.02a6.002 6.002 0 012.54 6.155" />
      </svg>
    </span>
  )
}
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'

const props = defineProps<{ label?: string }>()

const announcement = ref('')

onMounted(() => {
  announcement.value = props.label ?? ''
  watch(
    () => props.label,
    (label) => (announcement.value = label ?? '')
  )
})
</script>

<template>
  <span class="relative inline-block size-4 shrink-0 align-middle" :role="label ? 'status' : undefined" :aria-hidden="label ? undefined : 'true'">
    <span v-if="label" class="sr-only">{{ announcement }}</span>
    <svg class="size-full" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" aria-hidden="true">
      <path
        stroke="currentColor"
        stroke-width="2"
        stroke-dasharray="3 3"
        stroke-opacity="0.24"
        d="M8 14A6 6 0 108 2a6 6 0 000 12z"
      />
    </svg>
    <svg
      class="absolute inset-0 size-full animate-spin motion-reduce:animate-[spin_1.5s_linear_infinite]"
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 16 16"
      aria-hidden="true"
    >
      <path stroke="currentColor" stroke-width="2" d="M11.346 3.02a6.002 6.002 0 012.54 6.155" />
    </svg>
  </span>
</template>

Component API

PropDefaultDescription
Spinnerrenders a <span> element
label-Announces the wait to a screen reader. Leave it out when the surrounding control already says what is going on, and the spinner stays decorative.
PropDefaultDescription
Spinnerrenders a <span> element
label-Announces the wait to a screen reader. Leave it out when the surrounding control already says what is going on, and the spinner stays decorative.

Examples

Basic example

The spinner takes no props. It draws itself in currentColor, so it picks up the color of the text around it:

import { Spinner } from '@/components/spinner'

export default function Example() {
  return <Spinner />
}
<script setup lang="ts">
import Spinner from '@/components/spinner.vue'
</script>

<template>
  <Spinner />
</template>

Sizes

Resize it with the size-* utilities:

import { Spinner } from '@/components/spinner'

export default function Example() {
  return (
    <>
      <Spinner />
      <Spinner className="size-5" />
      <Spinner className="size-8" />
    </>
  )
}
<script setup lang="ts">
import Spinner from '@/components/spinner.vue'
</script>

<template>
  <Spinner />
  <Spinner class="size-5" />
  <Spinner class="size-8" />
</template>

Colors

Both the track and the arc are drawn in currentColor, so a single text-* class colors the whole thing:

import { Spinner } from '@/components/spinner'

export default function Example() {
  return (
    <>
      <Spinner className="text-blue-500" />
      <Spinner className="text-emerald-500" />
      <Spinner className="text-zinc-400" />
    </>
  )
}
<script setup lang="ts">
import Spinner from '@/components/spinner.vue'
</script>

<template>
  <Spinner class="text-blue-500" />
  <Spinner class="text-emerald-500" />
  <Spinner class="text-zinc-400" />
</template>

On its own

A spinner shown by itself has to say what it is waiting for. Give it a label and it becomes a live region; without one it is marked decorative and stays silent:

import { Spinner } from '@/components/spinner'

export default function Example() {
  return <Spinner label="Loading transactions" className="size-5" />
}
<script setup lang="ts">
import Spinner from '@/components/spinner.vue'
</script>

<template>
  <Spinner label="Loading transactions" class="size-5" />
</template>

Inside a button

Drop it into a button to mark a pending action, and disable the button while it runs. Give it data-slot="icon" so the button sizes, spaces and colors it like any of its icons:

import { Button } from '@/components/button'
import { Spinner } from '@/components/spinner'

export default function Example() {
  return (
    <Button disabled>
      <Spinner data-slot="icon" />
      Saving…
    </Button>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Spinner from '@/components/spinner.vue'
</script>

<template>
  <Button disabled>
    <Spinner data-slot="icon" />
    Saving…
  </Button>
</template>