Spinner
Spinner is a small decorative wrapper for indeterminate work. It includes a neutral fallback ring, accepts an application-owned loading mark, inherits the caller's text color, and leaves loading state and meaningful language with the application.
Installation
One command detects Vue, React, or Svelte and installs the framework-native source:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add spinner- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
The installed file belongs to the application. There is no initializer, Klean runtime, configuration file, provider, alias prompt, generated helper, or animation package.
Usage
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.
Vue
<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>
React
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>
)
}
Svelte
<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>
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. This is how Slipway retains the animated Slippy mascot while adopting Klean's shared loading contract. Slippy remains Slipway source—it does not become a mascot, icon, or variant prop.
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.
Vue source
<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>
React source
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
Svelte source
<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>