Tailwind Spinner: 16 Spinner and Skeleton Loader Examples

Copy a spinner from an older Tailwind tutorial into a v4 project and the ring can look off. Border utilities now default to currentColor instead of gray-200, so a snippet with no track color draws a dark track.

Below are 16 Tailwind spinner examples you can copy, followed by a guide to building, sizing, speeding up and labeling them.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Spinner shapes, speeds and live dots

The core loaders built from animate-spin, animate-ping and a little custom CSS: border rings, SVG arcs drawn with stroke-dasharray, custom speeds through @theme, a typing indicator and live badges.

Border ring spinners in four sizes

  • animate-spin
  • border-t-*
  • border-current
  • size-*
  • motion-reduce:

Each ring is a rounded-full box with two border colors: the track on all sides and a darker border-t-* side that animate-spin carries around. The single-color version uses border-current with border-t-transparent so it follows the text color, and the inline one is sized in em so it scales with the sentence. Every spinner has role=status with sr-only text and slows to a 3s turn under motion-reduce.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <h2 class="text-base font-semibold text-slate-900">Border ring spinners</h2>
  <p class="mt-1 text-sm text-slate-600">A track color on every side and one darker side that does the moving.</p>
  <div class="mt-6 flex flex-wrap items-end gap-x-8 gap-y-6">
    <div class="flex flex-col items-center gap-2">
      <div role="status" class="size-4 animate-spin rounded-full border-2 border-indigo-100 border-t-indigo-600 motion-reduce:animate-[spin_3s_linear_infinite]"><span class="sr-only">Loading</span></div>
      <code class="text-xs text-slate-600">size-4</code>
    </div>
    <div class="flex flex-col items-center gap-2">
      <div role="status" class="size-6 animate-spin rounded-full border-[3px] border-emerald-100 border-t-emerald-600 motion-reduce:animate-[spin_3s_linear_infinite]"><span class="sr-only">Loading</span></div>
      <code class="text-xs text-slate-600">size-6</code>
    </div>
    <div class="flex flex-col items-center gap-2">
      <div role="status" class="size-8 animate-spin rounded-full border-4 border-rose-100 border-t-rose-600 motion-reduce:animate-[spin_3s_linear_infinite]"><span class="sr-only">Loading</span></div>
      <code class="text-xs text-slate-600">size-8</code>
    </div>
    <div class="flex flex-col items-center gap-2">
      <div role="status" class="size-12 animate-spin rounded-full border-4 border-orange-100 border-t-orange-600 border-r-orange-600 motion-reduce:animate-[spin_3s_linear_infinite]"><span class="sr-only">Loading</span></div>
      <code class="text-xs text-slate-600">size-12</code>
    </div>
  </div>
  <div class="mt-8 grid gap-3 sm:grid-cols-2">
    <div class="flex items-center gap-3 rounded-xl bg-sky-50 p-4 text-sky-800">
      <div role="status" class="size-5 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-[spin_3s_linear_infinite]"><span class="sr-only">Loading</span></div>
      <p class="text-sm font-medium">Syncing your calendar</p>
    </div>
    <div class="flex items-center gap-3 rounded-xl bg-slate-900 p-4 text-white">
      <div role="status" class="size-5 shrink-0 animate-spin rounded-full border-2 border-white/25 border-t-white motion-reduce:animate-[spin_3s_linear_infinite]"><span class="sr-only">Loading</span></div>
      <p class="text-sm font-medium">Rendering preview</p>
    </div>
  </div>
  <p class="mt-4 text-sm text-slate-700">Fetching rates <span role="status" class="inline-block size-[1em] animate-spin rounded-full border-2 border-current border-e-transparent align-[-0.125em] motion-reduce:animate-[spin_3s_linear_infinite]"><span class="sr-only">Loading</span></span> from three suppliers, this takes a few seconds.</p>
</div>

SVG arc spinners with stroke-dasharray

  • animate-spin
  • stroke-*
  • stroke-dasharray
  • pathLength
  • @utility

Each spinner is two circles: a faint track and an arc whose length comes from stroke-dasharray on a pathLength of 100, so 25 100 draws a quarter and round caps come from stroke-linecap. animate-spin turns the whole svg, and the stroke-* utilities color track and arc separately. The third arc also grows and shrinks through a small @utility tsp-dash with its own keyframes, applied only under motion-safe.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <h2 class="text-base font-semibold text-slate-900">SVG arc spinners</h2>
  <p class="mt-1 text-sm text-slate-600">The arc length is a dash pattern, so it can be any fraction of the ring.</p>
  <div class="mt-6 grid gap-4 sm:grid-cols-3">
    <div class="flex flex-col items-center gap-3 rounded-xl bg-violet-50 px-4 py-5">
      <div role="status">
        <svg class="size-12 animate-spin text-violet-700 motion-reduce:animate-[spin_3s_linear_infinite]" viewBox="0 0 48 48" fill="none" aria-hidden="true">
          <circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="5" class="opacity-20"/>
          <circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="5" stroke-linecap="round" pathLength="100" stroke-dasharray="25 100"/>
        </svg>
        <span class="sr-only">Loading</span>
      </div>
      <p class="text-center text-xs text-violet-900"><code class="font-semibold">25 100</code><br>quarter arc, round caps</p>
    </div>
    <div class="flex flex-col items-center gap-3 rounded-xl bg-teal-50 px-4 py-5">
      <div role="status">
        <svg class="size-12 animate-[spin_1.4s_linear_infinite] motion-reduce:animate-[spin_3s_linear_infinite]" viewBox="0 0 48 48" fill="none" aria-hidden="true">
          <circle cx="24" cy="24" r="20" stroke-width="2" class="stroke-teal-200"/>
          <circle cx="24" cy="24" r="20" stroke-width="2" stroke-linecap="round" pathLength="100" stroke-dasharray="70 100" class="stroke-teal-700"/>
        </svg>
        <span class="sr-only">Loading</span>
      </div>
      <p class="text-center text-xs text-teal-900"><code class="font-semibold">70 100</code><br>thin long arc, stroke-* colors</p>
    </div>
    <div class="flex flex-col items-center gap-3 rounded-xl bg-rose-50 px-4 py-5">
      <div role="status">
        <svg class="size-12 animate-[spin_2s_linear_infinite] text-rose-600 motion-reduce:animate-[spin_4s_linear_infinite]" viewBox="0 0 48 48" fill="none" aria-hidden="true">
          <circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="4" stroke-linecap="round" pathLength="100" stroke-dasharray="60 100" class="motion-safe:tsp-dash"/>
        </svg>
        <span class="sr-only">Loading</span>
      </div>
      <p class="text-center text-xs text-rose-900"><code class="font-semibold">tsp-dash</code><br>arc grows and shrinks</p>
    </div>
  </div>
</div>

Speeds and custom animations through @theme

  • animate-[...]
  • @theme
  • --animate-*
  • @keyframes
  • motion-reduce:animate-none

The first ring is the stock animate-spin, the second sets the whole animation with an arbitrary value, and the rest come from --animate-tsp-* variables in @theme, which Tailwind turns into animate-tsp-slow, animate-tsp-reverse and animate-tsp-bars. The bars keyframes live inside @theme next to their variable, and each bar starts at a different point thanks to a negative [animation-delay:-0.4s] style arbitrary property. Every loader stops under motion-reduce, where the label still reads Loading.

