Form — Async availability check
A signup form that checks a username and work email for availability the moment you stop typing. Debounced input fires a simulated async lookup against a fictional taken-list, showing a spinner and then a clear result: a green check when the handle is free, or a red cross with a small set of available alternatives like name1 or name_2 when it isn't. Status flows through aria-busy and aria-live, and the submit button stays locked while any check runs or any field is taken.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--ok-soft: #e6f4ed;
--warn: #d98a2b;
--danger: #d4503e;
--danger-soft: #fbeae7;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "cv11", "ss01";
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- layout ---- */
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px 20px;
background:
radial-gradient(900px 480px at 12% -8%, rgba(91, 91, 240, 0.1), transparent 60%),
radial-gradient(720px 420px at 105% 6%, rgba(0, 180, 166, 0.1), transparent 55%);
}
.card {
width: 100%;
max-width: 460px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 30px 30px 26px;
}
.card__head {
margin-bottom: 22px;
}
.eyebrow {
display: inline-block;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand);
background: var(--brand-50);
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 12px;
}
.card__title {
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--ink);
}
.card__sub {
margin-top: 7px;
font-size: 14.5px;
color: var(--muted);
}
/* ---- field ---- */
.field {
margin-bottom: 20px;
}
.field__label-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 7px;
}
.field__label {
font-size: 13.5px;
font-weight: 600;
color: var(--ink-2);
}
.req {
color: var(--danger);
font-weight: 700;
}
.field__count {
font-size: 12px;
font-weight: 500;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ---- control ---- */
.control {
display: flex;
align-items: stretch;
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
overflow: hidden;
}
.control:focus-within {
border-color: var(--brand);
box-shadow: 0 0 0 4px var(--brand-50);
}
.control__prefix {
display: flex;
align-items: center;
padding: 0 4px 0 14px;
font-size: 14.5px;
font-weight: 500;
color: var(--muted);
white-space: nowrap;
user-select: none;
}
.control__icon {
display: flex;
align-items: center;
padding-left: 12px;
color: var(--muted);
}
.control__icon svg {
width: 18px;
height: 18px;
}
.control__input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
padding: 13px 12px 13px 4px;
font: inherit;
font-size: 15px;
color: var(--ink);
}
.control__prefix + .control__input {
padding-left: 0;
}
.control__input--icon {
padding-left: 10px;
}
.control__input::placeholder {
color: #aab0c8;
}
/* status icon slot */
.control__status {
display: flex;
align-items: center;
justify-content: center;
width: 0;
opacity: 0;
padding-right: 0;
transition:
width 0.18s ease,
opacity 0.18s ease,
padding 0.18s ease;
}
.field[data-state="checking"] .control__status,
.field[data-state="ok"] .control__status,
.field[data-state="taken"] .control__status,
.field[data-state="error"] .control__status {
width: 38px;
opacity: 1;
padding-right: 12px;
}
.ic {
width: 20px;
height: 20px;
display: none;
}
.field[data-state="checking"] .ic--spin {
display: block;
color: var(--brand);
}
.field[data-state="ok"] .ic--ok {
display: block;
color: var(--ok);
}
.field[data-state="taken"] .ic--bad,
.field[data-state="error"] .ic--bad {
display: block;
color: var(--danger);
}
.ic--spin {
animation: spin 0.8s linear infinite;
transform-origin: 50% 50%;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---- field states ---- */
.field[data-state="ok"] .control {
border-color: var(--ok);
}
.field[data-state="ok"] .control:focus-within {
box-shadow: 0 0 0 4px var(--ok-soft);
}
.field[data-state="taken"] .control,
.field[data-state="error"] .control {
border-color: var(--danger);
}
.field[data-state="taken"] .control:focus-within,
.field[data-state="error"] .control:focus-within {
box-shadow: 0 0 0 4px var(--danger-soft);
}
.field[data-state="checking"] .control {
border-color: var(--brand);
}
/* ---- help text ---- */
.field__help {
margin-top: 7px;
font-size: 12.75px;
color: var(--muted);
display: flex;
align-items: center;
gap: 5px;
min-height: 17px;
transition: color 0.16s ease;
}
.field[data-state="ok"] .field__help {
color: var(--ok);
font-weight: 500;
}
.field[data-state="taken"] .field__help,
.field[data-state="error"] .field__help {
color: var(--danger);
font-weight: 500;
}
.field[data-state="checking"] .field__help {
color: var(--brand);
font-weight: 500;
}
/* ---- suggestions ---- */
.suggest {
margin-top: 10px;
padding: 11px 12px;
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.18);
border-radius: var(--r-sm);
animation: pop 0.2s ease;
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.suggest__label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--brand-700);
margin-bottom: 8px;
}
.suggest__chips {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.chip {
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--brand-d);
background: var(--white);
border: 1px solid rgba(91, 91, 240, 0.3);
border-radius: 999px;
padding: 6px 13px;
cursor: pointer;
transition:
background 0.14s ease,
transform 0.1s ease,
border-color 0.14s ease;
}
.chip:hover {
background: var(--brand);
border-color: var(--brand);
color: var(--white);
}
.chip:active {
transform: scale(0.96);
}
.chip:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* ---- submit ---- */
.btn-submit {
width: 100%;
margin-top: 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
font: inherit;
font-size: 15.5px;
font-weight: 700;
color: var(--white);
background: var(--brand);
border: 0;
border-radius: var(--r-md);
padding: 14px 18px;
cursor: pointer;
box-shadow: 0 6px 16px rgba(91, 91, 240, 0.28);
transition:
background 0.16s ease,
transform 0.1s ease,
box-shadow 0.16s ease,
opacity 0.16s ease;
}
.btn-submit:hover:not(:disabled) {
background: var(--brand-d);
}
.btn-submit:active:not(:disabled) {
transform: translateY(1px);
}
.btn-submit:disabled {
background: #c9cce0;
color: #f3f4fa;
box-shadow: none;
cursor: not-allowed;
}
.btn-submit__spin {
width: 19px;
height: 19px;
display: none;
color: currentColor;
}
.btn-submit[data-loading="true"] .btn-submit__spin {
display: block;
}
.btn-submit[data-loading="true"] .btn-submit__label {
opacity: 0.85;
}
.card__foot {
margin-top: 14px;
font-size: 12px;
color: var(--muted);
text-align: center;
}
/* ---- focus rings ---- */
:focus-visible {
outline: none;
}
.control__input:focus-visible {
outline: none;
}
.btn-submit:focus-visible,
.btn-ghost:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 3px;
}
/* ---- success panel ---- */
.done {
text-align: center;
padding: 40px 30px 34px;
animation: pop 0.28s ease;
}
.done__badge {
display: grid;
place-items: center;
width: 60px;
height: 60px;
margin: 0 auto 18px;
border-radius: 50%;
background: var(--ok-soft);
color: var(--ok);
animation: badge 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.4);
}
@keyframes badge {
from {
transform: scale(0.4);
opacity: 0;
}
}
.done__badge svg {
width: 30px;
height: 30px;
}
.done__title {
font-size: 23px;
font-weight: 800;
letter-spacing: -0.02em;
}
.done__sub {
margin-top: 9px;
font-size: 14.5px;
color: var(--muted);
}
.done__sub strong {
color: var(--ink-2);
font-weight: 600;
}
.btn-ghost {
margin-top: 22px;
font: inherit;
font-size: 14.5px;
font-weight: 600;
color: var(--brand-d);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.22);
border-radius: var(--r-md);
padding: 11px 20px;
cursor: pointer;
transition:
background 0.14s ease,
transform 0.1s ease;
}
.btn-ghost:hover {
background: var(--brand);
color: var(--white);
border-color: var(--brand);
}
.btn-ghost:active {
transform: translateY(1px);
}
/* ---- toast ---- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 140%);
display: flex;
align-items: center;
gap: 9px;
background: var(--ink);
color: var(--white);
font-size: 13.5px;
font-weight: 500;
padding: 11px 16px;
border-radius: 999px;
box-shadow: var(--sh-2);
transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 60;
max-width: calc(100vw - 32px);
}
.toast[data-state="show"] {
transform: translate(-50%, 0);
}
.toast__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
.toast[data-tone="bad"] .toast__dot {
background: var(--danger);
}
.toast[data-tone="ok"] .toast__dot {
background: var(--ok);
}
/* ---- responsive ---- */
@media (max-width: 520px) {
.page {
padding: 20px 14px;
}
.card {
padding: 24px 20px 22px;
border-radius: var(--r-md);
}
.card__title {
font-size: 23px;
}
.control__prefix {
padding-left: 12px;
font-size: 13.5px;
}
.control__input {
font-size: 16px; /* avoid iOS zoom */
}
.toast {
bottom: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
.ic--spin,
.btn-submit__spin.ic--spin {
animation: spin 0.8s linear infinite !important;
}
}(function () {
"use strict";
/* ---------------------------------------------------------------
* Fictional "server" data. In a real app these checks would be
* network requests; here we resolve a Promise via setTimeout so the
* loading -> result transition is exactly the same shape.
* --------------------------------------------------------------- */
var TAKEN_USERNAMES = [
"admin",
"support",
"nova",
"atlas",
"maya",
"river",
"jordan",
"sky",
"dev",
"team",
"hello",
"founder",
];
var TAKEN_EMAILS = [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
];
var LATENCY = 720; // simulated round-trip in ms
var DEBOUNCE = 450; // wait after last keystroke before checking
// Resolves to { available: boolean }
function checkUsername(value) {
return new Promise(function (resolve) {
setTimeout(function () {
resolve({ available: TAKEN_USERNAMES.indexOf(value.toLowerCase()) === -1 });
}, LATENCY);
});
}
function checkEmail(value) {
return new Promise(function (resolve) {
setTimeout(function () {
resolve({ available: TAKEN_EMAILS.indexOf(value.toLowerCase()) === -1 });
}, LATENCY);
});
}
function suggestUsernames(base) {
var clean = base.toLowerCase().replace(/[^a-z0-9._]/g, "") || "user";
var pool = [
clean + "1",
clean + "_2",
clean + ".hq",
"the" + clean,
clean + new Date().getFullYear().toString().slice(-2),
];
// only offer ones our fictional server says are free
return pool
.filter(function (n) {
return TAKEN_USERNAMES.indexOf(n) === -1;
})
.slice(0, 3);
}
/* --------------------------------------------------------------- */
var form = document.getElementById("signup-form");
var donePanel = document.getElementById("done-panel");
var submitBtn = document.getElementById("submit-btn");
var liveRegion = document.getElementById("live-region");
// toast
var toastEl = document.getElementById("toast");
var toastMsgEl = document.getElementById("toast-msg");
var toastTimer = null;
function toast(msg, tone) {
toastMsgEl.textContent = msg;
toastEl.setAttribute("data-tone", tone || "");
toastEl.setAttribute("data-state", "show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.setAttribute("data-state", "hide");
}, 2600);
}
function announce(msg) {
liveRegion.textContent = "";
// re-set on next frame so repeated identical messages still announce
requestAnimationFrame(function () {
liveRegion.textContent = msg;
});
}
/* --------------------------------------------------------------- */
function FieldController(opts) {
this.root = document.querySelector('[data-field="' + opts.name + '"]');
this.name = opts.name;
this.input = this.root.querySelector(".control__input");
this.help = this.root.querySelector("[data-help]");
this.suggestBox = this.root.querySelector("[data-suggest]");
this.chips = this.suggestBox ? this.suggestBox.querySelector("[data-chips]") : null;
this.countEl = opts.countId ? document.getElementById(opts.countId) : null;
this.checkFn = opts.checkFn;
this.localValidate = opts.localValidate;
this.takenMsg = opts.takenMsg;
this.okMsg = opts.okMsg;
this.idleMsg = this.help.textContent;
this.maxLen = opts.maxLen || 0;
this.state = "idle"; // idle | checking | ok | taken | error
this.requestId = 0; // guards against out-of-order async results
this.timer = null;
this._bind();
this._renderCount();
}
FieldController.prototype._bind = function () {
var self = this;
this.input.addEventListener("input", function () {
self._renderCount();
self.onInput();
});
this.input.addEventListener("blur", function () {
// if the user leaves while idle text exists but no check ran, force one
if (self.input.value.trim() && self.state === "idle") {
self.onInput(true);
}
});
};
FieldController.prototype._renderCount = function () {
if (!this.countEl || !this.maxLen) return;
this.countEl.textContent = this.input.value.length + " / " + this.maxLen;
};
FieldController.prototype.setState = function (state, msg) {
this.state = state;
this.root.setAttribute("data-state", state);
if (state === "checking") {
this.input.setAttribute("aria-busy", "true");
} else {
this.input.removeAttribute("aria-busy");
}
var invalid = state === "taken" || state === "error";
this.input.setAttribute("aria-invalid", invalid ? "true" : "false");
if (typeof msg === "string") this.help.textContent = msg;
if (state !== "taken") this._hideSuggest();
onAnyFieldChange();
};
FieldController.prototype.reset = function () {
clearTimeout(this.timer);
this.requestId++;
this.state = "idle";
this.root.removeAttribute("data-state");
this.input.setAttribute("aria-invalid", "false");
this.input.removeAttribute("aria-busy");
this.input.value = "";
this.help.textContent = this.idleMsg;
this._hideSuggest();
this._renderCount();
};
FieldController.prototype.onInput = function (immediate) {
var self = this;
clearTimeout(this.timer);
this.requestId++; // cancel any in-flight result
var value = this.input.value.trim();
// empty -> back to idle
if (!value) {
this.setState("idle", this.idleMsg);
this.root.removeAttribute("data-state");
return;
}
// synchronous local rules first (format/length)
var local = this.localValidate(value);
if (!local.valid) {
this.setState("error", local.msg);
return;
}
// looks fine locally -> go check the "server" after a debounce
var run = function () {
var myId = ++self.requestId;
self.setState("checking", "Checking availability…");
announce("Checking availability for " + self.name);
self.checkFn(value).then(function (res) {
// ignore if a newer request superseded this one
if (myId !== self.requestId) return;
if (res.available) {
self.setState("ok", self.okMsg);
announce(value + " is available");
} else {
self.setState("taken", self.takenMsg);
announce(value + " is already taken");
if (self.name === "username") self._showSuggest(value);
}
});
};
if (immediate) run();
else this.timer = setTimeout(run, DEBOUNCE);
};
FieldController.prototype._showSuggest = function (value) {
if (!this.suggestBox || !this.chips) return;
var names = suggestUsernames(value);
if (!names.length) {
this._hideSuggest();
return;
}
var self = this;
this.chips.innerHTML = "";
names.forEach(function (n) {
var b = document.createElement("button");
b.type = "button";
b.className = "chip";
b.textContent = n;
b.addEventListener("click", function () {
self.input.value = n;
self._renderCount();
self.input.focus();
self.onInput(true); // re-check the chosen name immediately
toast("Trying " + n, "");
});
self.chips.appendChild(b);
});
this.suggestBox.hidden = false;
};
FieldController.prototype._hideSuggest = function () {
if (this.suggestBox) this.suggestBox.hidden = true;
};
FieldController.prototype.isReady = function () {
return this.state === "ok";
};
FieldController.prototype.isBlocking = function () {
return this.state === "checking";
};
/* ---- local validators ---- */
function validateUsername(v) {
if (v.length < 3) return { valid: false, msg: "Too short — at least 3 characters." };
if (v.length > 20) return { valid: false, msg: "Too long — 20 characters max." };
if (!/^[a-zA-Z0-9._]+$/.test(v))
return {
valid: false,
msg: "Only letters, numbers, dot and underscore.",
};
if (/^[._]|[._]$/.test(v))
return { valid: false, msg: "Can't start or end with a dot or underscore." };
return { valid: true };
}
function validateEmail(v) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v))
return { valid: false, msg: "Enter a valid email, like [email protected]." };
return { valid: true };
}
/* --------------------------------------------------------------- */
var fields = [
new FieldController({
name: "username",
countId: "username-count",
maxLen: 20,
checkFn: checkUsername,
localValidate: validateUsername,
okMsg: "Nice — that handle is available.",
takenMsg: "Sorry, that one's taken.",
}),
new FieldController({
name: "email",
checkFn: checkEmail,
localValidate: validateEmail,
okMsg: "Looks good — no account uses this yet.",
takenMsg: "An account already uses this email.",
}),
];
function allReady() {
return fields.every(function (f) {
return f.isReady();
});
}
function anyChecking() {
return fields.some(function (f) {
return f.isBlocking();
});
}
function onAnyFieldChange() {
// submit is blocked while anything is still checking, or not all valid
var disabled = !allReady() || anyChecking();
submitBtn.disabled = disabled;
submitBtn.removeAttribute("data-loading");
}
/* ---- submit ---- */
form.addEventListener("submit", function (e) {
e.preventDefault();
if (anyChecking()) {
toast("Hang on — still checking availability", "");
announce("Please wait, availability check in progress.");
return;
}
// find first non-ready field and focus it
var firstBad = fields.find(function (f) {
return !f.isReady();
});
if (firstBad) {
firstBad.input.focus();
toast("Resolve the highlighted field first", "bad");
return;
}
// fake the create call with a brief loading state
submitBtn.setAttribute("data-loading", "true");
submitBtn.disabled = true;
announce("Reserving your workspace…");
setTimeout(function () {
var handle = fields[0].input.value.trim();
var email = fields[1].input.value.trim();
document.getElementById("done-handle").textContent = "app.dev/" + handle;
document.getElementById("done-email").textContent = email;
form.hidden = true;
donePanel.hidden = false;
// move focus into the confirmation so screen readers land there
donePanel.setAttribute("tabindex", "-1");
donePanel.focus();
toast("Workspace reserved", "ok");
announce("Success. Your workspace " + handle + " is reserved.");
}, 900);
});
/* ---- reset / claim another ---- */
document.getElementById("reset-btn").addEventListener("click", function () {
fields.forEach(function (f) {
f.reset();
});
submitBtn.disabled = true;
submitBtn.removeAttribute("data-loading");
donePanel.hidden = true;
form.hidden = false;
fields[0].input.focus();
announce("Form reset. Claim a new handle.");
});
// start with submit disabled and focus on the first field
onAnyFieldChange();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Form — Async availability check</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<form class="card" id="signup-form" novalidate>
<header class="card__head">
<span class="eyebrow">Create your workspace</span>
<h1 class="card__title">Claim your handle</h1>
<p class="card__sub">
We check availability as you type. Green means it's yours, red means
someone got there first.
</p>
</header>
<!-- USERNAME -->
<div class="field" data-field="username">
<div class="field__label-row">
<label class="field__label" for="username"
>Username <span class="req" aria-hidden="true">*</span></label
>
<span class="field__count" id="username-count" aria-hidden="true"
>0 / 20</span
>
</div>
<div class="control">
<span class="control__prefix" aria-hidden="true">app.dev/</span>
<input
id="username"
name="username"
class="control__input"
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
placeholder="yourname"
maxlength="20"
required
aria-required="true"
aria-describedby="username-help"
aria-invalid="false"
/>
<span class="control__status" data-status aria-hidden="true">
<!-- spinner -->
<svg class="ic ic--spin" viewBox="0 0 24 24" data-icon="spin">
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-dasharray="44 56"
/>
</svg>
<!-- check -->
<svg class="ic ic--ok" viewBox="0 0 24 24" data-icon="ok">
<path
d="M5 12.5l4.2 4.2L19 7"
fill="none"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<!-- cross -->
<svg class="ic ic--bad" viewBox="0 0 24 24" data-icon="bad">
<path
d="M7 7l10 10M17 7L7 17"
fill="none"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
/>
</svg>
</span>
</div>
<p class="field__help" id="username-help" data-help aria-live="polite">
3–20 characters. Letters, numbers, dot, underscore.
</p>
<div
class="suggest"
id="username-suggest"
data-suggest
hidden
role="group"
aria-label="Available alternatives"
>
<span class="suggest__label">Try one of these:</span>
<div class="suggest__chips" data-chips></div>
</div>
</div>
<!-- EMAIL -->
<div class="field" data-field="email">
<div class="field__label-row">
<label class="field__label" for="email"
>Work email <span class="req" aria-hidden="true">*</span></label
>
</div>
<div class="control">
<span class="control__icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<rect
x="3"
y="5"
width="18"
height="14"
rx="2.5"
fill="none"
stroke="currentColor"
stroke-width="1.8"
/>
<path
d="M4 7l8 6 8-6"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<input
id="email"
name="email"
class="control__input control__input--icon"
type="email"
inputmode="email"
autocomplete="off"
spellcheck="false"
placeholder="[email protected]"
required
aria-required="true"
aria-describedby="email-help"
aria-invalid="false"
/>
<span class="control__status" data-status aria-hidden="true">
<svg class="ic ic--spin" viewBox="0 0 24 24" data-icon="spin">
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-dasharray="44 56"
/>
</svg>
<svg class="ic ic--ok" viewBox="0 0 24 24" data-icon="ok">
<path
d="M5 12.5l4.2 4.2L19 7"
fill="none"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<svg class="ic ic--bad" viewBox="0 0 24 24" data-icon="bad">
<path
d="M7 7l10 10M17 7L7 17"
fill="none"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
/>
</svg>
</span>
</div>
<p class="field__help" id="email-help" data-help aria-live="polite">
We'll check this isn't already registered.
</p>
</div>
<!-- live region for screen readers -->
<p class="sr-only" id="live-region" aria-live="polite" aria-atomic="true"></p>
<button type="submit" class="btn-submit" id="submit-btn" disabled>
<span class="btn-submit__label">Create workspace</span>
<svg class="btn-submit__spin ic--spin" viewBox="0 0 24 24" aria-hidden="true">
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
stroke-width="2.6"
stroke-linecap="round"
stroke-dasharray="44 56"
/>
</svg>
</button>
<p class="card__foot">
Names are reserved for 24 hours once claimed. No spam, ever.
</p>
</form>
<!-- SUCCESS PANEL -->
<section class="card done" id="done-panel" hidden aria-live="polite">
<span class="done__badge" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path
d="M5 12.5l4.2 4.2L19 7"
fill="none"
stroke="currentColor"
stroke-width="2.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<h2 class="done__title">Workspace reserved</h2>
<p class="done__sub">
<strong id="done-handle">app.dev/yourname</strong> is now yours. We sent a
confirmation to <strong id="done-email">[email protected]</strong>.
</p>
<button type="button" class="btn-ghost" id="reset-btn">
Claim another handle
</button>
</section>
</main>
<!-- toast -->
<div class="toast" id="toast" role="status" aria-live="polite" data-state="hide">
<span class="toast__dot" aria-hidden="true"></span>
<span class="toast__msg" id="toast-msg"></span>
</div>
<script src="script.js"></script>
</body>
</html>Async availability check
Two fields — a app.dev/ username handle and a work email — each run a remote-style availability check after you pause typing. Local format rules (length, allowed characters, a valid email shape) are validated synchronously first, so the form never wastes a “network” round-trip on input that can’t pass. Once a value looks valid locally, a debounced timer kicks off a simulated lookup: the control shows an inline spinner and announces “Checking availability…”, then resolves into either a green check with a reassuring message or a red cross.
When a username is taken, a small panel offers up to three available alternatives (name1, name_2, the…) as clickable chips; choosing one drops it into the field and immediately re-checks it. Every result is mirrored to an off-screen aria-live region and a toast, the input carries aria-busy while checking and aria-invalid when taken, and helper text is wired through aria-describedby.
The “Create workspace” button stays disabled while any check is in flight or any field is unresolved or taken, and out-of-order async responses are discarded with a per-field request id so a slow earlier check can never overwrite a newer one. A real submit shows a brief loading state and then an animated confirmation panel with a “Claim another handle” action that fully resets state. The layout stacks cleanly down to 360px and respects prefers-reduced-motion.