Skip to content

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.

Preview Live · Vue
Usage
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>
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
<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
Spinner.vue
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.

Terminal
npx klean-ui add spinner

Source you own. Edit the installed files to make them yours.

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.

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:

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

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