View code
<div class="w-full max-w-xl rounded-2xl bg-zinc-900 p-6 text-zinc-100 shadow-sm sm:p-8">
  <h2 class="text-base font-semibold">Same keyframes, different timing</h2>
  <p class="mt-1 text-sm text-zinc-400">Arbitrary values for one-offs, theme variables for anything you reuse.</p>
  <div class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
    <div class="flex flex-col items-center gap-3 rounded-xl bg-zinc-800 px-3 py-5">
      <div role="status" class="size-8 animate-spin rounded-full border-4 border-zinc-700 border-t-lime-400 motion-reduce:animate-none"><span class="sr-only">Loading</span></div>
      <code class="text-center text-xs break-all text-zinc-300">animate-spin</code>
    </div>
    <div class="flex flex-col items-center gap-3 rounded-xl bg-zinc-800 px-3 py-5">
      <div role="status" class="size-8 animate-[spin_2s_linear_infinite] rounded-full border-4 border-zinc-700 border-t-lime-400 motion-reduce:animate-none"><span class="sr-only">Loading</span></div>
      <code class="text-center text-xs break-all text-zinc-300">animate-[spin_2s_linear_infinite]</code>
    </div>
    <div class="flex flex-col items-center gap-3 rounded-xl bg-zinc-800 px-3 py-5">
      <div role="status" class="size-8 animate-tsp-slow rounded-full border-4 border-zinc-700 border-t-lime-400 motion-reduce:animate-none"><span class="sr-only">Loading</span></div>
      <code class="text-center text-xs break-all text-zinc-300">animate-tsp-slow</code>
    </div>
    <div class="flex flex-col items-center gap-3 rounded-xl bg-zinc-800 px-3 py-5">
      <div role="status" class="size-8 animate-tsp-reverse rounded-full border-4 border-zinc-700 border-t-cyan-400 motion-reduce:animate-none"><span class="sr-only">Loading</span></div>
      <code class="text-center text-xs break-all text-zinc-300">animate-tsp-reverse</code>
    </div>
    <div class="flex flex-col items-center gap-3 rounded-xl bg-zinc-800 px-3 py-5">
      <div role="status" class="flex h-8 items-end gap-1">
        <span class="h-8 w-1.5 origin-bottom animate-tsp-bars rounded-full bg-cyan-400 [animation-delay:-0.4s] motion-reduce:animate-none"></span>
        <span class="h-8 w-1.5 origin-bottom animate-tsp-bars rounded-full bg-cyan-400 [animation-delay:-0.25s] motion-reduce:animate-none"></span>
        <span class="h-8 w-1.5 origin-bottom animate-tsp-bars rounded-full bg-cyan-400 [animation-delay:-0.1s] motion-reduce:animate-none"></span>
        <span class="h-8 w-1.5 origin-bottom animate-tsp-bars rounded-full bg-cyan-400 motion-reduce:animate-none"></span>
        <span class="sr-only">Loading</span>
      </div>
      <code class="text-center text-xs break-all text-zinc-300">animate-tsp-bars</code>
    </div>
    <div class="flex flex-col items-center gap-3 rounded-xl bg-zinc-800 px-3 py-5">
      <div role="status" class="size-8 animate-[spin_0.6s_linear_infinite] rounded-full border-4 border-zinc-700 border-t-cyan-400 border-b-cyan-400 motion-reduce:animate-none"><span class="sr-only">Loading</span></div>
      <code class="text-center text-xs break-all text-zinc-300">animate-[spin_0.6s_linear_infinite]</code>
    </div>
  </div>
</div>

Chat typing indicator with staggered dots

  • @utility
  • motion-safe:
  • animate-pulse
  • [--var:value]
  • role=status

The bubble dots run a short tsp-typing keyframe that lifts each dot 5px, and a --tsp-delay custom property staggers them, because delay-* in Tailwind sets transition-delay, not animation-delay. The footer line uses the stock animate-pulse with the same stagger idea. Both indicators sit in a role=status element whose sr-only text names who is typing, and the bubble only animates under motion-safe.

View code
<div class="w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200">
  <div class="flex items-center gap-3 border-b border-slate-200 px-4 py-3">
    <span aria-hidden="true" class="grid size-9 place-items-center rounded-full bg-amber-200 text-sm font-semibold text-amber-900">ML</span>
    <div>
      <h2 class="text-sm font-semibold text-slate-900">Mara Lindqvist</h2>
      <p class="text-xs text-emerald-700">Online</p>
    </div>
  </div>
  <div class="space-y-3 bg-slate-50 px-4 py-5">
    <div class="flex items-end gap-2">
      <span aria-hidden="true" class="grid size-7 shrink-0 place-items-center rounded-full bg-amber-200 text-[11px] font-semibold text-amber-900">ML</span>
      <p class="max-w-[75%] rounded-2xl rounded-bl-sm bg-white px-3.5 py-2 text-sm text-slate-800 ring-1 ring-slate-200">Did the new hero photo make it into the build?</p>
    </div>
    <div class="flex justify-end">
      <p class="max-w-[75%] rounded-2xl rounded-br-sm bg-indigo-600 px-3.5 py-2 text-sm text-white">Pushed it ten minutes ago. Checking the preview now.</p>
    </div>
    <div class="flex items-end gap-2">
      <span aria-hidden="true" class="grid size-7 shrink-0 place-items-center rounded-full bg-amber-200 text-[11px] font-semibold text-amber-900">ML</span>
      <div role="status" class="flex h-9 items-center gap-1 rounded-2xl rounded-bl-sm bg-white px-4 ring-1 ring-slate-200">
        <span aria-hidden="true" class="size-2 rounded-full bg-slate-500 motion-safe:tsp-typing"></span>
        <span aria-hidden="true" class="size-2 rounded-full bg-slate-500 [--tsp-delay:0.15s] motion-safe:tsp-typing"></span>
        <span aria-hidden="true" class="size-2 rounded-full bg-slate-500 [--tsp-delay:0.3s] motion-safe:tsp-typing"></span>
        <span class="sr-only">Mara is typing</span>
      </div>
    </div>
  </div>
  <form class="flex items-center gap-2 border-t border-slate-200 p-3">
    <label for="tsp-chat-msg" class="sr-only">Message</label>
    <input id="tsp-chat-msg" type="text" placeholder="Write a message" class="min-h-11 w-full min-w-0 rounded-lg bg-slate-100 px-3 text-sm text-slate-900 placeholder:text-slate-500 focus-visible:outline-2 focus-visible:outline-indigo-600">
    <button type="submit" aria-label="Send message" class="grid size-11 shrink-0 place-items-center rounded-lg bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
      <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
    </button>
  </form>
  <p role="status" class="flex items-center gap-1.5 px-4 pb-3 text-xs text-slate-600">
    <span aria-hidden="true" class="flex gap-0.5">
      <span class="size-1 animate-pulse rounded-full bg-slate-500 motion-reduce:animate-none"></span>
      <span class="size-1 animate-pulse rounded-full bg-slate-500 [animation-delay:0.2s] motion-reduce:animate-none"></span>
      <span class="size-1 animate-pulse rounded-full bg-slate-500 [animation-delay:0.4s] motion-reduce:animate-none"></span>
    </span>
    Jonas is typing in #design-review
  </p>
</div>

Live badges with animate-ping

  • animate-ping
  • relative
  • absolute
  • motion-reduce:animate-none
  • bg-black/60

Every live dot is two stacked spans: a solid relative dot and an absolute copy behind it with animate-ping, which scales it to twice the size while fading it out. That is the radar ring from the Tailwind docs, used here for a live stream, an online counter and an unread badge. Under motion-reduce the ring stops and sits exactly under the dot, so nothing looks broken.

View code
<div class="w-full max-w-md space-y-4">
  <div class="relative overflow-hidden rounded-2xl bg-cover bg-center" style="background-image:url(city-skyline.webp)">
    <div class="bg-black/60 p-5">
      <span class="inline-flex items-center gap-2 rounded-md bg-red-600 px-2 py-1 text-xs font-bold tracking-wide text-white uppercase">
        <span class="relative flex size-2">
          <span class="absolute inline-flex size-full animate-ping rounded-full bg-white opacity-75 motion-reduce:animate-none"></span>
          <span class="relative inline-flex size-2 rounded-full bg-white"></span>
        </span>
        Live
      </span>
      <h2 class="mt-10 text-lg font-semibold text-white">Q4 roadmap town hall</h2>
      <p class="mt-1 text-sm text-slate-200">1,284 watching now</p>
    </div>
  </div>
  <div class="flex items-center justify-between gap-3 rounded-2xl bg-[#161b26] p-4 ring-1 ring-white/10">
    <div class="flex items-center gap-3">
      <span class="relative flex size-3">
        <span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-60 motion-reduce:animate-none"></span>
        <span class="relative inline-flex size-3 rounded-full bg-emerald-500"></span>
      </span>
      <div>
        <p class="text-sm font-semibold text-white">Support is online</p>
        <p class="text-xs text-slate-400">3 agents, typical reply in 2 minutes</p>
      </div>
    </div>
    <button type="button" class="min-h-11 shrink-0 rounded-lg bg-emerald-500 px-3 text-sm font-semibold text-emerald-950 hover:bg-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-400">Chat</button>
  </div>
  <div class="flex items-center justify-between gap-3 rounded-2xl bg-[#161b26] p-4 ring-1 ring-white/10">
    <p class="text-sm text-slate-300">New activity on your projects</p>
    <button type="button" aria-label="Notifications, 4 unread" class="relative grid size-11 place-items-center rounded-full bg-white/5 text-slate-200 hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-400">
      <svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10a6 6 0 1 1 12 0v4l1.5 3h-15L6 14z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>
      <span aria-hidden="true" class="absolute -top-0.5 -right-0.5 flex size-5">
        <span class="absolute inline-flex size-full animate-ping rounded-full bg-rose-400 opacity-60 motion-reduce:animate-none"></span>
        <span class="relative grid size-5 place-items-center rounded-full bg-rose-600 text-[11px] font-bold text-white">4</span>
      </span>
    </button>
  </div>
</div>

Skeleton screens with animate-pulse

Gray blocks that copy the shape of the content that is on its way. animate-pulse sits on the wrapper, aria-busy drives the swap and the skeleton stays hidden from screen readers.

Inbox list skeleton

  • animate-pulse
  • group-aria-busy:
  • aria-busy
  • divide-y
  • aria-disabled:

Click Reload to show the skeleton again.

