ui-019

Loading Submit Button

£10 or the whole Vault, £99

A loading submit button for any button: pass it a promise and it morphs into a spinner, then a drawn checkmark or an error shake, announced to screen readers.

intermediate
4 more details
micro-interactionmorphingsvg-strokeform-animation

This element reads better at your own screen size than in the frame below:

Updated

About this component

A loading button that answers the click instead of freezing. Attach it to any button and run a promise through it: the label lifts away, the button narrows into a circular spinner, and when the promise settles it expands into a success fill with a checkmark drawn stroke-first, or shakes into an error label and returns to its original text.

Read the full effect overview

Widths are measured for every label, so success and error labels of any length fit, and the button's own box never changes size, so nothing around it shifts. A form path runs a submit handler that returns a promise. Vanilla JavaScript and GSAP core, no framework and no DrawSVG. The demo shows a save that succeeds, a sync that fails, a slow publish and a real payment form, in light and dark themes.

Loading Submit Button - UI component preview

What's included

10 items
  • LoadingButton.attach(button) on any button, returning run, reset and destroy
  • run() takes a promise and resolves or rejects with its result
  • Width re-measured for every label and kept through font loads and resizes
  • No layout shift: the button's box holds still while its surface morphs
  • Double clicks ignored, disabled and aria-busy for the whole request
  • Success or error text announced through a polite live region
  • Declarative form path with data-loading-button and one submit handler
  • Checkmark drawn by tweening strokeDashoffset, no DrawSVG plugin needed
  • Reduced motion switches states instantly instead of animating
  • Light and dark themes, every colour a custom property in one block

Perfect for

4 use cases
  • Checkout and payment forms where the wait needs covering
  • Save, sync and publish buttons in dashboards and admin tools
  • Sign-up, newsletter and waitlist captures on marketing pages
  • Any async action that currently shows a spinner beside the button

How it works

4 sections

A fixed box and a morphing surface

The button keeps an invisible copy of its idle label in flow, so its layout box never changes size and nothing beside it moves. The visible pill is an absolutely positioned surface centred inside it, and that is what GSAP tweens: width, border-radius, background, and x for the error shake.

Width measured for every label

Each state's width is measured as it is entered, from a hidden span holding the label text plus the check and padding. The spinner is the button's height with half of it as radius. A stretched button keeps its full width, and a ResizeObserver plus fonts.ready re-measure a held outcome.

Drawing the checkmark

The tick is one SVG path. Its length is read with getTotalLength, then strokeDasharray and strokeDashoffset are both set to that length so the stroke is fully retracted. GSAP tweens strokeDashoffset to 0 and the line draws itself, with no plugin.

State machine and accessibility

run() only starts from idle, so a second click returns the in-flight promise. The button carries disabled and aria-busy for the whole request, and a polite live region announces the success message or the error text. Every tween lives in one gsap.context that destroy() reverts.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Loading Submit Button opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page, three wired buttons and a real form

  • assets/style.css: component styles, every colour a plain CSS custom property

  • assets/script.js: readable, commented source; one global, window.LoadingButton

  • LoadingButton.attach(button, options) on any button, as many per page as you like

  • run(promise) drives the whole sequence and resolves or rejects with the promise's own result

  • Width re-measured for every label, including success and error labels of different lengths, and kept correct through font loads and resizes

  • The button's layout box never changes size, so nothing around it shifts

  • A second click while busy does nothing; disabled and aria-busy cover the whole request

  • A polite live region announces the success message or the error text

  • Declarative form path: data-loading-button on a submit button plus one handler

  • Checkmark drawn by tweening strokeDashoffset, no DrawSVG plugin needed

  • destroy() reverts every inline style, removes its listeners and restores the original markup

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Use any button. Its existing content (text, an icon, both) becomes the idle label:

Code snippet omitted: it ships with the download.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

4. Attach it and run your request through it:

Code snippet omitted: it ships with the download.

Anything that rejects is an error: a thrown Error's message is what the live region announces unless you set errorMessage.

A form, declaratively

Put data-loading-button on the submit button and give it a handler that returns a promise. The script attaches every [data-loading-button] on load, the browser validates the form first, and the handler receives the submit event and the form's FormData:

Code snippet omitted: it ships with the download.

Code snippet omitted: it ships with the download.

attach() on an already attached button returns the same instance, so this is safe whether it runs before or after the automatic attach. A data-loading-button with no handler leaves the form to submit normally.

Demo furniture. The row the three buttons sit in (.stage-row), the payment card (.pay-card) and its fields, the ground (.stage) and the toolbar strip are the demo's scaffolding, not part of what you bought. Take the button classes and the script.

API

LoadingButton.attach(button, options?)

Returns an instance. Options (each also readable from a data- attribute on the button; options win):

Option Attribute Default Description
successLabel data-success-label '' Label shown beside the drawn check. Empty shows the check alone in a circle. May be a function of the resolved value
errorLabel data-error-label 'Try again' Label shown on the error state. May be a function of the error
pendingMessage data-pending-message '' Announced when the request starts. Empty announces nothing (aria-busy still applies)
successMessage data-success-message the success label, or 'Done' Announced on success. May be a function of the value
errorMessage data-error-message error.message, or the error label Announced on error. May be a function of the error
hold data-hold 1.6 Seconds the outcome is held before returning to idle. false holds it until reset()
minPending data-min-pending 0 Minimum seconds the spinner shows, for requests that return too fast to read
status data-loading-status a hidden live region Element or selector for a visible status line. Given role="status" and aria-live="polite" if it lacks them
onSubmit null (event, formData) => promise. When set, the button's form submits through run()

