Async Submit
A copy-paste form states component in pure HTML, CSS & vanilla JS. Zero dependencies, framework-agnostic, MIT-licensed.
Form StatesHTMLCSSJavaScriptany framework
Copy into your project
HTML
<!-- Async Submit (toggle .is-loading then .is-success from JS) -->
<button class="nuda-async" type="button">
<span class="nuda-async__label">Submit</span>
<span class="nuda-async__spinner"></span>
<svg class="nuda-async__check" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12 L10 17 L20 7" />
</svg>
</button>CSS
/* Async Submit
Button morphs label → spinner → check on submit.
Customize: --async-bg, --async-text */
.nuda-async {
--async-bg: #e4ff54;
--async-text: #09090b;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 140px;
height: 42px;
padding: 0 22px;
background: var(--async-bg);
color: var(--async-text);
border: none;
border-radius: 8px;
font: 700 0.875rem ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
overflow: hidden;
transition:
width 0.35s,
min-width 0.35s,
background 0.25s,
box-shadow 0.25s;
}
.nuda-async:hover { box-shadow: 0 0 18px rgba(228, 255, 84, 0.35); }
.nuda-async__label { transition: opacity 0.2s, transform 0.25s; }
.nuda-async__spinner,
.nuda-async__check {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
transition: transform 0.3s, opacity 0.25s;
}
.nuda-async__spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(9, 9, 11, 0.2);
border-top-color: var(--async-text);
animation: nuda-async-spin 1s linear infinite;
}
.nuda-async__check { width: 20px; height: 20px; }
.nuda-async.is-loading {
min-width: 42px;
width: 42px;
padding: 0;
}
.nuda-async.is-loading .nuda-async__label { opacity: 0; transform: scale(0.8); }
.nuda-async.is-loading .nuda-async__spinner {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
.nuda-async.is-success {
min-width: 42px;
width: 42px;
padding: 0;
animation: nuda-async-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.nuda-async.is-success .nuda-async__label,
.nuda-async.is-success .nuda-async__spinner { opacity: 0; }
.nuda-async.is-success .nuda-async__check {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
@keyframes nuda-async-spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
@keyframes nuda-async-pop {
0% { transform: scale(0.95); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.nuda-async__spinner { animation: none; }
}JavaScript
/* Async Submit — drive the loading → success state machine. */
(function () {
var btn = document.querySelector('.nuda-async');
if (!btn) return;
btn.addEventListener('click', function () {
if (btn.classList.contains('is-loading') || btn.classList.contains('is-success')) return;
btn.classList.add('is-loading');
// Simulate an async request — replace with your real submit/fetch.
setTimeout(function () {
btn.classList.remove('is-loading');
btn.classList.add('is-success');
}, 1400);
// Optional: reset back to idle.
setTimeout(function () {
btn.classList.remove('is-success');
}, 3400);
});
})();How to use Async Submit
Paste the HTML where you need it and the CSS into a global stylesheet (or a <style> tag). Every class is prefixed nuda- so it never collides with Tailwind or your own styles. Tweak the CSS custom properties to match your design system.
Works in React, Vue, Svelte, Astro, Next.js, Nuxt, Laravel Blade, Django, Rails — or a single .html file. No npm install, no build step.