The list wrapper carries aria-busy, and group-aria-busy: decides which of the two lists is visible, so the script only flips one attribute. The skeleton rows copy the real rows (avatar circle, two text lines, a time stamp) so nothing jumps when the content lands, and animate-pulse sits once on the skeleton list. The skeleton is aria-hidden while a role=status line tells screen readers what is happening.

View code
<div class="w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200">
  <div class="flex items-center justify-between gap-3 border-b border-slate-200 px-4 py-2">
    <h2 class="text-sm font-semibold text-slate-900">Inbox</h2>
    <button type="button" id="tsp-inbox-reload" class="min-h-11 rounded-lg px-3 text-sm font-medium text-sky-700 hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-sky-600 aria-disabled:cursor-wait aria-disabled:text-slate-500">Reload</button>
  </div>
  <div id="tsp-inbox" aria-busy="true" class="group">
    <ul aria-hidden="true" class="hidden animate-pulse divide-y divide-slate-100 group-aria-busy:block motion-reduce:animate-none">
      <li class="flex items-center gap-3 px-4 py-3.5"><span class="size-10 shrink-0 rounded-full bg-slate-200"></span><span class="flex-1 space-y-2"><span class="block h-3 w-1/3 rounded bg-slate-200"></span><span class="block h-3 w-4/5 rounded bg-slate-200"></span></span><span class="h-3 w-8 rounded bg-slate-200"></span></li>
      <li class="flex items-center gap-3 px-4 py-3.5"><span class="size-10 shrink-0 rounded-full bg-slate-200"></span><span class="flex-1 space-y-2"><span class="block h-3 w-2/5 rounded bg-slate-200"></span><span class="block h-3 w-3/5 rounded bg-slate-200"></span></span><span class="h-3 w-8 rounded bg-slate-200"></span></li>
      <li class="flex items-center gap-3 px-4 py-3.5"><span class="size-10 shrink-0 rounded-full bg-slate-200"></span><span class="flex-1 space-y-2"><span class="block h-3 w-1/4 rounded bg-slate-200"></span><span class="block h-3 w-11/12 rounded bg-slate-200"></span></span><span class="h-3 w-8 rounded bg-slate-200"></span></li>
      <li class="flex items-center gap-3 px-4 py-3.5"><span class="size-10 shrink-0 rounded-full bg-slate-200"></span><span class="flex-1 space-y-2"><span class="block h-3 w-1/3 rounded bg-slate-200"></span><span class="block h-3 w-2/3 rounded bg-slate-200"></span></span><span class="h-3 w-8 rounded bg-slate-200"></span></li>
    </ul>
    <ul class="divide-y divide-slate-100 group-aria-busy:hidden">
      <li class="flex items-center gap-3 px-4 py-3"><span aria-hidden="true" class="grid size-10 shrink-0 place-items-center rounded-full bg-sky-100 text-sm font-semibold text-sky-800">PN</span><span class="min-w-0 flex-1"><span class="block truncate text-sm font-semibold text-slate-900">Priya Natarajan</span><span class="block truncate text-sm text-slate-600">Invoice for October is attached</span></span><span class="text-xs text-slate-500">9:14</span></li>
      <li class="flex items-center gap-3 px-4 py-3"><span aria-hidden="true" class="grid size-10 shrink-0 place-items-center rounded-full bg-violet-100 text-sm font-semibold text-violet-800">HS</span><span class="min-w-0 flex-1"><span class="block truncate text-sm font-semibold text-slate-900">Halden Studio</span><span class="block truncate text-sm text-slate-600">Moodboard v3 is ready for comments</span></span><span class="text-xs text-slate-500">8:52</span></li>
      <li class="flex items-center gap-3 px-4 py-3"><span aria-hidden="true" class="grid size-10 shrink-0 place-items-center rounded-full bg-emerald-100 text-sm font-semibold text-emerald-800">TF</span><span class="min-w-0 flex-1"><span class="block truncate text-sm font-semibold text-slate-900">Tomas Ferreira</span><span class="block truncate text-sm text-slate-600">Re: launch checklist, two items left</span></span><span class="text-xs text-slate-500">Mon</span></li>
      <li class="flex items-center gap-3 px-4 py-3"><span aria-hidden="true" class="grid size-10 shrink-0 place-items-center rounded-full bg-amber-100 text-sm font-semibold text-amber-900">RO</span><span class="min-w-0 flex-1"><span class="block truncate text-sm font-semibold text-slate-900">Ruth Okafor</span><span class="block truncate text-sm text-slate-600">Coffee on Friday before the review?</span></span><span class="text-xs text-slate-500">Sun</span></li>
    </ul>
  </div>
  <p id="tsp-inbox-status" role="status" class="sr-only">Loading messages</p>
</div>

Article card next to its skeleton

  • animate-pulse
  • aspect-[4/3]
  • place-items-center
  • sm:grid-cols-2
  • sr-only

The loaded card and the skeleton share the same frame: aspect-[4/3] for the photo area, the same padding and line heights, so the swap causes no layout shift. The skeleton photo is a gray block with a faint outline icon placed with grid place-items-center, and animate-pulse runs on the whole card. A role=status wrapper with sr-only text stands in for the gray blocks.

View code
<div class="grid w-full max-w-2xl gap-5 sm:grid-cols-2">
  <article class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200">
    <img src="coast-road.webp" width="800" height="600" alt="Pine-covered cliffs and terraced hillsides dropping to a calm sea at sunset" class="aspect-[4/3] w-full object-cover">
    <div class="p-5">
      <p class="text-xs font-semibold text-teal-700">Travel journal</p>
      <h2 class="mt-1.5 text-base font-semibold leading-snug text-slate-900">Driving the cliff road above the western coast</h2>
      <p class="mt-2 text-sm text-slate-600">Twelve hairpins, two lookouts and the best almond cake on the island.</p>
      <p class="mt-4 text-xs text-slate-500">6 min read</p>
    </div>
  </article>
  <div role="status" class="animate-pulse overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 motion-reduce:animate-none">
    <div aria-hidden="true" class="grid aspect-[4/3] w-full place-items-center bg-slate-200">
      <svg class="size-10 text-slate-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M21 16l-5-5-8 8"/></svg>
    </div>
    <div aria-hidden="true" class="p-5">
      <div class="h-3 w-20 rounded bg-slate-200"></div>
      <div class="mt-3 h-4 w-11/12 rounded bg-slate-200"></div>
      <div class="mt-2 h-4 w-3/5 rounded bg-slate-200"></div>
      <div class="mt-4 h-3 w-full rounded bg-slate-200"></div>
      <div class="mt-2 h-3 w-4/5 rounded bg-slate-200"></div>
      <div class="mt-5 h-3 w-16 rounded bg-slate-200"></div>
    </div>
    <span class="sr-only">Loading article</span>
  </div>
</div>

Profile header skeleton

  • animate-pulse
  • group-aria-busy:
  • ring-4
  • -mt-*
  • grid-cols-3

Click Load profile, then Replay to see the skeleton again.

The skeleton mirrors the profile block for block: the cover band, an avatar circle pulled up with a negative margin and ring-4 ring-white, two name lines, three stat cells and two button shapes. Both versions sit in one aria-busy wrapper and group-aria-busy: shows one or the other, so the card keeps its height when the data arrives. animate-pulse is on the skeleton wrapper only.

View code
<div class="w-full max-w-sm">
  <div id="tsp-prof" aria-busy="true" class="group overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200">
    <div aria-hidden="true" class="hidden animate-pulse group-aria-busy:block motion-reduce:animate-none">
      <div class="h-20 bg-slate-200"></div>
      <div class="px-5 pb-5">
        <div class="-mt-9 size-18 rounded-full bg-slate-300 ring-4 ring-white"></div>
        <div class="mt-3 h-4 w-36 rounded bg-slate-200"></div>
        <div class="mt-2 h-3 w-52 rounded bg-slate-200"></div>
        <div class="mt-5 grid grid-cols-3 gap-3">
          <div class="h-11 rounded-lg bg-slate-100"></div>
          <div class="h-11 rounded-lg bg-slate-100"></div>
          <div class="h-11 rounded-lg bg-slate-100"></div>
        </div>
        <div class="mt-5 flex gap-2">
          <div class="h-11 flex-1 rounded-lg bg-slate-200"></div>
          <div class="h-11 flex-1 rounded-lg bg-slate-100"></div>
        </div>
      </div>
    </div>
    <div class="group-aria-busy:hidden">
      <div class="h-20 bg-teal-700"></div>
      <div class="px-5 pb-5">
        <span aria-hidden="true" class="-mt-9 grid size-18 place-items-center rounded-full bg-orange-100 text-xl font-semibold text-orange-900 ring-4 ring-white">ID</span>
        <h2 class="mt-3 text-base font-semibold text-slate-900">Ines Duarte</h2>
        <p class="mt-0.5 text-sm text-slate-600">Product designer at Northwind Labs</p>
        <dl class="mt-5 grid grid-cols-3 gap-3 text-center">
          <div class="rounded-lg bg-slate-50 py-1.5"><dt class="text-xs text-slate-600">Posts</dt><dd class="text-sm font-semibold text-slate-900">128</dd></div>
          <div class="rounded-lg bg-slate-50 py-1.5"><dt class="text-xs text-slate-600">Followers</dt><dd class="text-sm font-semibold text-slate-900">2.4k</dd></div>
          <div class="rounded-lg bg-slate-50 py-1.5"><dt class="text-xs text-slate-600">Following</dt><dd class="text-sm font-semibold text-slate-900">312</dd></div>
        </dl>
        <div class="mt-5 flex gap-2">
          <button type="button" class="min-h-11 flex-1 rounded-lg bg-teal-700 text-sm font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700">Follow</button>
          <button type="button" class="min-h-11 flex-1 rounded-lg bg-white text-sm font-semibold text-slate-800 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700">Message</button>
        </div>
      </div>
    </div>
  </div>
  <div class="mt-4 flex items-center justify-between gap-3">
    <p id="tsp-prof-status" role="status" class="text-sm text-slate-600">Waiting for profile data</p>
    <button type="button" id="tsp-prof-load" class="min-h-11 shrink-0 rounded-lg bg-slate-900 px-4 text-sm font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-disabled:cursor-wait aria-disabled:bg-slate-600">Load profile</button>
  </div>
