GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Loading Button UI — 9 States, Spinner to Success

Among nine loading button patterns, one turns its spinner into a checkmark, one swaps the label for three pulsing dots, and one slides the label to the next

Auto-plays · click a tile to jump to its section · all nine in one zip

This toolkit gathers nine of them in one row of buttons: a spinner swap, a progress fill, a checkmark morph, an error shake, an icon-only collapse, a three-dot pulse, a dim-and-lock, a multi-step caption cycle, and a reduced-motion static dot. Getting a loading button design right is more subtle than it looks — each one below is driven by a small, named calculation function you can read start to finish in the code section, not a library or a single CSS animation shorthand.

Demo

Button Whose Label Crossfades Into a Spinner Icon
Open in a new tab

01 · Button Whose Label Crossfades Into a Spinner Icon — Measured area 0.503% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

lockWidthspinLooparia-busy

Pattern 01 above needs no separate library to lock its width while it waits — a single getBoundingClientRect read before the label fades out is enough. Scroll for the remaining eight, each paired with its own code block just under its demo.

Anatomy of nine states — where the one layer of calculation runs

# Pattern Cumulative area Avg. intensity Frames moved
01 Label-to-spinner crossfade 0.50% 98.6 20 of 23
02 Progress-fill sweep 5.05% 75.9 14 of 23
03 Spinner-to-check morph 5.07% 90.8 15 of 23
04 Shake and revert 0.73% 97.9 16 of 23
05 Icon-only collapse 4.39% 168.1 13 of 23
06 Three-dot pulse 0.21% 91.9 20 of 23
07 Dim and lock 3.32% 32.0 10 of 23
08 Multi-step captions 0.79% 161.3 10 of 23
09 Reduced-motion static dot 0.59% 91.9 10 of 23

Pattern 03's checkmark morph covers the most screen area of the nine at 5.07%, just 0.02 points ahead of pattern 02's progress fill at 5.05%; pattern 06's dot pulse covers the least, at 0.21%. Pattern 05's icon-only collapse posts the highest average intensity at 168.1 — a scaleX collapse and its inverse-scaled icon both moving at once reads as a sharp, concentrated change — while pattern 07's dim-and-lock sits at the other end with the lowest average intensity, 32.0, since its slow film-opacity pulse shifts very few pixels by very little each frame. Patterns 01 and 06 each moved in the most of the 23 measured frame pairs, 20 apiece, while 07, 08, and 09 each settle for the fewest, 10.

Eased progress driving one shared value — 02, 03, 05

Patterns 02, 03, and 05 each run a single requestAnimationFrame loop that computes one eased progress value p every frame and spends it on more than one property at once. drive (02) runs p through easeOutCubic and writes it straight into the fill's scaleX and the percentage label in the same tick. morph (03) spends the same p on four properties together — ring opacity, check opacity, stroke-dashoffset, and a background tint — which is what makes the shape read as continuously reshaping rather than swapping icons. driveScale (05) turns p into a scaleX for the button and an inverse 1 / scale for its icon in the same step, so the icon never gets visually squeezed while the button itself narrows.

Continuous periodic waveforms — 04, 06, 07

Patterns 04, 06, and 07 don't ease toward a target at all — they keep computing a sine wave for as long as the loop runs. shake (04) multiplies a decaying exponential by Math.sin so the oscillation's amplitude falls off over time instead of stopping abruptly. pulseLoop (06) offsets each dot's Math.sin call by PHASE_OFFSET_RAD times its index, so three dots share one formula but never move in sync. pulseLoop (07) is the most involved of the three: because its frequency itself ramps from BASE_HZ to MAX_HZ as the cooldown runs out, sin(2π·f·t) can't be used directly, so phase is integrated frame by frame instead.

Discrete measured state swaps — 01, 08, 09

Patterns 01, 08, and 09 skip continuous animation curves in favor of measuring something once and then flipping between two or three fixed states. lockWidth (01) reads the button's current rendered width with getBoundingClientRect before the label fades out, so the button can't reflow narrower while its spinner spins. measureOffsets (08) reads every step caption's real offsetTop instead of assuming a fixed line height, so the track always slides by exactly as much as the stacked captions actually take up. Pattern 09 measures nothing about layout at all — it reads matchMedia('(prefers-reduced-motion: reduce)') once, and a role="switch" toggle then lets a visitor flip between both states by hand regardless of what the OS reports.

