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.
- Create a div and set its size with size-8 (older projects use h-8 w-8).
- Round it with rounded-full so the border forms a circle.
- Add a border width such as border-4 and a track color such as border-gray-200.
- Color one side differently with border-t-blue-600. That side becomes the moving segment.
- Apply animate-spin.
- 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>
| Pros | Cons |
|---|
| Round caps through stroke-linecap | Path data adds markup |
| Arc length set by the path, not the border | Geometry is harder to edit than a class |
| One viewBox scales to any size | Needs 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.
- 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.
- 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.
- Flowbite is copy-paste markup in the v4.0 docs, and the v3.1.2 docs are still published for older projects.
- Preline UI lists 17 spinner examples, and 9 of them need a separate CSS block for custom keyframes.
- 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).
| Indicator | Fits | Tailwind approach | Limit |
|---|
| Spinner | One module, 2 to 10 second wait | animate-spin | No sense of duration |
| Skeleton screen | Full-page load under 10 seconds | Pulse-animated gray blocks | Wrong for uploads, downloads, conversions |
| Typing dots | Chat and streaming-style waits | Custom keyframes via animate-[…] | No progress information |
| Progress bar | Waits over 10 seconds, multi-step processes | Width on an inner div | Needs 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.