</div>

Loading states inside real UI

Where spinners actually live: inside buttons, over a card, beside an autosaving field, in an upload queue and as a thin bar at the top of an app.

Submit and icon buttons with a busy state

  • group-aria-busy:
  • group-data-[state=done]:
  • disabled:
  • animate-spin
  • motion-reduce:animate-none

Click the button or type in the field to start the loading state.

On submit the script disables the button, sets aria-busy and a data-state, and Tailwind shows the right icon with group-aria-busy: and group-data-[state=done]: variants. The spinner is a border-current span, so it takes the white label color, and min-w-40 keeps the width steady while the label changes from Save changes to Saving and Saved. The icon-only sync button keeps its aria-label and spins its own arrows with group-aria-busy:animate-spin, and a role=status line announces both.

View code
<form id="tsp-settings" class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
  <div class="flex items-start justify-between gap-3">
    <div>
      <h2 class="text-base font-semibold text-slate-900">Workspace settings</h2>
      <p class="mt-1 text-sm text-slate-600">Last synced 4 minutes ago.</p>
    </div>
    <button type="button" id="tsp-sync" aria-label="Sync now" class="group grid size-11 shrink-0 place-items-center rounded-lg text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 aria-busy:cursor-wait aria-busy:text-indigo-700">
      <svg class="size-5 group-aria-busy:animate-spin motion-reduce:group-aria-busy:animate-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 0 0-14.9-3M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.9 3M20 20v-4h-4"/></svg>
    </button>
  </div>
  <label for="tsp-ws-name" class="mt-5 block text-sm font-medium text-slate-800">Workspace name</label>
  <input id="tsp-ws-name" type="text" value="Northwind Labs" class="mt-1.5 block min-h-11 w-full rounded-lg border border-slate-300 px-3 text-sm text-slate-900 focus-visible:border-indigo-600 focus-visible:outline-2 focus-visible:outline-indigo-600/30">
  <div class="mt-6 flex flex-wrap items-center gap-3">
    <button type="submit" id="tsp-save" data-state="idle" class="group inline-flex min-h-11 min-w-40 items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-wait disabled:hover:bg-indigo-600 data-[state=done]:bg-emerald-700">
      <span aria-hidden="true" class="hidden size-4 animate-spin rounded-full border-2 border-current border-t-transparent group-aria-busy:block motion-reduce:animate-none"></span>
      <svg class="hidden size-4 group-data-[state=done]:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>
      <span class="tsp-save-label">Save changes</span>
    </button>
    <p id="tsp-btn-status" role="status" class="text-sm text-slate-600"></p>
  </div>
</form>

Loading overlay over a chart card

  • absolute inset-0
  • bg-white/80
  • backdrop-blur-*
  • inert
  • aria-busy:

Click the button or type in the field to start the loading state.

The card body is relative and the overlay is an absolute inset-0 layer with bg-white/80 and a light backdrop-blur, so the old figures stay faintly visible while new ones load. During the refresh the body gets inert and aria-busy, which blocks clicks and focus inside it, and the hidden attribute toggles the overlay. The Refresh button sits outside the covered area, so focus never lands on a dead element.

View code
<div class="w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-stone-200">
  <div class="flex items-center justify-between gap-3 border-b border-stone-200 px-5 py-2">
    <h2 class="text-sm font-semibold text-stone-900">Revenue this week</h2>
    <button type="button" id="tsp-rev-refresh" class="inline-flex min-h-11 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-emerald-800 hover:bg-emerald-50 focus-visible:outline-2 focus-visible:outline-emerald-700 aria-disabled:cursor-wait aria-disabled:text-stone-500">
      <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 0 0-14.9-3M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.9 3M20 20v-4h-4"/></svg>
      Refresh
    </button>
  </div>
  <div class="relative">
    <div id="tsp-rev" class="p-5">
      <p class="text-3xl font-semibold tracking-tight text-stone-900 tabular-nums" id="tsp-rev-total">$18,420</p>
      <p class="mt-1 text-sm text-emerald-700" id="tsp-rev-delta">Up 6.2% on last week</p>
      <div aria-hidden="true" class="mt-6 flex h-32 items-end gap-2">
        <div class="tsp-rev-bar flex-1 rounded-t-md bg-emerald-200" style="height:42%"></div>
        <div class="tsp-rev-bar flex-1 rounded-t-md bg-emerald-200" style="height:58%"></div>
        <div class="tsp-rev-bar flex-1 rounded-t-md bg-emerald-200" style="height:51%"></div>
        <div class="tsp-rev-bar flex-1 rounded-t-md bg-emerald-200" style="height:74%"></div>
        <div class="tsp-rev-bar flex-1 rounded-t-md bg-emerald-600" style="height:92%"></div>
        <div class="tsp-rev-bar flex-1 rounded-t-md bg-emerald-200" style="height:66%"></div>
        <div class="tsp-rev-bar flex-1 rounded-t-md bg-emerald-200" style="height:38%"></div>
      </div>
      <div aria-hidden="true" class="mt-2 grid grid-cols-7 text-center text-xs text-stone-500"><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span></div>
    </div>
    <div id="tsp-rev-overlay" hidden aria-hidden="true" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/80 backdrop-blur-[2px]">
      <div class="size-9 animate-spin rounded-full border-4 border-stone-200 border-t-emerald-700 motion-reduce:animate-[spin_3s_linear_infinite]"></div>
      <p class="text-sm font-medium text-stone-800">Updating figures</p>
    </div>
  </div>
  <p id="tsp-rev-status" role="status" class="sr-only"></p>
</div>

Autosave status with role=status

  • role=status
  • aria-live
  • group-data-[state=*]:
  • animate-spin
  • size-3

Click the button or type in the field to start the loading state.

The status line is a role=status element with aria-live=polite, and its data-state (dirty, saving, saved) picks the icon through group-data-[state=saving]: and friends: an amber dot, a tiny size-3 border spinner, or a check. The script debounces typing, shows Saving... after 700ms of quiet and Saved just now when the fake request returns. The words carry the state, so the spinner simply stops under motion-reduce.

View code
<div class="w-full max-w-lg rounded-2xl bg-[#fbfaf7] p-5 shadow-sm ring-1 ring-stone-300 sm:p-6">
  <div class="flex flex-wrap items-center justify-between gap-2">
    <label for="tsp-note" class="text-sm font-semibold text-stone-900">Release notes, version 2.8</label>
    <p id="tsp-save-state" role="status" aria-live="polite" data-state="saved" class="group flex items-center gap-1.5 text-xs font-medium text-stone-600">
      <span aria-hidden="true" class="hidden size-2 rounded-full bg-amber-500 group-data-[state=dirty]:block"></span>
      <span aria-hidden="true" class="hidden size-3 animate-spin rounded-full border-2 border-stone-300 border-t-stone-700 group-data-[state=saving]:block motion-reduce:animate-none"></span>
      <svg class="hidden size-3.5 text-emerald-700 group-data-[state=saved]:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>
      <span id="tsp-save-text">All changes saved</span>
    </p>
  </div>
  <textarea id="tsp-note" rows="6" class="mt-3 block w-full resize-y rounded-lg border border-stone-300 bg-white p-3 text-sm leading-relaxed text-stone-800 focus-visible:border-stone-600 focus-visible:outline-2 focus-visible:outline-stone-400/40">Version 2.8 adds bulk export for invoices, a calmer empty state for new workspaces and faster search across archived projects.</textarea>
  <p class="mt-2 text-xs text-stone-600">Edits save automatically a moment after you stop typing.</p>
</div>

Upload queue: spinner, then check

  • group-data-[state=*]:
  • animate-spin
  • setTimeout
  • truncate
  • aria-disabled:

Click the button or type in the field to start the loading state.