Code per pattern

Every snippet below is lifted straight from its own demo's script — nothing shortened, nothing renamed for readability.

01Button Whose Label Crossfades Into a Spinner Icon

The instant the button is pressed, its label fades out while a small circular spinner fades in at the same spot and keeps turning, with the button's width staying fixed throughout. Reach for it on a form submit button while it waits for a server response.

Button Whose Label Crossfades Into a Spinner Icon
Open in a new tab

01 · Button Whose Label Crossfades Into a Spinner Icon — Measured area 0.503% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

lockWidthspinLooparia-busy
  function lockWidth() {
    var w = btn.getBoundingClientRect().width;
    btn.style.width = w + 'px';
  }
  function unlockWidth() { btn.style.width = ''; }

  function startLoad(myGen) {
    lockWidth();
    btn.disabled = true;
    btn.setAttribute('aria-busy', 'true');
    status.textContent = 'Saving';
    fade(label, 1, 0, FADE_MS, myGen);
    fade(spinnerWrap, 0, 1, FADE_MS, myGen, function () { spinLoop(myGen); });
  }
  function finishLoad(myGen) {
    cancelAnimationFrame(rafId);
    fade(spinnerWrap, 1, 0, FADE_MS, myGen);
    fade(label, 0, 1, FADE_MS, myGen, function () {
      btn.disabled = false;
      btn.removeAttribute('aria-busy');
      status.textContent = 'Saved';
      unlockWidth();
    });
  }

02Button Background That Fills Like a Mini Progress Bar

After the button is pressed, a pale fill sweeps its background from left to right to show progress, pauses briefly once it reaches the far edge, then moves on to the next state. It's built for showing progress on a file upload or a long-running task.

Button Background That Fills Like a Mini Progress Bar
Open in a new tab

02 · Button Background That Fills Like a Mini Progress Bar — Measured area 5.052% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