Instance

Member Description
run(promiseOrFn) Starts idle, spinner, then success or error. Accepts a promise or a function returning one (a thrown error counts as a rejection). Returns a promise that settles with the original result once the outcome has been shown. While busy (pending, outcome or resetting) a second call starts nothing and returns the in-flight promise
reset() Returns to idle now, from any state. A request still in flight keeps settling the promise run() returned, but no longer drives the button
destroy() Kills and reverts every tween, removes listeners and the created live region, restores the button's original children, disabled state and classes
onSubmit(handler) Sets the form handler; returns the instance
setOptions(options) Merges new options
state 'idle', 'pending', 'success', 'error' or 'resetting'

LoadingButton.get(button) returns an attached instance or null. LoadingButton.attachAll(root?) attaches every [data-loading-button] under root.

Events

Dispatched on the button, bubbling: loadingbutton:pending, loadingbutton:success (detail.value), loadingbutton:error (detail.error), loadingbutton:reset.

Changed from the first release

The first release was bound to three ids (morphForm, morphBtn, morphStatus) and faked its request with REQUEST_MS. Those ids no longer do anything on their own: call LoadingButton.attach() on the button, or add data-loading-button. The old .morph-content / .morph-label markup is still accepted, and its label becomes the idle content. --btn-w now defaults to auto so a button hugs its label; add morph-btn--block (or set --btn-w: 100%) for the previous full-width look.

How It Works

A fixed box and a morphing surface. attach() keeps an invisible copy of the idle label in the button's flow (.morph-sizer), so the <button> holds its idle size for its whole life and nothing beside it moves. The visible pill is .morph-surface, absolutely positioned and centred inside it, and that is what GSAP tweens: width, border-radius, background, and x for the shake. A success label longer than the idle one widens the surface over its margins instead of pushing neighbours; leave room beside the button for your longest outcome label.

Width, not scale. Every state's width is measured at the moment it is entered: the label text is written into a hidden measuring span and its offsetWidth read, plus the check and the button's padding. The spinner pill is the button's own height with a radius of half of it, so it lands on a circle whatever the resting radius is. A button stretched wider than its label (width: 100%, a grid cell) keeps its full width in every state. A ResizeObserver and document.fonts.ready re-measure an outcome state while it is held, so a late web font or a resize never leaves the pill the wrong size.

Drawing the checkmark. The tick is one SVG path. Its length is read with getTotalLength(), then strokeDasharray and strokeDashoffset are both set to that length so the stroke is fully retracted. GSAP tweens strokeDashoffset to 0 and the line draws itself.

Colours come from the theme. The idle, success and error fills are read from --btn-bg, --btn-success-bg and --btn-error-bg on the button itself with getComputedStyle as each tween starts, so a theme on any ancestor, or a modifier class on the button, is honoured.

State machine. run() refuses to start unless the instance is idle. Each run has an id; reset() and destroy() advance it, so a late response cannot drive a button that has moved on. All tweens are created inside one gsap.context, which destroy() reverts.

Themes

The component ships in two themes, light (the default) and dark: one design at two sets of token values. Set the attribute on <body>, or on any ancestor of the component in your own app:

Code snippet omitted: it ships with the download.

Nothing else changes: same markup, same script, and no theme name is ever read in JavaScript. The demo's toolbar toggle and the ?variant=dark URL parameter only set that attribute.

Every colour, radius, shadow and font the component and the demo ground use is a custom property in the body[data-variant="light"] and body[data-variant="dark"] blocks at the top of assets/style.css. To wear your own brand, re-value that one short list; the ground tokens (--ground, --raised, --ink, --line, --accent) are the same names the rest of the page uses.

Customisation

Property Where Default (light) Description
--btn-w style.css auto Resting width. auto hugs the label; morph-btn--block sets 100%
--btn-h style.css 44px Button height, and therefore the spinner pill's diameter
--btn-pad-x style.css 18px Horizontal padding, used in every width measurement
--btn-radius style.css 6px Resting corner radius
--btn-bg / --btn-fg style.css #635bff / #ffffff Idle fill and label colour
--btn-border / --btn-shadow style.css accent hairline / small shadow Surface border and elevation
--btn-success-bg / --btn-success-fg style.css #1a7f52 / #ffffff Success fill and label
--btn-error-bg / --btn-error-fg style.css #c33b32 / #ffffff Error fill and label
.morph-btn--secondary style.css Raised surface, ink label, strong hairline: a secondary action on the same tokens

Easings and durations are the gsap calls in assets/script.js (collapse 0.42s, success expand 0.45s, check draw 0.42s, error shake 0.45s). The checkmark shape is CHECK_SVG at the top of the script; change the path and the draw still works, because its length is measured at runtime.

Accessibility

  • The control stays a real <button>: keyboard operable by default, and a type="submit" button still submits its form with JavaScript switched off.
  • disabled plus aria-busy="true" cover the whole request and the held outcome; .morph-btn:disabled also sets pointer-events: none.
  • The live region is role="status" with aria-live="polite", created when you do not supply one, so the pending message (if set) and the outcome are heard without focus moving. The error announcement is the error's own text.
  • The label element carries the visible text, so the button's accessible name follows it ("Saved", "Sync failed"); the spinner, check and width sizer are aria-hidden.
  • Under prefers-reduced-motion: reduce every duration collapses to zero, read at the moment each run starts: the states still happen in order, and the outcome is still held for hold seconds so it can be read.
  • Focus is shown on the visible surface via :focus-visible, and text clears 4.5:1 against its fill in both themes.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.