Each row is a group with data-state set to queued, uploading or done, and three icons (clock, spinner, check) plus the status color are switched with group-data-[state=uploading]: style variants. A setTimeout chain moves through the rows one by one, and a role=status summary reports 1 of 3, 2 of 3 and so on. When the chain ends the button turns into Upload again so the sequence can be replayed.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <h2 class="text-base font-semibold text-slate-900">Upload to Brand assets</h2>
  <p class="mt-1 text-sm text-slate-600">3 files, 14.2 MB in total</p>
  <ul class="mt-4 divide-y divide-slate-100">
    <li class="tsp-up-row group flex items-center gap-3 py-3" data-state="queued">
      <span aria-hidden="true" class="grid size-10 shrink-0 place-items-center rounded-lg bg-rose-50 text-xs font-bold text-rose-700">PDF</span>
      <span class="min-w-0 flex-1"><span class="block truncate text-sm font-medium text-slate-900">brand-guidelines-2026.pdf</span><span class="tsp-up-text block text-xs text-slate-500 group-data-[state=done]:text-emerald-700 group-data-[state=uploading]:text-sky-700">Queued</span></span>
      <span aria-hidden="true" class="grid size-6 shrink-0 place-items-center">
        <svg class="size-5 text-slate-400 group-data-[state=done]:hidden group-data-[state=uploading]:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
        <span class="hidden size-5 animate-spin rounded-full border-2 border-sky-100 border-t-sky-600 group-data-[state=uploading]:block motion-reduce:animate-[spin_3s_linear_infinite]"></span>
        <svg class="hidden size-6 text-emerald-600 group-data-[state=done]:block" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm4.7 7.7-5.5 5.6a1 1 0 0 1-1.4 0l-2.5-2.5 1.4-1.4 1.8 1.8 4.8-4.9z"/></svg>
      </span>
    </li>
    <li class="tsp-up-row group flex items-center gap-3 py-3" data-state="queued">
      <span aria-hidden="true" class="grid size-10 shrink-0 place-items-center rounded-lg bg-sky-50 text-xs font-bold text-sky-800">IMG</span>
      <span class="min-w-0 flex-1"><span class="block truncate text-sm font-medium text-slate-900">hero-coast-road.webp</span><span class="tsp-up-text block text-xs text-slate-500 group-data-[state=done]:text-emerald-700 group-data-[state=uploading]:text-sky-700">Queued</span></span>
      <span aria-hidden="true" class="grid size-6 shrink-0 place-items-center">
        <svg class="size-5 text-slate-400 group-data-[state=done]:hidden group-data-[state=uploading]:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
        <span class="hidden size-5 animate-spin rounded-full border-2 border-sky-100 border-t-sky-600 group-data-[state=uploading]:block motion-reduce:animate-[spin_3s_linear_infinite]"></span>
        <svg class="hidden size-6 text-emerald-600 group-data-[state=done]:block" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm4.7 7.7-5.5 5.6a1 1 0 0 1-1.4 0l-2.5-2.5 1.4-1.4 1.8 1.8 4.8-4.9z"/></svg>
      </span>
    </li>
    <li class="tsp-up-row group flex items-center gap-3 py-3" data-state="queued">
      <span aria-hidden="true" class="grid size-10 shrink-0 place-items-center rounded-lg bg-amber-50 text-xs font-bold text-amber-800">ZIP</span>
      <span class="min-w-0 flex-1"><span class="block truncate text-sm font-medium text-slate-900">display-fonts.zip</span><span class="tsp-up-text block text-xs text-slate-500 group-data-[state=done]:text-emerald-700 group-data-[state=uploading]:text-sky-700">Queued</span></span>
      <span aria-hidden="true" class="grid size-6 shrink-0 place-items-center">
        <svg class="size-5 text-slate-400 group-data-[state=done]:hidden group-data-[state=uploading]:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
        <span class="hidden size-5 animate-spin rounded-full border-2 border-sky-100 border-t-sky-600 group-data-[state=uploading]:block motion-reduce:animate-[spin_3s_linear_infinite]"></span>
        <svg class="hidden size-6 text-emerald-600 group-data-[state=done]:block" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm4.7 7.7-5.5 5.6a1 1 0 0 1-1.4 0l-2.5-2.5 1.4-1.4 1.8 1.8 4.8-4.9z"/></svg>
      </span>
    </li>
  </ul>
  <div class="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4">
    <p id="tsp-up-sum" role="status" class="text-sm text-slate-600">0 of 3 uploaded</p>
    <button type="button" id="tsp-up-start" class="min-h-11 rounded-lg bg-sky-700 px-4 text-sm font-semibold text-white hover:bg-sky-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-disabled:cursor-wait aria-disabled:bg-slate-500">Start upload</button>
  </div>
</div>

Page-top loading bar

  • transition-[width,opacity]
  • aria-[current=page]:
  • aria-busy:
  • h-[3px]
  • motion-reduce:transition-none

Click Billing or Team to watch the bar run across the top.

A 3px absolute bar sits on the top edge of the app, and the script steps its width from 30% to 60% and 85% while the next view loads, then fills it, swaps the content and fades it out with opacity-0. transition-[width,opacity] does the smoothing and motion-reduce:transition-none turns it into plain jumps. The nav uses aria-current=page with an aria-[current=page]: variant, the main area dims with aria-busy:opacity-60, and a role=status line announces the view.

View code
<div class="relative w-full bg-white">
  <div aria-hidden="true" class="absolute inset-x-0 top-0 h-[3px]">
    <div id="tsp-bar" class="h-full w-0 bg-fuchsia-600 opacity-0 transition-[width,opacity] duration-300 ease-out motion-reduce:transition-none"></div>
  </div>
  <header class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 md:px-8">
    <span class="flex items-center gap-2 text-sm font-bold text-slate-900"><span aria-hidden="true" class="grid size-7 place-items-center rounded-md bg-fuchsia-700 text-xs text-white">F</span>Fernhill Docs</span>
    <nav aria-label="Workspace">
      <ul class="flex gap-1">
        <li><button type="button" data-page="overview" aria-current="page" class="tsp-nav min-h-11 rounded-lg px-3 text-sm font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-fuchsia-700 aria-[current=page]:bg-slate-900 aria-[current=page]:text-white">Overview</button></li>
        <li><button type="button" data-page="billing" class="tsp-nav min-h-11 rounded-lg px-3 text-sm font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-fuchsia-700 aria-[current=page]:bg-slate-900 aria-[current=page]:text-white">Billing</button></li>
        <li><button type="button" data-page="team" class="tsp-nav min-h-11 rounded-lg px-3 text-sm font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-fuchsia-700 aria-[current=page]:bg-slate-900 aria-[current=page]:text-white">Team</button></li>
      </ul>
    </nav>
  </header>
  <main id="tsp-page" class="px-5 py-8 transition-opacity aria-busy:opacity-60 motion-reduce:transition-none md:px-8">
    <h2 id="tsp-page-title" class="text-xl font-semibold tracking-tight text-slate-900">Overview</h2>
    <p id="tsp-page-text" class="mt-2 max-w-prose text-sm text-slate-600">Twelve documents changed this week. Two drafts are waiting for review and the style guide was published on Tuesday.</p>
    <div class="mt-6 grid gap-3 sm:grid-cols-3">
      <div class="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200"><p class="text-xs text-slate-600">Documents</p><p class="mt-1 text-lg font-semibold text-slate-900">148</p></div>
      <div class="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200"><p class="text-xs text-slate-600">Editors</p><p class="mt-1 text-lg font-semibold text-slate-900">9</p></div>
      <div class="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200"><p class="text-xs text-slate-600">Open reviews</p><p class="mt-1 text-lg font-semibold text-slate-900">2</p></div>
    </div>
  </main>
  <p id="tsp-page-status" role="status" class="sr-only"></p>
</div>

Progress rings, dark mode and reduced motion

A determinate SVG ring next to its indeterminate twin, a deploy log that switches with the dark: variant, and three ways to handle prefers-reduced-motion.

Determinate progress ring vs indeterminate

  • role=progressbar
  • stroke-dashoffset
  • pathLength
  • -rotate-90
  • group-data-[phase=*]:

Click Start export to run both phases.