easeOutCubictransform-originscaleX
  // Calculation layer: the fill bar only ever grows via scaleX (transform-origin: left — an exception
  // for growing from its start edge, allowed because it's a fill bar). Progress p is eased with
  // easeOutCubic every frame and the same value drives both style.transform and the percent label.
  function drive(myGen, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var raw = Math.min(1, (now - start) / FILL_MS);
      var p = easeOutCubic(raw);
      fill.style.transform = 'scaleX(' + p.toFixed(4) + ')';
      text.textContent = 'Uploading ' + Math.round(p * 100) + '%';
      if (raw < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }

03Button Whose Spinner Morphs Into a Success Checkmark

The moment the task finishes, the spinning ring smoothly reshapes itself into a checkmark as though drawing a line, and the button's background tints along with it. Use it for confirming a save or payment completed, right inside the button.

Button Whose Spinner Morphs Into a Success Checkmark
Open in a new tab

03 · Button Whose Spinner Morphs Into a Success Checkmark — Measured area 5.073% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

getTotalLengthstrokeDashoffsetspinLoop
  // One rAF loop drives ring opacity, check opacity, check draw length, and tint opacity all at once
  // from the same progress p, so the shape reads as continuously morphing rather than swapping.
  function morph(myGen, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var p = easeOutCubic(Math.min(1, (now - start) / DRAW_MS));
      ringWrap.style.opacity = String(1 - p);
      checkWrap.style.opacity = String(p);
      checkPath.style.strokeDashoffset = String(checkLen * (1 - p));
      tint.style.opacity = String(p * .5);
      if (p < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }

04Button That Shakes Briefly on Failure Then Reverts to Its Label

When the spinner hits an error it turns into an X icon while the whole button shakes side to side briefly, then the shaking settles and the original label text returns. Reach for it to prompt a retry after a submission fails from a network error.

Button That Shakes Briefly on Failure Then Reverts to Its Label
Open in a new tab

04 · Button That Shakes Briefly on Failure Then Reverts to Its Label — Measured area 0.726% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sintranslateXaria-live
  // Calculation layer: a damped sine wave x(t) = A · e^(-decay·t) · sin(2π·freq·t) is computed every
  // frame and applied to the whole button's translateX — not a fixed keyframe, an actual physical value.
  function shake(myGen, ms, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var elapsed = now - start;
      var tSec = elapsed / 1000;
      var x = SHAKE_AMPLITUDE_PX * Math.exp(-SHAKE_DECAY_PER_S * tSec) * Math.sin(2 * Math.PI * SHAKE_FREQ_HZ * tSec);
      btn.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
      if (elapsed < ms) requestAnimationFrame(step);
      else { btn.style.transform = 'translateX(0px)'; if (onDone) onDone(); }
    }
    requestAnimationFrame(step);
  }

05Button That Collapses to Icon-Only Width While Loading

Pressing the button slides its text label away while the button narrows down to just enough width for a single round icon, then widens back out with the label returning once the task ends. It's meant for saving space for a button in a narrow toolbar or on a mobile screen.

Button That Collapses to Icon-Only Width While Loading
Open in a new tab

05 · Button That Collapses to Icon-Only Width While Loading — Measured area 4.394% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

driveScalegetBoundingClientRectscaleX
  // Calculation layer: measures the button's current rendered width (fullWidth) against the target width
  // for a single icon to get collapsedRatio, then scales btn by that value every frame while applying
  // an inverse 1/scale to the icon so it doesn't get squeezed while the button narrows.
  function driveScale(myGen, fromScale, toScale, ms, onDone) {
    var fullWidth = btn.getBoundingClientRect().width / fromScale;
    var collapsedRatio = COLLAPSED_WIDTH_PX / fullWidth;
    var resolvedFrom = fromScale === 1 ? 1 : collapsedRatio;
    var resolvedTo = toScale === 1 ? 1 : collapsedRatio;
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var p = easeInOutQuad(Math.min(1, (now - start) / ms));
      var scale = resolvedFrom + (resolvedTo - resolvedFrom) * p;
      btn.style.transform = 'scaleX(' + scale.toFixed(4) + ')';
      icon.style.transform = 'scaleX(' + (1 / scale).toFixed(4) + ')';
      if (p < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }

06Button Whose Label Is Replaced by Three Pulsing Dots

Once pressed, three small dots line up where the text used to be, and they grow and shrink one after another from left to right to signal that work is still in progress. It suits indicating a wait for something quick, like sending a chat message or a short API call.

Button Whose Label Is Replaced by Three Pulsing Dots
Open in a new tab

06 · Button Whose Label Is Replaced by Three Pulsing Dots — Measured area 0.207% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

pulseLoopMath.sinPHASE_OFFSET_RAD
  // Calculation layer: each dot gets its own phase in scale_i(t) = 1 + A·sin(2π·t/period − i·phaseOffset),
  // computed every frame — not staggered with animation-delay, each dot is calculated directly every tick.
  function pulseLoop(myGen) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var t = now - start;
      for (var i = 0; i < dotEls.length; i++) {
        var s = 1 + AMPLITUDE * Math.sin((2 * Math.PI * t) / PULSE_PERIOD_MS - i * PHASE_OFFSET_RAD);
        dotEls[i].style.transform = 'scale(' + s.toFixed(3) + ')';
      }
      rafId = requestAnimationFrame(step);
    }
    rafId = requestAnimationFrame(step);
  }

07Button That Dims and Locks Out Clicks While Loading

One press dims the button's background and lays a translucent film over it so further clicks do nothing, while that dim tone very slowly brightens and darkens in a loop to signal it's still waiting. Use it to prevent a double click from firing the same request twice.

Button That Dims and Locks Out Clicks While Loading
Open in a new tab

07 · Button That Dims and Locks Out Clicks While Loading — Measured area 3.318% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

BASE_HZrequestAnimationFramearia-busy
  // Calculation layer: as the remaining time shrinks, the frequency ramps from BASE_HZ to MAX_HZ. Since
  // frequency changes over time, sin(2π·f·t) can't be used directly, so phase is integrated frame by
  // frame instead (phase += 2π·f(now)·dt) — the dim film blinks faster the less time is left.
  function pulseLoop(myGen) {
    var last = null;
    var phase = 0;
    var loopStart = null;
    function step(now) {
      if (myGen !== gen) return;
      if (last === null) { last = now; loopStart = now; }
      var dt = now - last;
      last = now;
      var elapsed = now - loopStart;
      var remainingFrac = clamp01(1 - elapsed / COOLDOWN_MS);
      var freq = BASE_HZ + (MAX_HZ - BASE_HZ) * (1 - remainingFrac);
      phase += 2 * Math.PI * freq * (dt / 1000);
      var wave = (1 + Math.sin(phase)) / 2;
      film.style.opacity = String(.14 + .16 * wave);
      rafId = requestAnimationFrame(step);
    }
    rafId = requestAnimationFrame(step);
  }

