EmpireUI
Get Pro
← Blog7 min read#button-animation#loading-state#react-ui

Button Loading State Animation: Spinner to Check Flow

Build a polished button loading state with spinner-to-checkmark animation using React and Tailwind v4. No libraries needed — just clean CSS transitions developers actually want.

Code editor showing a React button component with loading spinner animation on dark background

Why Your Submit Button Is Lying to Users

Honestly, a button that does nothing visible after a click is one of the worst UX patterns still shipping in 2026. The user clicks. Nothing changes. They click again. Now you have a double-submit bug and an angry support ticket.

The fix isn't complicated. A loading state — specifically a spinner that transitions to a checkmark on success — gives users instant feedback that something is happening. It stops double-clicks. It signals completion without a full page reload. Three problems, one 40-line component.

What we're building here is a three-phase button: idle, loading (spinner), and done (animated checkmark). The transitions between phases are CSS-driven, which keeps things snappy and off the main thread where possible. No animation library required.

The Component State Machine

Think of the button as a tiny state machine with three nodes: idle, loading, and success. Each node renders different content and applies different Tailwind classes. The transitions are triggered by your async function resolving.

Here's the full React component. It uses useState for phase tracking, a useEffect to auto-reset back to idle after 2000ms, and CSS keyframe animations for the spinner and checkmark draw. This works with Tailwind v4.0.2 and React 19.

import { useState, useEffect } from 'react';

type Phase = 'idle' | 'loading' | 'success';

interface LoadingButtonProps {
  onSubmit: () => Promise<void>;
  label?: string;
}