While the export counts invoices the length is unknown, so an animate-spin arc shows and the progressbar has no aria-valuenow. Once the total is known the determinate ring takes over: a circle with pathLength=100 and stroke-dasharray=100, rotated with -rotate-90 so it starts at twelve o clock, where stroke-dashoffset = 100 minus the percentage. group-data-[phase=counting]: swaps the two SVGs, and a short transition-[stroke-dashoffset] smooths each step unless motion-reduce is on.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
  <div id="tsp-ring" data-phase="idle" class="group flex items-center gap-5">
    <div id="tsp-ring-bar" role="progressbar" aria-labelledby="tsp-ring-title" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" class="relative grid size-24 shrink-0 place-items-center">
      <svg class="hidden size-24 animate-spin text-amber-600 group-data-[phase=counting]:block motion-reduce:animate-[spin_3s_linear_infinite]" viewBox="0 0 48 48" fill="none" aria-hidden="true">
        <circle cx="24" cy="24" r="20" stroke-width="4" class="stroke-amber-100"/>
        <circle cx="24" cy="24" r="20" stroke="currentColor" stroke-width="4" stroke-linecap="round" pathLength="100" stroke-dasharray="22 100"/>
      </svg>
      <svg class="size-24 -rotate-90 group-data-[phase=counting]:hidden" viewBox="0 0 48 48" fill="none" aria-hidden="true">
        <circle cx="24" cy="24" r="20" stroke-width="4" class="stroke-slate-200"/>
        <circle id="tsp-ring-arc" cx="24" cy="24" r="20" stroke-width="4" stroke-linecap="round" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100" class="stroke-amber-600 transition-[stroke-dashoffset] group-data-[phase=idle]:opacity-0 duration-200 ease-out motion-reduce:transition-none"/>
      </svg>
      <span id="tsp-ring-pct" class="absolute text-lg font-semibold text-slate-900 tabular-nums group-data-[phase=counting]:hidden">0%</span>
    </div>
    <div class="min-w-0">
      <h2 id="tsp-ring-title" class="text-base font-semibold text-slate-900">Export 248 invoices</h2>
      <p id="tsp-ring-count" class="mt-1 text-sm text-slate-600 tabular-nums">CSV, October 2026</p>
      <p id="tsp-ring-status" role="status" class="mt-1 text-sm font-medium text-amber-800 group-data-[phase=done]:text-emerald-700">Ready to export</p>
    </div>
  </div>
  <button type="button" id="tsp-ring-start" class="mt-6 min-h-11 w-full rounded-lg bg-slate-900 px-4 text-sm font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-disabled:cursor-wait aria-disabled:bg-slate-600">Start export</button>
  <dl class="mt-5 grid gap-3 border-t border-slate-200 pt-4 text-xs sm:grid-cols-2">
    <div><dt class="font-semibold text-slate-900">Indeterminate</dt><dd class="mt-0.5 text-slate-600">Length unknown: animate-spin, no aria-valuenow.</dd></div>
    <div><dt class="font-semibold text-slate-900">Determinate</dt><dd class="mt-0.5 text-slate-600">Length known: stroke-dashoffset follows the percentage.</dd></div>
  </dl>
</div>

Deploy log with dark mode loaders

  • dark:
  • dark:border-t-*
  • border-dashed
  • @utility
  • aria-pressed

Use the theme button to switch the panel between dark and light.

Every color has a dark: twin: the spinner track drops to dark:border-sky-400/25 and the moving side brightens to dark:border-t-sky-400, because a 600 shade that reads well on white gets lost on a near-black panel. The panel starts with the dark class on its own wrapper and the Dark theme toggle button removes or adds it, with aria-pressed tracking the state. Pending steps use a dashed border-dashed circle, and the indeterminate bar under the list slides through a motion-safe:tsp-slide utility and becomes a static faded bar under reduced motion.

