Form — Submit success / confirmation states
A contact form that walks the whole submit lifecycle in front of you: idle, then a sending state with a button spinner and disabled fields, then either a confirmed receipt or a recoverable error. Success paints an animated SVG checkmark, a personalized confirmation, and a copyable reference number with a Submit-another reset. The failure path raises a role=alert banner with a retry button, and an aria-live region announces every outcome. Real inline validation, no fake submit.
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;
--warn: #d98a2b;
--danger: #d4503e;
--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;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.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: 32px 20px;
background:
radial-gradient(900px 500px at 100% -10%, rgba(91, 91, 240, 0.08), transparent 60%),
radial-gradient(700px 460px at -10% 110%, rgba(0, 180, 166, 0.07), transparent 55%);
}
.card {
position: relative;
width: 100%;
max-width: 480px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 30px;
overflow: hidden;
}
/* ---------- Header ---------- */
.card__head {
margin-bottom: 22px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--brand-700);
background: var(--brand-50);
border-radius: 999px;
}
.badge svg {
color: var(--brand);
}
.card__title {
margin: 14px 0 6px;
font-size: 23px;
font-weight: 800;
letter-spacing: -0.02em;
}
.card__sub {
margin: 0;
font-size: 14px;
color: var(--muted);
}
/* ---------- Error banner ---------- */
.banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 14px 14px 16px;
margin-bottom: 20px;
background: #fcecea;
border: 1px solid rgba(212, 80, 62, 0.35);
border-left: 4px solid var(--danger);
border-radius: var(--r-md);
animation: banner-in 0.28s ease;
}
@keyframes banner-in {
from {
opacity: 0;
transform: translateY(-6px);
}
}
.banner__icon {
color: var(--danger);
flex: none;
margin-top: 1px;
}
.banner__body {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.banner__title {
font-size: 14px;
font-weight: 700;
color: #8f2f22;
}
.banner__text {
font-size: 13px;
color: #9a4337;
}
.banner__retry {
flex: none;
align-self: center;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--white);
background: var(--danger);
border: none;
border-radius: var(--r-sm);
padding: 8px 14px;
cursor: pointer;
transition: background 0.15s ease;
}
.banner__retry:hover {
background: #be4334;
}
.banner__retry:focus-visible {
outline: 3px solid rgba(212, 80, 62, 0.4);
outline-offset: 2px;
}
/* ---------- Form ---------- */
.form {
display: grid;
gap: 18px;
}
.field {
display: grid;
gap: 7px;
}
.field__label {
font-size: 13.5px;
font-weight: 600;
color: var(--ink-2);
}
.req {
color: var(--danger);
font-weight: 700;
}
.control {
position: relative;
display: flex;
align-items: center;
}
.input {
width: 100%;
font: inherit;
font-size: 15px;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
padding: 12px 42px 12px 14px;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.input::placeholder {
color: #9ca2bd;
}
.input:hover:not(:focus):not(:disabled) {
border-color: var(--muted);
}
.input:focus-visible,
.input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 4px rgba(91, 91, 240, 0.16);
}
.textarea {
resize: vertical;
min-height: 96px;
padding-right: 14px;
line-height: 1.55;
}
.select {
appearance: none;
cursor: pointer;
}
.select__chevron {
position: absolute;
right: 12px;
display: grid;
place-items: center;
color: var(--muted);
pointer-events: none;
}
/* Trailing status icon (valid / invalid) */
.control__icon {
position: absolute;
right: 12px;
width: 18px;
height: 18px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.15s ease, transform 0.15s ease;
pointer-events: none;
}
.control__icon::after {
font-size: 13px;
font-weight: 800;
line-height: 1;
}
/* ---------- Field states ---------- */
.field.is-valid .input {
border-color: var(--ok);
}
.field.is-valid .control__icon {
opacity: 1;
transform: scale(1);
color: var(--ok);
}
.field.is-valid .control__icon::after {
content: "✓";
}
.field.is-error .input {
border-color: var(--danger);
background: #fef6f5;
}
.field.is-error .input:focus {
box-shadow: 0 0 0 4px rgba(212, 80, 62, 0.16);
}
.field.is-error .control__icon {
opacity: 1;
transform: scale(1);
color: var(--danger);
}
.field.is-error .control__icon::after {
content: "!";
}
.field.is-error .field__label {
color: var(--danger);
}
.help {
margin: 0;
font-size: 12.5px;
color: var(--muted);
}
.field.is-error .help {
color: var(--danger);
font-weight: 500;
}
.help-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.counter {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--muted);
flex: none;
}
.counter.is-near {
color: var(--warn);
font-weight: 600;
}
.counter.is-over {
color: var(--danger);
font-weight: 700;
}
/* ---------- Submit button + lifecycle ---------- */
.submit {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
width: 100%;
margin-top: 2px;
font: inherit;
font-size: 15px;
font-weight: 600;
color: var(--white);
background: var(--brand);
border: none;
border-radius: var(--r-md);
padding: 13px 18px;
cursor: pointer;
box-shadow: var(--sh-1);
transition: background 0.15s ease, transform 0.08s ease, box-shadow 0.15s ease;
}
.submit:hover:not(:disabled) {
background: var(--brand-d);
}
.submit:active:not(:disabled) {
transform: translateY(1px);
}
.submit:focus-visible {
outline: 3px solid rgba(91, 91, 240, 0.4);
outline-offset: 2px;
}
.submit:disabled {
cursor: not-allowed;
background: #b9bbe9;
box-shadow: none;
}
.submit__spin {
width: 17px;
height: 17px;
border-radius: 50%;
border: 2.5px solid rgba(255, 255, 255, 0.45);
border-top-color: var(--white);
display: none;
}
.submit.is-loading {
background: var(--brand-d);
cursor: progress;
}
.submit.is-loading .submit__spin {
display: block;
animation: spin 0.7s linear infinite;
}
.submit.is-loading .submit__label {
opacity: 0.92;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.form__foot {
margin: 0;
text-align: center;
font-size: 12.5px;
color: var(--muted);
}
/* ---------- Success state ---------- */
.success {
text-align: center;
padding: 8px 4px 4px;
animation: success-in 0.4s cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes success-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
.success__check {
display: grid;
place-items: center;
margin: 6px auto 14px;
}
.success__circle {
stroke: var(--ok);
stroke-width: 3;
stroke-dasharray: 151;
stroke-dashoffset: 151;
animation: draw-circle 0.5s ease-out forwards;
}
.success__tick {
stroke: var(--ok);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: draw-tick 0.35s 0.42s ease-out forwards;
}
@keyframes draw-circle {
to {
stroke-dashoffset: 0;
}
}
@keyframes draw-tick {
to {
stroke-dashoffset: 0;
}
}
.success__title {
margin: 0 0 6px;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
outline: none;
}
.success__text {
margin: 0 auto 18px;
max-width: 36ch;
font-size: 14.5px;
color: var(--ink-2);
}
.receipt {
margin: 0 0 20px;
padding: 14px 16px;
text-align: left;
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.18);
border-radius: var(--r-md);
display: grid;
gap: 10px;
}
.receipt__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0;
}
.receipt dt {
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
}
.receipt dd {
margin: 0;
font-size: 13.5px;
font-weight: 600;
color: var(--ink);
}
.receipt code {
font-family: "Inter", ui-monospace, monospace;
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--brand-700);
background: var(--white);
border: 1px solid rgba(91, 91, 240, 0.25);
border-radius: var(--r-sm);
padding: 3px 8px;
}
.ghost {
display: inline-flex;
align-items: center;
gap: 7px;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--brand-d);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
padding: 11px 18px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.ghost:hover {
border-color: var(--brand);
background: var(--brand-50);
}
.ghost:focus-visible {
outline: 3px solid rgba(91, 91, 240, 0.4);
outline-offset: 2px;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 16px);
max-width: calc(100% - 32px);
padding: 11px 16px;
font-size: 13.5px;
font-weight: 500;
color: var(--white);
background: var(--ink);
border-radius: var(--r-md);
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
.toast.is-error {
background: var(--danger);
}
.toast.is-ok {
background: var(--ok);
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.page {
padding: 16px 12px;
}
.card {
padding: 22px 18px;
border-radius: var(--r-md);
}
.card__title {
font-size: 21px;
}
.banner {
flex-wrap: wrap;
}
.banner__retry {
width: 100%;
}
.help-row {
flex-direction: column;
gap: 2px;
}
.counter {
align-self: flex-end;
}
.receipt__row {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.success__circle,
.success__tick {
stroke-dashoffset: 0;
}
}(function () {
"use strict";
var form = document.getElementById("contact");
var submitBtn = document.getElementById("submit");
var submitLabel = submitBtn.querySelector(".submit__label");
var successPanel = document.getElementById("success");
var errorBanner = document.getElementById("error-banner");
var statusRegion = document.getElementById("status");
var toastEl = document.getElementById("toast");
var MESSAGE_MAX = 600;
var MESSAGE_MIN = 12;
// Field definitions: each knows how to read + validate itself.
var FIELDS = {
name: {
el: document.getElementById("name"),
help: document.getElementById("name-help"),
defaultHelp: "So we know who we're replying to.",
validate: function (v) {
if (!v.trim()) return "Please tell us your name.";
if (v.trim().length < 2) return "That looks a little short.";
return "";
},
},
email: {
el: document.getElementById("email"),
help: document.getElementById("email-help"),
defaultHelp: "We'll send the confirmation here.",
validate: function (v) {
if (!v.trim()) return "An email lets us reply.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.trim()))
return "Enter a valid email, like [email protected].";
return "";
},
},
topic: {
el: document.getElementById("topic"),
help: document.getElementById("topic-help"),
defaultHelp: "Helps us route you to the right people.",
validate: function (v) {
if (!v) return "Pick the closest topic.";
return "";
},
},
message: {
el: document.getElementById("message"),
help: document.getElementById("message-help"),
defaultHelp: "A sentence or two is plenty. Min 12 characters.",
validate: function (v) {
var t = v.trim();
if (!t) return "Don't forget your message.";
if (t.length < MESSAGE_MIN)
return "A few more words, please (at least " + MESSAGE_MIN + " characters).";
if (v.length > MESSAGE_MAX) return "That's over the " + MESSAGE_MAX + " character limit.";
return "";
},
},
};
var touched = {};
/* ---------- Toast helper ---------- */
var toastTimer = null;
function toast(msg, kind) {
toastEl.textContent = msg;
toastEl.className = "toast is-show" + (kind ? " is-" + kind : "");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.className = "toast";
}, 3400);
}
/* ---------- Per-field rendering ---------- */
function fieldWrap(key) {
return FIELDS[key].el.closest(".field");
}
function applyState(key, message) {
var def = FIELDS[key];
var wrap = fieldWrap(key);
var value = (def.el.value || "").toString();
var isEmpty = !value.trim();
if (message) {
wrap.classList.add("is-error");
wrap.classList.remove("is-valid");
def.el.setAttribute("aria-invalid", "true");
def.help.textContent = message;
} else {
wrap.classList.remove("is-error");
def.el.removeAttribute("aria-invalid");
def.help.textContent = def.defaultHelp;
// Only mark valid once there's actual content.
wrap.classList.toggle("is-valid", !isEmpty);
}
}
function checkField(key, force) {
var def = FIELDS[key];
if (!force && !touched[key]) return def.validate(def.el.value) === "";
var msg = def.validate(def.el.value);
applyState(key, msg);
return msg === "";
}
function isFormValid() {
return Object.keys(FIELDS).every(function (key) {
return FIELDS[key].validate(FIELDS[key].el.value) === "";
});
}
/* ---------- Wire up field events ---------- */
Object.keys(FIELDS).forEach(function (key) {
var def = FIELDS[key];
var evt = def.el.tagName === "SELECT" ? "change" : "input";
def.el.addEventListener("blur", function () {
touched[key] = true;
checkField(key, true);
});
def.el.addEventListener(evt, function () {
// Re-validate live only after the field has been touched once.
if (touched[key]) checkField(key, true);
});
});
/* ---------- Character counter ---------- */
var msgEl = FIELDS.message.el;
var counter = document.getElementById("message-count");
function updateCount() {
var len = msgEl.value.length;
counter.textContent = len + " / " + MESSAGE_MAX;
counter.classList.toggle("is-near", len > MESSAGE_MAX * 0.85 && len <= MESSAGE_MAX);
counter.classList.toggle("is-over", len > MESSAGE_MAX);
}
msgEl.addEventListener("input", updateCount);
updateCount();
/* ---------- Reference number ---------- */
function makeRef() {
var part = function (n) {
var s = "";
var chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
for (var i = 0; i < n; i++) s += chars[Math.floor(Math.random() * chars.length)];
return s;
};
return "REQ-" + part(4) + "-" + part(4);
}
/* ---------- State transitions ---------- */
function setLoading(on) {
submitBtn.classList.toggle("is-loading", on);
submitBtn.disabled = on;
submitBtn.setAttribute("aria-busy", on ? "true" : "false");
submitLabel.textContent = on ? "Sending…" : "Send message";
}
function showError() {
errorBanner.hidden = false;
statusRegion.textContent = "Submission failed. The request timed out. Please retry.";
toast("Couldn't send — give it another go.", "error");
errorBanner.scrollIntoView({ behavior: "smooth", block: "nearest" });
errorBanner.querySelector("[data-retry]").focus();
}
function showSuccess(data, ref) {
form.hidden = true;
errorBanner.hidden = true;
successPanel.hidden = false;
document.getElementById("success-name").textContent =
data.name.trim().split(/\s+/)[0] || "there";
document.getElementById("success-email").textContent = data.email.trim();
document.getElementById("success-ref").textContent = ref;
statusRegion.textContent =
"Message sent successfully. Your reference number is " + ref + ".";
toast("Message sent — reference " + ref, "ok");
// Move focus to the heading so screen-reader + keyboard users land here.
var title = successPanel.querySelector(".success__title");
requestAnimationFrame(function () {
title.focus();
});
}
/* ---------- Fake network round-trip ---------- */
// We deliberately fail once per page load so the error/retry path is
// demonstrable, then succeed on the retry.
var attempts = 0;
function send(data) {
return new Promise(function (resolve, reject) {
attempts++;
var willFail = attempts === 1; // first attempt fails, retry succeeds
setTimeout(
function () {
if (willFail) reject(new Error("timeout"));
else resolve(makeRef());
},
900 + Math.random() * 500
);
});
}
function collect() {
return {
name: FIELDS.name.el.value,
email: FIELDS.email.el.value,
topic: FIELDS.topic.el.value,
message: FIELDS.message.el.value,
};
}
function attemptSubmit() {
// Full validation pass; focus first invalid field if any.
var firstInvalid = null;
Object.keys(FIELDS).forEach(function (key) {
touched[key] = true;
var ok = checkField(key, true);
if (!ok && !firstInvalid) firstInvalid = FIELDS[key].el;
});
if (firstInvalid) {
statusRegion.textContent = "Please fix the highlighted fields before sending.";
toast("Please complete the highlighted fields.", "error");
firstInvalid.focus();
return;
}
errorBanner.hidden = true;
var data = collect();
setLoading(true);
statusRegion.textContent = "Sending your message…";
send(data)
.then(function (ref) {
setLoading(false);
showSuccess(data, ref);
})
.catch(function () {
setLoading(false);
showError();
});
}
form.addEventListener("submit", function (e) {
e.preventDefault();
attemptSubmit();
});
/* ---------- Retry from error banner ---------- */
errorBanner.querySelector("[data-retry]").addEventListener("click", function () {
attemptSubmit();
});
/* ---------- Reset / submit another ---------- */
document.getElementById("reset").addEventListener("click", function () {
form.reset();
touched = {};
Object.keys(FIELDS).forEach(function (key) {
var wrap = fieldWrap(key);
wrap.classList.remove("is-error", "is-valid");
FIELDS[key].el.removeAttribute("aria-invalid");
FIELDS[key].help.textContent = FIELDS[key].defaultHelp;
});
updateCount();
successPanel.hidden = true;
errorBanner.hidden = true;
form.hidden = false;
setLoading(false);
statusRegion.textContent = "Form reset. Ready for a new message.";
FIELDS.name.el.focus();
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Submit success / confirmation states</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">
<section class="card" aria-labelledby="form-title">
<header class="card__head">
<span class="badge">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="currentColor"
d="M2 12a10 10 0 1 1 20 0 10 10 0 0 1-20 0m13.7-2.3-4.8 4.8-2.3-2.3-1.4 1.4 3.7 3.7 6.2-6.2z"
/>
</svg>
Submit lifecycle
</span>
<h1 id="form-title" class="card__title">Contact our team</h1>
<p class="card__sub">
Send us a message and watch the full submit flow — idle, sending, then a
confirmed receipt with a reference number.
</p>
</header>
<!-- Error banner (shown only on the failure path) -->
<div
id="error-banner"
class="banner"
role="alert"
hidden
>
<svg class="banner__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
fill="currentColor"
d="M12 2 1 21h22zm0 6 .9 7h-1.8zm-1 9h2v2h-2z"
/>
</svg>
<div class="banner__body">
<strong class="banner__title">We couldn't send your message</strong>
<span class="banner__text">
The request timed out before it reached us. Nothing was lost — just retry.
</span>
</div>
<button type="button" class="banner__retry" data-retry>Retry</button>
</div>
<form id="contact" class="form" novalidate>
<!-- Name -->
<div class="field" data-field="name">
<label class="field__label" for="name">
Full name <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="name"
name="name"
type="text"
class="input"
autocomplete="name"
placeholder="Priya Aaltonen"
required
aria-required="true"
aria-describedby="name-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="name-help" class="help">So we know who we're replying to.</p>
</div>
<!-- Email -->
<div class="field" data-field="email">
<label class="field__label" for="email">
Work email <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<input
id="email"
name="email"
type="email"
class="input"
autocomplete="email"
inputmode="email"
placeholder="[email protected]"
required
aria-required="true"
aria-describedby="email-help"
/>
<span class="control__icon" aria-hidden="true"></span>
</div>
<p id="email-help" class="help">We'll send the confirmation here.</p>
</div>
<!-- Topic -->
<div class="field" data-field="topic">
<label class="field__label" for="topic">
What's this about? <span class="req" aria-hidden="true">*</span>
</label>
<div class="control">
<select
id="topic"
name="topic"
class="input select"
required
aria-required="true"
aria-describedby="topic-help"
>
<option value="" selected disabled>Choose a topic…</option>
<option value="sales">Sales & pricing</option>
<option value="support">Technical support</option>
<option value="partnership">Partnership</option>
<option value="other">Something else</option>
</select>
<span class="select__chevron" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="m7 10 5 5 5-5z" />
</svg>
</span>
</div>
<p id="topic-help" class="help">Helps us route you to the right people.</p>
</div>
<!-- Message -->
<div class="field" data-field="message">
<label class="field__label" for="message">
Message <span class="req" aria-hidden="true">*</span>
</label>
<div class="control control--area">
<textarea
id="message"
name="message"
class="input textarea"
rows="4"
placeholder="Tell us a little about what you need…"
required
aria-required="true"
aria-describedby="message-help message-count"
></textarea>
</div>
<div class="help-row">
<p id="message-help" class="help">A sentence or two is plenty. Min 12 characters.</p>
<span id="message-count" class="counter" aria-live="off">0 / 600</span>
</div>
</div>
<button id="submit" type="submit" class="submit">
<span class="submit__spin" aria-hidden="true"></span>
<span class="submit__label">Send message</span>
</button>
<p class="form__foot">
We reply within one business day. No marketing — promise.
</p>
</form>
<!-- Success / confirmation state -->
<div id="success" class="success" hidden>
<div class="success__check" aria-hidden="true">
<svg viewBox="0 0 52 52" width="72" height="72">
<circle class="success__circle" cx="26" cy="26" r="24" fill="none" />
<path class="success__tick" fill="none" d="M14 27l8 8 16-17" />
</svg>
</div>
<h2 class="success__title" tabindex="-1">Message sent</h2>
<p class="success__text">
Thanks, <strong id="success-name">there</strong> — your message is with our team.
A copy is on its way to <strong id="success-email">your inbox</strong>.
</p>
<dl class="receipt">
<div class="receipt__row">
<dt>Reference</dt>
<dd><code id="success-ref">—</code></dd>
</div>
<div class="receipt__row">
<dt>Expected reply</dt>
<dd>Within 1 business day</dd>
</div>
</dl>
<button type="button" class="ghost" id="reset">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M12 5V1L7 6l5 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7"
/>
</svg>
Submit another
</button>
</div>
</section>
</main>
<!-- Polite live region announces the result -->
<div id="status" class="sr-only" role="status" aria-live="polite"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Submit success / confirmation states
A four-field contact form (name, work email, topic, message) built to show the full submit lifecycle rather than just a fire-and-forget POST. Each field validates on blur and then live as you type, with inline error text wired through aria-describedby, an aria-invalid flag, and matching red/green border states plus a trailing status icon. The message field carries a live character counter that warns as it nears its limit.
Pressing Send message runs a real validation pass first — if anything is wrong it focuses the first invalid field and announces the problem. When the form is valid the button enters its sending state: a spinner appears, the label switches to “Sending…”, and the control is disabled with aria-busy. The simulated round-trip fails on the first attempt so the recovery path is visible: a role="alert" banner explains the timeout and offers a Retry button. The retry succeeds and the form swaps to a confirmation panel with an animated drawn-on SVG checkmark, a greeting using the sender’s first name, and a unique REQ-XXXX-XXXX reference number.
Every transition is announced through a polite aria-live status region and echoed in a toast, focus moves to the success heading for keyboard and screen-reader users, and Submit another fully resets the form back to its idle state. The layout stacks cleanly down to 360px and respects prefers-reduced-motion.