Spinner
Indicate work in progress with a decorative ring or your own loading mark. Spinner inherits the current text color; describe the work in a separate visible label or status.
<script setup>
import Spinner from '@/components/ui/spinner/Spinner.vue'
</script>
<template>
<span role="status" aria-live="polite" aria-atomic="true">
<template v-if="loading">
<Spinner class="size-4" />
<span>Loading deployments…</span>
</template>
</span>
</template>import Spinner from '@/components/ui/spinner/Spinner.jsx'
export default function DeploymentStatus({ loading }) {
return (
<span role="status" aria-live="polite" aria-atomic="true">
{loading ? (
<>
<Spinner className="size-4" />
<span>Loading deployments…</span>
</>
) : null}
</span>
)
}<script>
import Spinner from '$lib/components/ui/spinner/Spinner.svelte'
let { loading = false } = $props()
</script>
<span role="status" aria-live="polite" aria-atomic="true">
{#if loading}
<Spinner class="size-4" />
<span>Loading deployments…</span>
{/if}
</span>View source Spinner.vue
<script setup>
import { computed, useAttrs, useSlots } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
const slots = useSlots()
const hasCustomMark = computed(() => Boolean(slots.default))
const forwardedAttrs = computed(() => {
const {
class: _class,
'aria-hidden': _ariaHidden,
role: _role,
focusable: _focusable,
tabindex: _tabindex,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
</script>
<template>
<span
v-bind="forwardedAttrs"
data-slot="spinner"
aria-hidden="true"
:class="
twMerge(
[
'inline-flex size-4 shrink-0 items-center justify-center motion-reduce:animate-none! motion-reduce:**:animate-none! *:size-full',
hasCustomMark ? '' : 'animate-spin'
],
attrs.class
)
"
>
<slot>
<svg
data-slot="spinner-mark"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
stroke-width="2"
opacity="0.2"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</slot>
</span>
</template>Installation
One command detects Vue, React, or Svelte and installs the framework-native source:
Run from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add spinnerUsage
Keep the status surface mounted before its contents change. Spinner is decorative, so the useful status is announced once rather than as an unnamed image and again as text.
Copy the example above for your framework.
Loading semantics
Spinner's wrapper renders aria-hidden="true"; its fallback SVG is also non-focusable. The surrounding application describes the work:
- put
aria-busy="true"on the button or region whose content is changing; - keep specific visible text such as “Saving invoice…” or “Deploying service…”;
- use a persistent
role="status"surface when a dynamic change should be announced; - keep existing content readable when refreshing it is safer than replacing the whole region;
- disable a submitting button when a second activation would duplicate the request.
Do not put role="status" or an accessible label on Spinner itself. One page can contain several visual marks while exposing one useful status message.
Product-owned marks
The neutral ring works without setup. When an application's identity belongs in the loading moment, put its own component inside Spinner:
<script setup>
import Spinner from '@/components/ui/spinner/Spinner.vue'
import SlippyLoader from '@/components/SlippyLoader.vue'
</script>
<template>
<Spinner class="size-4">
<SlippyLoader />
</Spinner>
</template>
The wrapper owns one size class and makes its direct child fill that space. A supplied mark keeps its own animation; Spinner does not rotate it a second time. Use this slot for your own animated product mark; its source and styling stay in your application.
Buttons and regions
The button owns its native state:
<Button type="submit" :disabled="form.processing" :aria-busy="form.processing">
<Spinner v-if="form.processing" class="size-4">
<SlippyLoader />
</Spinner>
{{ form.processing ? 'Saving changes…' : 'Save changes' }}
</Button>For a refreshed surface, put busy state on the surface and connect it to useful status text:
<section :aria-busy="refreshing" aria-describedby="deployment-status">
<p id="deployment-status" role="status">
<template v-if="refreshing">
<Spinner /> Refreshing deployments…
</template>
</p>
<!-- Existing deployment rows -->
</section>Spinner represents indeterminate work. When progress can be measured and that measurement helps someone decide whether to wait, use visible progress text or the native <progress> element.
Styling with Tailwind
The wrapper and its mark use currentColor. Size and color are ordinary caller classes:
<template>
<!-- Compact secondary status -->
<Spinner class="size-3 text-gray-500" />
<!-- Prominent application status -->
<Spinner class="size-8 text-sky-600" />
<!-- Product-owned mark, sized by the same wrapper -->
<Spinner class="size-6 text-white">
<ProductLoader />
</Spinner>
</template>
The fallback is a small neutral ring. There are no size, color, speed, stroke, variant, tone, mascot, or loading props. Repeated product treatment belongs in an application component. Brand characters and product-specific loaders remain product-owned.
API
| Concern | Vue | React | Svelte |
|---|---|---|---|
| Styling | class | className | class |
| Element access | template ref | forwarded ref | component binding |
| Wrapper attrs | non-conflicting native span attributes | non-conflicting native span attributes | non-conflicting native span attributes |
| Custom mark | default slot | children | default snippet |
| Semantics | fixed decorative wrapper | fixed decorative wrapper | fixed decorative wrapper |
data-slot="spinner" is stable for inspection and nearby selectors. The caller owns loading state, status text, aria-busy, cancellation, progress, retry, and persistence.
Motion
The default ring follows prefers-reduced-motion without JavaScript. A custom mark supplies its own normal animation; Spinner's reduced-motion guard stops CSS animation inside the wrapper. With motion reduced, the mark remains visible and adjacent text continues communicating the state.
Avoid bounce, wobble, dramatic entrances, and long exits. Loading is already an interruption; its utility indicator should be calm.
Durable behavior
Loading state is ephemeral and should be derived from the real request, navigation, or job. Spinner never writes it to local storage, the URL, or a component timer.
Long-running server work is different from an in-flight browser request. Restore its true state from the server after navigation or reload, then render Spinner from that state. This keeps the interface honest after refreshes and reconnects.
Complete framework source
Copy, inspect, and change the complete source for your framework.
<script setup>
import { computed, useAttrs, useSlots } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
const slots = useSlots()
const hasCustomMark = computed(() => Boolean(slots.default))
const forwardedAttrs = computed(() => {
const {
class: _class,
'aria-hidden': _ariaHidden,
role: _role,
focusable: _focusable,
tabindex: _tabindex,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
</script>
<template>
<span
v-bind="forwardedAttrs"
data-slot="spinner"
aria-hidden="true"
:class="
twMerge(
[
'inline-flex size-4 shrink-0 items-center justify-center motion-reduce:animate-none! motion-reduce:**:animate-none! *:size-full',
hasCustomMark ? '' : 'animate-spin'
],
attrs.class
)
"
>
<slot>
<svg
data-slot="spinner-mark"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
stroke-width="2"
opacity="0.2"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</slot>
</span>
</template>import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'
const Spinner = forwardRef(function Spinner(
{
children,
className,
'aria-hidden': _ariaHidden,
role: _role,
focusable: _focusable,
tabIndex: _tabIndex,
'data-slot': _dataSlot,
...props
},
ref
) {
return (
<span
{...props}
ref={ref}
data-slot="spinner"
aria-hidden="true"
className={twMerge(
[
'inline-flex size-4 shrink-0 items-center justify-center motion-reduce:animate-none! motion-reduce:**:animate-none! *:size-full',
children ? '' : 'animate-spin'
],
className
)}
>
{children ?? (
<svg
data-slot="spinner-mark"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeWidth="2"
opacity="0.2"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
)}
</span>
)
})
export default Spinner<script>
import { twMerge } from "tailwind-merge";
let {
children,
class: className,
"aria-hidden": _ariaHidden,
role: _role,
focusable: _focusable,
tabindex: _tabindex,
"data-slot": _dataSlot,
...props
} = $props();
</script>
<span
{...props}
data-slot="spinner"
aria-hidden="true"
class={twMerge(
[
"inline-flex size-4 shrink-0 items-center justify-center motion-reduce:animate-none! motion-reduce:**:animate-none! *:size-full",
children ? "" : "animate-spin",
],
className,
)}
>
{#if children}
{@render children()}
{:else}
<svg
data-slot="spinner-mark"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
stroke-width="2"
opacity="0.2"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
{/if}
</span>