08Button Whose Label Text Cycles Through Steps While Loading

After the button is pressed, its label starts at one phrase and slides up to the next at set intervals, moving through several step captions in turn — spelling out which stage things are at instead of leaving that to a single spinner. It narrates progress through a long submission that passes through several stages.

Button Whose Label Text Cycles Through Steps While Loading
Open in a new tab

08 · Button Whose Label Text Cycles Through Steps While Loading — Measured area 0.785% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

measureOffsetsoffsetTopslideTo
  // Calculation layer: each step caption's offset is measured with offsetTop instead of guessed — it only
  // shifts up by however tall the captions stacked above it actually render, so line-height changes never misalign it.
  function measureOffsets() {
    return stepEls.map(function (el) { return el.offsetTop; });
  }

  function fade(el, from, to, ms, myGen, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var p = Math.min(1, (now - start) / ms);
      el.style.opacity = String(from + (to - from) * easeInOutQuad(p));
      if (p < 1) requestAnimationFrame(step); else if (onDone) onDone();
    }
    requestAnimationFrame(step);
  }

09Loading Button That Freezes to a Single Static Dot for Reduced Motion

When the system's reduce-motion setting is on, the button skips the spinning ring and instead leaves a single steady dot beside its label, removing that dot only the instant loading ends and the result state takes over. It works as an alternative loading indicator for users sensitive to vestibular motion or dizziness.

Loading Button That Freezes to a Single Static Dot for Reduced Motion
Open in a new tab

09 · Loading Button That Freezes to a Single Static Dot for Reduced Motion — Measured area 0.588% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

matchMediarole=switcharia-checked
  function setToggleUI() {
    toggle.setAttribute('aria-checked', simReduce ? 'true' : 'false');
    knob.style.transform = simReduce ? 'translateX(16px)' : 'translateX(0px)';
  }

  function applyToggle(next) {
    simReduce = next;
    setToggleUI();
  }

  // Simulated toggle — lets a visitor preview both states regardless of the real OS setting.
  toggle.addEventListener('click', function () {
    human();
    applyToggle(!simReduce);
  });

  if (osReduce) setToggleUI();

  // Real-use path — when the actual OS value changes (browser/OS setting), the simulated toggle follows it.
  function onOsChange(e) {
    osReduce = e.matches;
    applyToggle(osReduce);
  }
  if (mql.addEventListener) mql.addEventListener('change', onOsChange);

Where values change the UI — duration, shake amplitude, step-caption interval

04 — SHAKE_MS

This constant sets how long shake() keeps running each time pattern 04 hits an error — not the wave's frequency or amplitude, just the window during which the damped sine has time to decay back toward zero before the button settles and reverts to its label.

08 — STEP_INTERVAL_MS

This is how long tick() in pattern 08 waits between one step caption sliding out and the next one taking over — not the slide's own duration, just how long each caption stays put and readable before the sequence advances.

02 — HOLD_AT_FULL_MS

This is how long pattern 02 leaves its fill sitting at a full scaleX(1) before moving on to the next state — a pause that has nothing to do with the fill's own easeOutCubic curve, just how long the completed bar stays visible.

Where it breaks — accessibility, reduced-motion, double clicks

