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.