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
| Prop | Default | Description |
|---|---|---|
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. |
| Prop | Default | Description |
|---|---|---|
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>