export function LoadingButton({
  onSubmit,
  label = 'Save Changes',
}: LoadingButtonProps) {
  const [phase, setPhase] = useState<Phase>('idle');

  useEffect(() => {
    if (phase !== 'success') return;
    const id = setTimeout(() => setPhase('idle'), 2000);
    return () => clearTimeout(id);
  }, [phase]);

  const handleClick = async () => {
    if (phase !== 'idle') return;
    setPhase('loading');
    try {
      await onSubmit();
      setPhase('success');
    } catch {
      setPhase('idle');
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={phase !== 'idle'}
      className={[
        'relative flex items-center justify-center gap-2',
        'h-10 min-w-[140px] rounded-lg px-5 text-sm font-medium',
        'transition-all duration-200',
        phase === 'success'
          ? 'bg-emerald-500 text-white'
          : 'bg-indigo-600 text-white hover:bg-indigo-500',
        phase !== 'idle' ? 'cursor-not-allowed opacity-80' : '',
      ].join(' ')}
    >
      {phase === 'idle' && label}
      {phase === 'loading' && <Spinner />}
      {phase === 'success' && <Checkmark />}
    </button>
  );
}

The disabled prop on a non-idle button prevents re-clicks at the HTML level, not just via JS guards. Both defenses together are the right call — never rely on a single layer.

Building the CSS Spinner

The spinner is a 16x16px element with a 2px border. Three sides are transparent, one is white. Spin it with a CSS keyframe. That's literally all it is.

/* globals.css or a <style> tag in your component */
@keyframes spin {
  to { transform: rotate(360deg); }
}

@keyframes draw-check {
  from {
    stroke-dashoffset: 24;
  }
  to {
    stroke-dashoffset: 0;
  }
}

.spinner {
  width: 16px;
  height: 16px;
  border: 2px solid rgba(255, 255, 255, 0.25);
  border-top-color: rgba(255, 255, 255, 0.9);
  border-radius: 50%;
  animation: spin 0.65s linear infinite;
}

.check-path {
  stroke-dasharray: 24;
  stroke-dashoffset: 24;
  animation: draw-check 0.35s ease-out 0.05s forwards;
}

The rgba(255,255,255,0.25) on three sides gives a ghost track effect — users subconsciously perceive it as a progress ring rather than a random spinning shape. It's a small detail that feels professional.

The draw-check keyframe uses SVG stroke-dasharray/dashoffset to animate the checkmark path drawing itself in. The 0.05s delay lets the color transition from indigo to emerald complete first, so the two animations don't fight each other visually.

The Checkmark SVG Animation

You could use a Unicode checkmark. Don't. It won't animate and it looks different across operating systems. Use an inline SVG with a single polyline path so you can drive the draw animation via CSS.

function Spinner() {
  return <span className="spinner" aria-hidden="true" />;
}

function Checkmark() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      aria-hidden="true"
    >
      <polyline
        className="check-path"
        points="2.5,8 6.5,12 13.5,4"
        stroke="white"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

The polyline path length here is roughly 24 units, which matches stroke-dasharray: 24 in the CSS. If you resize the SVG viewBox you'll need to adjust that number — use path.getTotalLength() in the browser console to get the exact value for custom paths.

Want to pair this kind of micro-interaction with a more dramatic background effect? Check out the particles background for React — stacking a subtle particle field behind a card with this button creates a satisfying confirmation moment.

Accessibility: What Most Tutorials Skip

The visual change from label to spinner is meaningless to a screen reader user unless you handle ARIA. Here's what actually matters: aria-live on a visually hidden status element that updates with the phase, and keeping the button's accessible label consistent.

Add this alongside the button. It costs two lines and makes the component usable for everyone:

<>
  <LoadingButton onSubmit={handleSave} label="Save Changes" />
  <span
    role="status"
    aria-live="polite"
    className="sr-only"
  >
    {phase === 'loading' ? 'Saving...' : phase === 'success' ? 'Saved.' : ''}
  </span>
</>

The sr-only class in Tailwind positions the element off-screen (not display:none, which would silence it). The polite live region means the screen reader announces the change after finishing whatever it was saying. Use assertive only for errors — it interrupts immediately and is annoying in non-critical flows.

Tailwind v4 Utility Approach vs Custom CSS

In Tailwind v4.0.2 you can skip the external CSS file entirely and define keyframes inline in your config or via @theme. This keeps everything co-located if that's your preference.

Whether you use custom CSS or Tailwind's animate-* utilities is a style choice. The one place I'd stick with custom CSS is the SVG stroke animation — stroke-dashoffset isn't in Tailwind's default scale and adding it via arbitrary values ([stroke-dashoffset:24]) on every render is noisy. Sometimes CSS is just the right tool.

If you're already using theme toggle patterns in React, you'll want to make sure the button's success state color (emerald-500) has enough contrast in both light and dark modes. That green works fine on dark backgrounds but goes muddy on a white card in light mode — bump it to emerald-600 for light themes.

There's a broader conversation about when Tailwind makes sense versus dedicated stylesheets — Tailwind vs CSS Modules covers the tradeoffs in detail if you're making that architectural call right now.

Error State: The Third Phase Nobody Handles

What happens when onSubmit rejects? The current component resets to idle silently. That's probably wrong. Users need to know it failed.

Extend the state machine with a fourth phase: error. Flash the button red for 1500ms, then reset. Add a shake animation (3 rapid horizontal translations) for extra clarity. The shake doesn't need to be accessible — the error message in your form handles that — but it gives sighted users an immediate signal without requiring them to read anything.

The key thing is: don't leave users in the idle state after a failure with no explanation. Reset to idle only after giving them a visible error moment. 1500ms is enough. Any longer and it starts to feel like a separate blocked state rather than feedback.

For pages where this button sits in front of a visually rich background — say an aurora background component — make sure the error red (red-500) has enough contrast against whatever the background gradient is rendering at that moment. Dynamic backgrounds make static color decisions harder.

Dropping It Into Empire UI Styles

Empire UI ships 40 visual styles, and this button component is designed to inherit whichever style context it's placed in. The indigo-to-emerald color swap works in most of them. For styles like Glassmorphism or Neon, you'll want to swap the solid backgrounds for their respective variants.

For Glassmorphism: replace bg-indigo-600 with bg-white/10 backdrop-blur-md border border-white/20 and change the success state to bg-emerald-400/20 border-emerald-400/40. The spinner border should use rgba(255,255,255,0.15) for the ghost track in that style context.

The component accepts children and style overrides via className merging if you're using clsx or cn from shadcn. You don't need to fork it per style — pass the color overrides as a prop and let the base component handle the state logic. Keep the animation CSS shared across all variants.

One real-world thing worth knowing: on mobile Safari, transition-all on a button with disabled can cause a flicker on the first interaction. Scope the transition to specific properties — transition-colors duration-200 — instead of all. That 200ms is enough for the color change to feel smooth without overshooting.

FAQ

How do I prevent double-submit if the user clicks the button before it visually updates?

The component guards against this at two levels: the if (phase !== 'idle') return early return in the handler, and the disabled={phase !== 'idle'} HTML attribute. The disabled attribute takes effect synchronously on the next render after the first click sets phase to 'loading', which in React 19 is nearly immediate. In practice this is enough, but for critical financial actions you should also debounce or deduplicate on the server side.

What's the right stroke-dasharray value if I use a custom checkmark path?

Open the browser console, select your SVG path element, and run document.querySelector('.check-path').getTotalLength(). That gives you the exact path length in SVG user units. Set both stroke-dasharray and the initial stroke-dashoffset to that value. The animation then interpolates dashoffset to 0, which reveals the stroke progressively.

Can I use this component with React Query or SWR instead of a manual onSubmit promise?

Yes. Pass a function that triggers your mutation and returns the promise. With React Query v5 you'd do something like onSubmit={() => mutateAsync(formData)}. The component just needs a function that returns a Promise — it doesn't care what's inside. For optimistic updates you'll want to handle the phase externally via the mutation's isPending and isSuccess states instead.

The checkmark animation doesn't run on the first trigger in development mode. Why?

React 18+ StrictMode double-invokes effects, which can cause the CSS animation to be already-complete when the component mounts in dev. This doesn't happen in production. If it bothers you in dev, add a small key prop on the Checkmark component that changes with each success phase entry — this forces a fresh mount and restarts the animation.

How do I handle a timeout — what if the async operation takes more than 30 seconds?

Wrap your onSubmit promise with a race against a timeout promise. Something like Promise.race([onSubmit(), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000))]). The catch block in the component resets to idle, and you can surface the error through a separate error state or toast notification.

Does the sr-only class in Tailwind actually work for all screen readers?

Yes — the sr-only class uses clip, position absolute, and 1px dimensions rather than display:none or visibility:hidden, both of which remove content from the accessibility tree. The aria-live='polite' region will be announced by NVDA, JAWS, and VoiceOver. Test with VoiceOver on Safari (macOS) and TalkBack on Android Chrome if you need cross-platform confidence.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS 3D Transforms: Perspective, rotateX, Card Flip GalleryMagnetic Button Hover Effect: CSS + JS Cursor-Following AnimationTailwind Loading States: Spinner, Skeleton, Shimmer PatternsAurora UI Effects: Animated Northern Lights in CSS and React