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.
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



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 ofdisabled. 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 ofdata-sbs-loading,data-sbs-successordata-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.
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):
<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 / property | Default | Effect |
|---|---|---|
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
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.type="submit": the browser blocks the send while a required field is empty, and your function is not called. The button stays at rest.root.fx0290.run(fetch(…)): it returns the same promise, with the resolved value. With onSend, handle the answer inside your function before returning it.