View code
<div id="tsp-deploy" class="dark w-full max-w-lg">
  <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6 dark:bg-[#0f1420] dark:ring-white/10">
    <div class="flex flex-wrap items-start justify-between gap-3">
      <div>
        <h2 class="text-base font-semibold text-slate-900 dark:text-white">Deploying fernhill-web</h2>
        <p class="mt-0.5 text-sm text-slate-600 dark:text-slate-400">Production, commit 4f9c2ab</p>
      </div>
      <button type="button" id="tsp-deploy-theme" aria-pressed="true" class="min-h-11 rounded-lg px-3 text-sm font-medium text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 dark:text-slate-200 dark:ring-white/15 dark:hover:bg-white/5 dark:focus-visible:outline-sky-400">Dark theme</button>
    </div>
    <ol class="mt-5 space-y-1">
      <li class="flex items-center gap-3 rounded-lg px-2 py-2.5">
        <svg class="size-5 shrink-0 text-emerald-600 dark:text-emerald-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm4.7 7.7-5.5 5.6a1 1 0 0 1-1.4 0l-2.5-2.5 1.4-1.4 1.8 1.8 4.8-4.9z"/></svg>
        <span class="flex-1 text-sm text-slate-800 dark:text-slate-200">Install dependencies <span class="sr-only">, done</span></span>
        <span class="text-xs text-slate-500 tabular-nums dark:text-slate-400">18s</span>
      </li>
      <li class="flex items-center gap-3 rounded-lg px-2 py-2.5">
        <svg class="size-5 shrink-0 text-emerald-600 dark:text-emerald-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm4.7 7.7-5.5 5.6a1 1 0 0 1-1.4 0l-2.5-2.5 1.4-1.4 1.8 1.8 4.8-4.9z"/></svg>
        <span class="flex-1 text-sm text-slate-800 dark:text-slate-200">Build and bundle <span class="sr-only">, done</span></span>
        <span class="text-xs text-slate-500 tabular-nums dark:text-slate-400">1m 04s</span>
      </li>
      <li class="flex items-center gap-3 rounded-lg bg-sky-50 px-2 py-2.5 dark:bg-sky-400/10">
        <span aria-hidden="true" class="size-5 shrink-0 animate-spin rounded-full border-2 border-sky-200 border-t-sky-600 motion-reduce:animate-[spin_3s_linear_infinite] dark:border-sky-400/25 dark:border-t-sky-400"></span>
        <span class="flex-1 text-sm font-medium text-sky-900 dark:text-sky-100">Run test suite <span class="sr-only">, running</span></span>
        <span class="text-xs text-sky-800 tabular-nums dark:text-sky-300">142 of 310</span>
      </li>
      <li class="flex items-center gap-3 rounded-lg px-2 py-2.5">
        <span aria-hidden="true" class="size-5 shrink-0 rounded-full border-2 border-dashed border-slate-300 dark:border-slate-600"></span>
        <span class="flex-1 text-sm text-slate-600 dark:text-slate-400">Upload static assets <span class="sr-only">, waiting</span></span>
      </li>
      <li class="flex items-center gap-3 rounded-lg px-2 py-2.5">
        <span aria-hidden="true" class="size-5 shrink-0 rounded-full border-2 border-dashed border-slate-300 dark:border-slate-600"></span>
        <span class="flex-1 text-sm text-slate-600 dark:text-slate-400">Promote to production <span class="sr-only">, waiting</span></span>
      </li>
    </ol>
    <div role="status" class="mt-5">
      <div aria-hidden="true" class="h-1.5 overflow-hidden rounded-full bg-slate-200 dark:bg-white/10">
        <div class="h-full w-1/3 rounded-full bg-sky-600 motion-safe:tsp-slide motion-reduce:w-full motion-reduce:opacity-40 dark:bg-sky-400"></div>
      </div>
      <p class="mt-2 text-xs text-slate-600 dark:text-slate-400">Step 3 of 5, running tests</p>
    </div>
  </div>
</div>

Reduced motion: stop, slow down or swap

  • motion-reduce:
  • motion-safe:
  • group-data-[motion=reduce]:
  • animate-[spin_3s_linear_infinite]
  • aria-pressed

Use Preview reduced motion to see what those users get.

Three answers to prefers-reduced-motion. Stop works when a word sits beside the icon (motion-reduce:animate-none), slow down keeps a lone spinner alive at a calm 3s turn, and swap hides the ring and shows a sentence with motion-reduce:hidden and motion-reduce:block. The preview button sets data-motion=reduce on the wrapper, and the group-data-[motion=reduce]: classes copy the motion-reduce: ones, so you can drop them in production.

View code
<div id="tsp-rm" data-motion="full" class="group w-full max-w-2xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
  <div class="flex flex-wrap items-center justify-between gap-3">
    <h2 class="text-base font-semibold text-slate-900">Three reduced-motion fallbacks</h2>
    <button type="button" id="tsp-rm-toggle" aria-pressed="false" class="inline-flex min-h-11 items-center gap-2 rounded-full bg-slate-100 px-4 text-sm font-medium text-slate-800 hover:bg-slate-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 aria-pressed:bg-violet-700 aria-pressed:text-white">
      <span aria-hidden="true" class="size-2 rounded-full bg-slate-400 group-data-[motion=reduce]:bg-white"></span>
      Preview reduced motion
    </button>
  </div>
  <div class="mt-6 grid gap-4 sm:grid-cols-3">
    <div class="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200">
      <div class="flex h-12 items-center">
        <span class="inline-flex min-h-10 items-center gap-2 rounded-lg bg-violet-700 px-3 text-sm font-semibold text-white">
          <span aria-hidden="true" class="size-4 animate-spin rounded-full border-2 border-white/40 border-t-white motion-reduce:animate-none group-data-[motion=reduce]:animate-none"></span>
          Processing
        </span>
      </div>
      <h3 class="mt-3 text-sm font-semibold text-slate-900">Stop</h3>
      <p class="mt-1 text-xs text-slate-600">The word Processing carries the status, so the icon can freeze.</p>
      <code class="mt-2 block text-xs break-all text-violet-800">motion-reduce:animate-none</code>
    </div>
    <div class="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200">
      <div class="flex h-12 items-center">
        <div role="status" class="size-10 animate-spin rounded-full border-4 border-violet-100 border-t-violet-700 motion-reduce:animate-[spin_3s_linear_infinite] group-data-[motion=reduce]:animate-[spin_3s_linear_infinite]"><span class="sr-only">Loading</span></div>
      </div>
      <h3 class="mt-3 text-sm font-semibold text-slate-900">Slow down</h3>
      <p class="mt-1 text-xs text-slate-600">A lone spinner keeps turning, at a third of the speed.</p>
      <code class="mt-2 block text-xs break-all text-violet-800">motion-reduce:animate-[spin_3s_linear_infinite]</code>
    </div>
    <div class="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200">
      <div role="status" class="flex h-12 items-center">
        <div aria-hidden="true" class="size-10 animate-spin rounded-full border-4 border-violet-100 border-t-violet-700 motion-reduce:hidden group-data-[motion=reduce]:hidden"></div>
        <p class="hidden text-sm font-semibold text-violet-800 motion-reduce:block group-data-[motion=reduce]:block">Loading report...</p>
        <span class="sr-only motion-reduce:hidden group-data-[motion=reduce]:hidden">Loading report</span>
      </div>
      <h3 class="mt-3 text-sm font-semibold text-slate-900">Swap for text</h3>
      <p class="mt-1 text-xs text-slate-600">The ring disappears and a short sentence takes its place.</p>
      <code class="mt-2 block text-xs break-all text-violet-800">motion-reduce:hidden + motion-reduce:block</code>
    </div>
  </div>
</div>

What Is a Tailwind Spinner and How Does animate-spin Work?

It comes down to one utility. animate-spin rotates whatever element it sits on, so there are no custom keyframes to write and no JavaScript involved.

Don’t go looking for a generator or a standalone tool, because there isn’t one. You put a div or an SVG together with a few utility classes and that’s the whole spinner.

What animate-spin applies

The Tailwind CSS animation documentation (v4.3, as of October 2026) defines animate-spin as spin 1s linear infinite. The neighbors are animate-pulse (pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite), animate-bounce (bounce 1s infinite) and animate-ping (ping 1s cubic-bezier(0, 0, 0.2, 1) infinite).

The spin motion comes from a single set of CSS keyframes that end at rotate(360deg). Linear timing keeps the speed constant, so the spinner never seems to speed up or stall.

Only spin works as a loading indicator on its own. The same docs point pulse at skeleton loaders and ping at notification badges, while bounce is meant for scroll-down cues.

What core does not ship

No spinner class exists in core. The npm package tailwindcss-spinner (version 1.2.0) is deprecated, and its author message says Tailwind CSS v1.6 introduced animations with a spinner example in the docs.

Anyone still writing CSS spinners by hand, with custom class names and a keyframes block, is redoing work the framework already covers.

How to Build a Tailwind Spinner Step by Step

One div is enough.

  1. Create a div and set its size with size-8 (older projects use h-8 w-8).
  2. Round it with rounded-full so the border forms a circle.
  3. Add a border width such as border-4 and a track color such as border-gray-200.
  4. Color one side differently with border-t-blue-600. That side becomes the moving segment.
  5. Apply animate-spin.
  6. Add role=”status” and an sr-only text label.
<div role="status" class="size-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600">
  <span class="sr-only">Loading</span>
</div>

Want a single-color ring instead? Swap the pair for border-current and border-t-transparent, and the ring takes whatever text color its parent sets.

TW Elements ships that variant with border-e-transparent, inline-block and an align-[-0.125em] offset so the ring sits on the text baseline.

Size and color

GitLab’s Pajamas design system defines 4 spinner sizes. The 16px one goes in labels and buttons, 24px suits larger visual elements, 32px is for components such as charts, and 64px covers large content areas.

In Tailwind terms that maps to size-4 for buttons and inline text, size-6 (24px) for cards and status icons, and size-8 (32px) for panels. All of it assumes the default root size.

Color comes from border-* utilities, or from a text-* utility paired with border-current. Add dark: variants for dark mode, for example dark:border-gray-700 on the track.

Pick a segment color with enough color contrast against the background, because a pale ring on a white page disappears.

Where the border version breaks

If all four sides share one color, the ring rotates and nobody sees it move. That one is easy to spot. The v4 change is sneakier: the upgrade guide says border utilities now default to currentColor instead of gray-200, so a v3 snippet that relied on the default draws a dark track.

Older browsers are the other snag. Tailwind v4.0 targets Safari 16.4, Chrome 111 and Firefox 128, and the upgrade guide points projects that need older browsers to v3.4.

Set the track color explicitly and check the browser list, and the border version holds up in production.

Border Spinner or SVG Spinner: Which Construction Fits?

For most apps the border version wins. It’s one element, there’s no path data to copy and no asset to host. SVG earns its place when you want round caps, or when the ring and arc need separate colors driven by their own utilities. Arc length is the other reason, since only the path controls it.

Border div spinner

The track and the moving segment come from two border colors, and border-current lets the whole thing follow the parent text color. Past the class list there’s nothing to maintain.

The catch is that the segment is one border side, so the arc stays at roughly a quarter of the ring. The ends are flat too.

Inline SVG spinner

Flowbite’s default spinner is an SVG with 2 paths, a full ring and a partial arc. Its docs color the ring with a text-* utility and the arc with a fill-* utility.

An inline SVG keeps those paths in the markup, so Tailwind classes reach them like any other element. Flowbite also wraps the icon in a role=”status” div, hides the SVG with aria-hidden, and adds an sr-only label.

<svg class="size-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25" />
  <path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
</svg>
ProsCons
Round caps through stroke-linecapPath data adds markup
Arc length set by the path, not the borderGeometry is harder to edit than a class
One viewBox scales to any sizeNeeds aria-hidden so screen readers skip it

Decision rule

Flat single-color ring? Border spinner. Switch to SVG when the design calls for round ends or a thin arc.

I’d skip SVG for a plain ring. Pasted path data adds markup and gives nothing a border cannot.

If you do go with SVG, animate-spin rotates the whole element, the same idea behind guides on how to animate SVG with CSS. The path data never changes.

GitLab’s Pajamas spinner splits into a track and a segment that travels along it, and both builds above follow that structure.

How to Add a Tailwind Spinner to a Button, Form or Overlay

Put the spinner inside the button next to the label, disable the button while the request runs, and remove both when it finishes. Overlays and route-level loading use the same spinner in a different container.

Button loading state

The Tailwind docs example is a disabled button holding an animate-spin SVG with a right margin, and the word Processing next to it.

<button type="submit" disabled class="inline-flex items-center gap-2 rounded-md bg-indigo-600 px-4 py-2 text-white">
  <span class="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden="true"></span>
  Saving
  </button>

A border span works here and takes the label color through border-current.

On form submit, disable the button so a second click cannot send the form twice. Keep the busy label close in length to the original (“Saving” for “Save”) so the button width does not jump.

The surrounding classes follow regular Tailwind button styles, with inline-flex and items-center lining up the spinner and the label.

Overlay and centering

Flowbite dims a card instead of hiding it. The card is relative, its text carries opacity-20, and the spinner wrapper is absolute with top-2/4, left-1/2 and negative half translations. That pattern fits a single panel that loads on its own.

A full-page overlay is a different animal: fixed inset-0 z-50 flex items-center justify-center, usually with a translucent background. For a lone section you can skip the overlay and use flex justify-center with some vertical padding.

A fixed overlay blocks every click on the page. Reserve it for actions that really lock the interface.

React, Vue and Next.js

Next.js introduced the loading file convention in v13.0.0, and its docs (updated September 2026) say loading.js wraps page.js and nested children in a Suspense boundary. The fallback appears immediately on navigation while the route streams in.

export default function Loading() {
  return (
  <div className="flex min-h-[50vh] items-center justify-center">
  <div className="size-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600" />

  </div>
  )
}

Put that in app/dashboard/loading.tsx and the dashboard route shows the spinner during navigation.

There’s a gap, though. loading.js does not wrap layout.js in the same segment, so if the layout reads uncached or runtime data, no fallback appears for it, according to the docs.

In React, render the spinner from a state flag or as the fallback of a Suspense boundary. In Vue, toggle it with v-if on a loading ref.

If Tailwind is not set up in your React project yet, a guide to using Tailwind CSS in React covers the install first.

Do You Need a Library for a Tailwind Spinner?

Not for one or two spinners. Plain utility classes beat any library there, because a dependency weighs more than six classes.

A library starts to make sense when a project needs several loader styles that stay consistent. Here they are ranked by how little spinner markup you maintain, with details taken from each library’s own documentation as of October 2026.

  1. daisyUI comes out on top. It has 6 loading styles (spinner, dots, ring, ball, bars, infinity) in 5 sizes, all set through class names on one span and colored with text-* utilities.
  2. Material Tailwind gives you a React Spinner component with size and color props in the v3 docs. The v2 docs size it through Tailwind h-* and w-* classes instead.
  3. Flowbite is copy-paste markup in the v4.0 docs, and the v3.1.2 docs are still published for older projects.
  4. Preline UI lists 17 spinner examples, and 9 of them need a separate CSS block for custom keyframes.
  5. TW Elements offers a border spinner plus a growing variant, driven by an animate-[spinner-grow_0.75s_linear_infinite] arbitrary value.

Copying from these sets goes wrong in predictable ways. Custom keyframes often sit in a separate CSS tab, so pasting the HTML alone gives you an element that never moves.

Theme tokens are the other trap. Classes such as text-foreground and bg-surface-4 come from Preline’s own theme, and in a plain Tailwind project they do nothing, because the default theme has no such colors.

If you are weighing a library for the whole interface and not only the spinner, judge it on the full component set, not on the loaders alone.

How to Change Spinner Speed or Define a Custom Animation

Some tutorials claim speed needs custom CSS. The Tailwind docs show otherwise, and none of the options below needs a separate stylesheet. You can use an arbitrary value on one element, a named utility in tailwind.config.js (v3), or an –animate-* theme variable in CSS (v4).

One-off arbitrary values

Set the whole animation on a single element with animate-[spin_2s_linear_infinite]. Underscores stand in for spaces.

Both the v3.4 and v4.3 docs document this syntax, and v4 adds animate-(–my-animation) for values stored in a CSS variable.

Good for one slow spinner. Anything reused across a project belongs in the theme.

Tailwind v3 config

The v3 docs add a named utility through theme.extend.animation, with a spin-slow example that runs the spin keyframes for 3s.

module.exports = {
  theme: {
    extend: {
      animation: {
        'spin-slow': 'spin 3s linear infinite',
      },
    },
  },
}

Custom shapes need a keyframes entry beside it, as in the docs wiggle example. To draft keyframes for a new spinner shape, a CSS keyframe animation generator outputs a block you can adapt.

Tailwind v4 theme variables

Redefine –animate-spin inside @theme and every animate-spin in the project changes. The theme docs show the same pattern for breakpoints.

A new name works too. Add –animate-spin-slow in @theme and you get animate-spin-slow. If the animation needs custom keyframes, write the @keyframes rule beside the variable, also inside @theme.

@import "tailwindcss";

@theme {
  --animate-spin-slow: spin 3s linear infinite;
}

A custom animation that sits still usually means its @keyframes rule never reached the output. Keyframes inside @theme are emitted with their –animate-* variable, and keyframes defined outside @theme are always included, per the theme docs.

One more trap: a v3 tailwind.config.js is not picked up automatically in v4. Load it with the @config directive, as the upgrade guide describes.

How to Make a Tailwind Spinner Accessible

Give the spinner a text label that screen readers announce, then decide what the animation does when a user asks for reduced motion. Sources disagree on that second part.

Labeling the spinner

Bootstrap 5.3 documents two ways to label a spinner, and both carry role=”status”. One hides a span reading Loading inside the spinner. The other puts visible text such as Loading on the role and gives the spinner itself aria-hidden.

Tailwind’s sr-only class does the job of Bootstrap’s visually-hidden. Preline’s examples add an aria-label on the wrapper as well.

Inside buttons I’d go with the visible label, since words like Saving already sit beside the icon. The status role is one of the live-region roles in ARIA, which is why a changed label gets announced without moving focus.

Reduced motion: stop, slow or keep

The Tailwind docs example puts motion-safe:animate-spin on a disabled button, so reduced-motion users see a still icon next to the word Processing. That’s the stop option.

Bootstrap 5.3 takes a softer line. It disables most transitions under prefers-reduced-motion but slows meaningful animations such as spinners.

Then there’s the case for leaving the animation alone. W3C treats a loading animation as essential only when interaction cannot occur during that phase for all users and missing progress feedback could make people think the page is frozen or broken.

Stopping is fine when visible text carries the status, which is why the docs example pairs the still icon with a word. When the spinner is the only sign of progress, slow it instead.

<div role="status" class="size-8 animate-spin motion-reduce:animate-[spin_3s_linear_infinite] rounded-full border-4 border-gray-200 border-t-blue-600">
  <span class="sr-only">Loading</span>
</div>

That is the same slow-down approach Bootstrap takes, written in Tailwind terms. The failure mode is a frozen circle with no text, which is exactly the confusion the W3C note describes.

What WCAG 2.2.2 requires

W3C’s Understanding document for success criterion 2.2.2 (Level A, updated 10 August 2026) requires a way to pause, stop or hide moving content that starts automatically, lasts more than five seconds and runs alongside other content, unless the motion is essential.

A standalone preloader page is exempt, since nothing runs in parallel. The document’s own loading-animation example needs no control even when the wait passes five seconds.

A spinner inside a button is different because it runs beside a live page. If the rest of the page stays interactive, Note 4’s condition (no interaction possible during the phase) is not met.

That is the case for the motion-reduce pairing above over a bare animate-spin.

When Is a Spinner the Wrong Loading Indicator?

Skip the spinner for loads under 1 second, for full-page loads, for processes with a known length, and for waits past 10 seconds. Each case has a better pattern.

Thresholds below come from Nielsen Norman Group’s Skeleton Screens 101 (published June 2023, last updated September 2026).

IndicatorFitsTailwind approachLimit
SpinnerOne module, 2 to 10 second waitanimate-spinNo sense of duration
Skeleton screenFull-page load under 10 secondsPulse-animated gray blocksWrong for uploads, downloads, conversions
Typing dotsChat and streaming-style waitsCustom keyframes via animate-[…]No progress information
Progress barWaits over 10 seconds, multi-step processesWidth on an inner divNeeds a real percentage

Under one second: show nothing

NN/g adds that pages loading in under 1 second need neither a skeleton screen nor a spinner, and a quick flash can make users feel they cannot keep up.

Mount the spinner after a short timer so fast responses never show it. A flicker costs more than a missing indicator.

Full-page loads: use a skeleton

Spinners suit a single module such as a video or a dashboard card. When the whole screen loads, skeleton screens work better because the wireframe previews the layout.

<div class="animate-pulse space-y-3">
  <div class="h-4 w-1/2 rounded bg-gray-200"></div>
  <div class="h-3 rounded bg-gray-200"></div>
  <div class="h-3 w-5/6 rounded bg-gray-200"></div>
</div>

The Tailwind docs call this pattern a skeleton loader, and animate-pulse goes on the wrapper, not on each block.

Skip frame-only skeletons that show just a header and footer. NN/g treats them as a spinner in disguise.

Long and measurable waits: progress bars

Past 10 seconds, NN/g strongly recommends a progress bar, because a spinner gives no idea of the remaining wait. Uploads, downloads and conversions call for a progress bar or a step-by-step wizard, and never a skeleton screen.

When the length is truly unknown, Nielsen’s original response-time article calls a spinning ball the last resort, for cases where even a running count of work done is impossible.

Read together, those sources limit a spinner to unknown waits that stay short. Tailwind ships no progress utility, so progress bars built with Tailwind use a track div with an inner div whose width follows the real percentage.

Tailwind Spinner FAQ

Why is animate-spin not spinning?

Start with the element. A span needs inline-block or block, because CSS transforms skip inline elements.

Next, check how the class name is written, since names assembled from string fragments never reach the stylesheet. And if you used motion-safe:animate-spin, it stays still whenever the OS requests reduced motion.

Why does my spinner wobble instead of turning on its center?

The element is not square, or the animation sits on an SVG child. Give the spinner equal size-* values and put animate-spin on the svg root.

On an inner path or g, add origin-center plus [transform-box:fill-box] so it rotates around its own middle.

Can a Tailwind spinner run without JavaScript?

Yes. The rotation is a CSS animation, so JavaScript only decides when the spinner appears and disappears.

Bootstrap’s docs describe its spinners the same way: built with HTML and CSS, with custom JavaScript needed only to toggle visibility.

How do I reverse the spin direction?

Tailwind has no built-in utility for it. Define one like the docs’ spin-slow example and add the reverse keyword: spin 1s linear infinite reverse.

In v3 it goes in theme.extend.animation. In v4 it goes into an –animate-spin-reverse variable inside @theme.

What Should Replace a Tailwind Spinner When Loading Fails

An error message with a retry button should replace a Tailwind spinner when a request fails or outlasts its time limit. A spinner that never stops reads as a frozen page.

Cap the wait at five seconds for a spinner beside other page content, which keeps the animation under the WCAG 2.2.2 threshold. NN/g’s 10-second limit only suits a spinner that owns a whole module.

Start a timeout when the spinner mounts, swap in a message and a retry button when it expires, and write that message into the existing status region. The timeout comes first, since nothing else fires without it.

One downside is that a five-second cutoff flags slow connections that would have finished at seven, so word the message as a delay, not a verdict.

Position verified October 2026 against WCAG 2.2. A revision to the five-second rule would move the cutoff.