Forms✨ Premium

Submit Button States

Submit button that shows your server's real answer: a spinner while waiting, a green tick when the send succeeds, a red cross when it fails.

JSAnimationStates

Updated

This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Forms has 20 effects, including 2 free. Explore the category →

3 usage examples

Offering a carpool ride — form sent with the Enter key — Submit Button States example 1

① Offering a carpool ride — form sent with the Enter key

WhenThe driver has filled in from, to, time and seats, then presses Enter in the To field. The browser first checks the required fields; the wheel then spins while the server saves the listing, the green tick shows and "Ride online" appears under the button.
WhyThis is the most direct use of the effect: a real form, a submit button, native validation before sending. No need to aim at the button, Enter is enough, and the button still shows that the listing went through.
Settingstype="submit" button in a real <form> with required fields, green gradient, data-sbs-reset="0" to keep the tick. onSend returns a promise resolved after 0.65 s, standing in for the real request in the demo; the confirmation line is shown by the scene in the then. In the capture, the click outside the button is replayed as the Enter key (form.requestSubmit()).
"Pay" declined then retried in a payment summary — Submit Button States example 2

② "Pay" declined then retried in a payment summary

WhenThe customer clicks "Pay €48.00": the bank declines, the button turns red with a cross and a message explains what to do. They try again: this time the green tick confirms and the order number appears.
WhyThe same button tells the failure and then the success: the customer knows at once whether the payment went through, and the red state does not block the second try.
SettingsFull-width button inside a real <form> (type="submit"), blue gradient, data-sbs-reset="1600". The scene plays the bank: the promise is rejected on the 1st send and resolved on the 2nd. The message under the button is handled by the scene in the promise's then.
"Sync" on a dashboard that updates itself — Submit Button States example 3

③ "Sync" on a dashboard that updates itself

WhenOn a sales dashboard, the small button at the top right starts on its own at regular intervals: a wheel while fetching, a green tick when the figures are up to date, then back to "Sync".
WhyThe button doubles as a status light: you see that an update is running and that it worked, with no extra indicator. Visitors can also click to force a sync.
SettingsPill button (112 px wide, 12 px, 16 px icons), data-sbs-reset="700". The scene calls root.fx0290.run(sync) every 2.2 s; sync waits 0.8 s then updates the revenue figure.

How it works

The button fakes nothing: it waits for you to connect your send. On load, every .fx-0290-root block gets a root.fx0290 object. Give it a function that returns a promise, for example root.fx0290.onSend(() => fetch('/api/contact', { method: 'POST', body: new FormData(form) }).then(r => { if (!r.ok) throw r; })). On click, the button goes to loading (sbs-loading class: text hidden, spinning wheel); promise resolved → sbs-success (green, tick); promise rejected → sbs-error (red, cross).

Inside a <form> with type="submit", the button lets the browser check the fields first (required, type="email"…): your function only runs on the submit event, which is then cancelled so the page does not reload. Outside a form, use type="button": the click triggers it. With no function connected, the button keeps its normal behaviour (a submit button sends the form).

After a success or a failure, the button goes back to rest after data-sbs-reset milliseconds (2000 by default, 0 to keep the state). A new click after an error sends again; a click while loading is ignored, so there is no double send. You can also drive the state yourself: root.fx0290.run(promise) starts a loading from your code, root.fx0290.setState('loading' | 'success' | 'error' | 'default') forces a state.

Accessibility

  • Focus kept: while loading, the button gets aria-disabled="true" instead of disabled. It therefore keeps keyboard focus (a disabled button sends focus back to the top of the page); repeated clicks are ignored by the script.
  • State announced: an invisible role="status" area reads the text of data-sbs-loading, data-sbs-success or data-sbs-error ("Sending…", "Sent", "Sending failed, please try again"). The button's name stays "Send" even when its text is hidden on screen.
  • Keyboard: a real <button>, Enter and Space work; a light ring shows on keyboard focus (:focus-visible).
  • Colour and shape: success and error do not rely on green and red alone: tick or cross, plus the announced message. Also show a visible message under the button saying what to do after a failure.
  • Reduced motion: no lift on hover and no fade; the wheel, the only visual sign of waiting, spins 2.4 times slower.

Browser compatibility

Dependency-free JavaScript (Promise, closest, classList) and inline SVG. Measured in Chromium 151, Firefox 153 and WebKit 26.5 (Playwright): two copies, one inside a form with a required field, success, failure then a new try by touch, Enter from the keyboard with focus staying on the button, reduced motion, no name added to window, 0 errors.

Chrome 86+✓ Full (measured)
Firefox 85+✓ Full (measured)
Safari 15.4+✓ Full (measured in WebKit)
Edge 86+✓ Full (Chromium engine)
Mobile iOS 15.4+✓ WebKit engine, not checked on a device
Android Chrome 86+✓ Full (touch measured)

Without JavaScript, it is a normal button: inside a form, it sends the form. The focus ring needs :focus-visible (Chrome 86, Firefox 85, Safari 15.4).

The code

HTML structure to paste into your page (the CSS + JS come with your purchase):

index.html — structure
<div class="fx-0290-root" data-sbs-loading="Sending…" data-sbs-success="Sent" data-sbs-error="Sending failed, please try again" data-sbs-reset="2000">
  <button type="submit" class="sbs-btn">
    <span class="sbs-text">Send</span>
    <span class="sbs-loader" aria-hidden="true"><svg>…</svg></span>
    <span class="sbs-icon sbs-ok" aria-hidden="true"><svg>…</svg></span>
    <span class="sbs-icon sbs-ko" aria-hidden="true"><svg>…</svg></span>
  </button>
  <span class="sbs-status" role="status" aria-live="polite"></span>
</div>

One-time payment, no subscription — the code is yours forever

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Connected send (JS — root.fx0290.onSend(fn)) none: the button keeps its normal behaviour fn returns a promise: resolved → success, rejected (or throw) → error. With fetch, reject when response.ok is false.
Start from your code (JS — root.fx0290.run(fn or promise)) — Goes to loading then success or error depending on the promise, and returns it so you can chain (.then).
Force a state (JS — root.fx0290.setState(state)) 'default' 'loading', 'success', 'error' or 'default', for a send handled elsewhere (WebSocket, form library).
Back to rest (HTML — data-sbs-reset on the root) 2000 ms 0 keeps the tick or the cross until the next click.
Announced messages (HTML — data-sbs-loading, data-sbs-success, data-sbs-error) Sending… · Sent · Sending failed, please try again Texts read by screen readers; adapt them to the action ("Payment declined").
Colours (CSS — .sbs-btn, .sbs-success, .sbs-error) gradient #6366f1 → #8b5cf6 · #10b981 · #ef4444 Change each state's background; keep enough contrast for the white icon.
Button type (HTML) type="submit" Keep submit inside a form (native validation first); type="button" for an action outside a form.

FAQ

Only if your function does not reject: fetch only rejects its promise on a network failure. Add .then(r => { if (!r.ok) throw r; }) so that 4xx and 5xx answers turn the button red.
Yes, if the button sits in the form with type="submit": the browser blocks the send while a required field is empty, and your function is not called. The button stays at rest.
Use root.fx0290.run(fetch(…)): it returns the same promise, with the resolved value. With onSend, handle the answer inside your function before returning it.