All nine patterns share the same baseline: pressing the button sets btn.disabled = true and aria-busy="true" together, and the status text each one updates sits inside a role="status" aria-live="polite" region, so a screen reader hears every status change — Saving, Uploading, Processing — without needing to refocus anything. btn.disabled alone already blocks a second click and pulls the button out of the tab order, but pattern 07 layers a translucent film on top of that lockout specifically because a plain disabled state can read as too subtle to notice at a glance — the film makes the wait visible, not merely technically prevented. Pattern 09 is the only one that adds a second interactive control, a role="switch" toggle wired to aria-checked, since its whole premise is letting a visitor flip between the reduced and animated state by hand rather than waiting for the OS setting to change. All of that aria wiring, along with every timer this section covers, ships in the zip below.

  • 01 — the label-to-spinner crossfade skips straight to the spinner shown at full opacity, then straight back to the label at full opacity on completion; nothing fades in between.
  • 02 — the fill jumps straight to scaleX(1) at "Uploaded" instead of sweeping there, then fades out in one step.
  • 03 — the ring-to-check morph is skipped entirely; the ring disappears and the check appears already fully drawn, with the tint already at its end value.
  • 04 — the shake itself never runs; the X icon simply appears in place of the spinner, then the label reappears once the error hold time is over.
  • 05 — the width collapses straight to COLLAPSED_WIDTH_PX via one scaleX instead of easing there, and returns to full width the same way.
  • 06 — the three dots appear and disappear at fixed opacity instead of pulsing through their phase-offset sine wave.
  • 07 — the dim film and lock icon jump straight to their held opacity instead of the slow ramping brighten-darken loop.
  • 08 — the step captions still change in order, each STEP_STATUS entry replacing the last, but the track no longer slides between them frame by frame — it's the one pattern whose runReduced keeps a timer loop instead of collapsing to a single setTimeout, since the steps still need to be read one at a time.
  • 09 — this pattern's entire premise is the reduce-versus-animated difference, so nothing is removed for reduced motion; the reduced state is simply one of its two states, and the demo's own toggle lets a visitor preview both regardless of what the OS reports.

Reading that zip end to end, unlocked with the password g7cf8ma6, is the only way to see exactly which timer each pattern above swaps out for reduced motion — the damped shake math, the phase-offset dot pulse, and the offset-measured step slide included.

Three variants and accessibility

Variant Changed value What it feels like
Lengthen the shake duration 04 SHAKE_MS 520 → 900 Lengthening the error-shake duration (520ms to 900ms) keeps the button oscillating longer, making the failure feel more emphatic.
Narrow the step-caption interval 08 STEP_INTERVAL_MS 450 → 260 Narrowing the interval between step captions (450ms to 260ms) makes the whole sequence pass faster, leaving more a sense of progress than time to read each step.
Extend the pause after the progress fill completes 02 HOLD_AT_FULL_MS 500 → 1400 Extending the pause after the fill reaches the end (500ms to 1400ms) gives more time to visually register completion, but the whole flow feels correspondingly slower.

All three of these constants only change timing, never the aria wiring above — aria-busy, role="status", and the aria-live="polite" region stay exactly where they are in patterns 02, 04, and 08 no matter how far those numbers get tuned.

Where can I see loading button examples?

The clearest place to study a loading button animation in the wild is any payment or file-upload flow — a button that submits a form and then visibly waits is one of the few loading states nearly every production site ships. Component libraries and dashboard kits tend to reach for only one of the nine approaches above, usually the spinner swap or the disabled dim, so seeing several side by side is the fastest way to judge which timing and which layer of calculation fits a given form. Everything on this page is deliberately reusable outside a demo, too — the code blocks above are copied straight from working buttons, not written for illustration, so a state that fits your form's server response time is one copy away.

FAQ

Why do all nine patterns set aria-busy and disabled together instead of picking one?

They do different jobs. disabled is what actually stops a second click and pulls the button out of the tab order; aria-busy only tells assistive technology that the element's content is in the middle of changing. Dropping either one leaves a gap — a button that's only aria-busy can still be clicked twice, and one that's only disabled gives a screen reader no warning that its label is about to change out from under it.

Does pattern 01's width lock ever cause a layout shift?

No — that's exactly what lockWidth() is for. It reads the button's current rendered width with getBoundingClientRect and pins it as an inline style before the label starts fading out, so the shorter spinner icon that replaces the label can never let the button narrow and reflow whatever sits next to it.

Are these loading button examples safe to drop into a real production form?

Yes, with one caveat: every timer in this set is driven by setTimeout and requestAnimationFrame standing in for a real network response, so swap those simulated waits for your actual fetch or form-submission promise, keep the aria-busy/disabled pairing and the aria-live status region exactly as they are, and the pattern works the same outside the demo page.

Sources: Using CSS transitions · button: disabled · aria-busy · prefers-reduced-motion · CSS animation

Related reading: 9 CSS Button Animations (SCSS + React, copy-paste) · 9 CSS Loading Spinners: Copy-Paste, Single Element · 9 button hover effects css that award sites reuse

Enter the archive password

The password is inside this article. You will find it as you read.