Skip to content

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.

Spinner.vue

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.

Terminal
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

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

DeploymentStatus.jsx
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

DeploymentStatus.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:

SlippySpinner.vue
<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:

vue
<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:

vue
<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:

loading-styles.vue
<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

ConcernVueReactSvelte
StylingclassclassNameclass
Element accesstemplate refforwarded refcomponent binding
Wrapper attrsnon-conflicting native span attributesnon-conflicting native span attributesnon-conflicting native span attributes
Custom markdefault slotchildrendefault snippet
Semanticsfixed decorative wrapperfixed decorative wrapperfixed 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

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>

React source

Spinner.jsx
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

Spinner.svelte
<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>

  • Button — owns a command's disabled, busy, and truthful pending states.
  • Toast — announces a completed background outcome without blocking the current task.
  • Slide — may expose pending state after a consequential action is confirmed.

All open source projects are released under the MIT License.