Form — Top error summary + jump-to-field
An accessible GOV.UK-style form pattern that, on an invalid submit, renders a focused error-summary box at the top listing every invalid field as a clickable link. Each link scrolls to and focuses its field, while inline messages, aria-invalid, and aria-describedby keep the field-level state in sync. Fixing a field removes it from the summary live, and a clean submit swaps in a success confirmation panel — all in dependency-free vanilla JavaScript.
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;
--danger-soft: #fbeeec;
--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;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 20px 64px;
background:
radial-gradient(1200px 460px at 14% -8%, var(--brand-50), transparent 60%),
radial-gradient(900px 420px at 100% 0%, var(--accent-soft), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
width: 100%;
max-width: 600px;
}
/* ── Card ───────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 32px;
}
.card__head {
margin-bottom: 22px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border-radius: 999px;
background: var(--brand-50);
color: var(--brand-700);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
}
.card__title {
margin: 14px 0 8px;
font-size: 27px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.18;
}
.card__lede {
margin: 0;
color: var(--muted);
font-size: 15px;
max-width: 50ch;
}
/* ── Error summary ──────────────────────────────────── */
.summary {
margin-bottom: 24px;
padding: 18px 20px 8px;
border: 1px solid var(--danger);
border-left-width: 5px;
border-radius: var(--r-md);
background: var(--danger-soft);
animation: summaryIn 0.28s ease;
}
.summary:focus-visible {
outline: 3px solid color-mix(in srgb, var(--danger) 45%, transparent);
outline-offset: 2px;
}
.summary__title {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 6px;
color: var(--danger);
font-size: 17px;
font-weight: 700;
}
.summary__list {
margin: 0;
padding: 0 0 6px 30px;
display: grid;
gap: 4px;
}
.summary__list li {
list-style: disc;
}
.summary__list a {
color: var(--danger);
font-weight: 600;
font-size: 14.5px;
text-decoration: underline;
text-underline-offset: 3px;
border-radius: 4px;
cursor: pointer;
}
.summary__list a:hover {
color: #a93b2c;
}
.summary__list a:focus-visible {
outline: 3px solid color-mix(in srgb, var(--danger) 45%, transparent);
outline-offset: 2px;
}
@keyframes summaryIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Form grid ──────────────────────────────────────── */
.form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.field {
grid-column: 1 / -1;
min-width: 0;
border: 0;
margin: 0;
padding: 0;
}
.field--half {
grid-column: span 1;
}
.field--group {
padding: 0;
}
.field__label {
display: block;
margin-bottom: 4px;
padding: 0;
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
legend.field__label {
float: left;
}
.req {
color: var(--danger);
font-weight: 700;
}
.field__hint {
margin: 0 0 8px;
font-size: 13px;
color: var(--muted);
}
.field__error {
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--danger);
}
.field__error::before {
content: "";
flex: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--danger);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M12 2a10 10 0 100 20 10 10 0 000-20zm0 5a1 1 0 011 1v5a1 1 0 11-2 0V8a1 1 0 011-1zm0 9.5a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z'/%3E%3C/svg%3E")
center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M12 2a10 10 0 100 20 10 10 0 000-20zm0 5a1 1 0 011 1v5a1 1 0 11-2 0V8a1 1 0 011-1zm0 9.5a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.field__error--check::before {
margin-top: 0;
}
/* ── Inputs ─────────────────────────────────────────── */
.input {
width: 100%;
padding: 11px 13px;
font: inherit;
font-size: 15px;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
box-shadow: var(--sh-1);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.15s ease;
}
.input::placeholder {
color: #9aa0bd;
}
.input:hover:not(:disabled) {
border-color: var(--ink-2);
}
.input:focus-visible {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 4px var(--brand-50);
}
.input:disabled {
background: #f1f2f7;
color: var(--muted);
cursor: not-allowed;
box-shadow: none;
}
/* Error / success states on the wrapping .field */
.field.is-error .input {
border-color: var(--danger);
background: #fffafa;
}
.field.is-error .input:focus-visible {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 20%, transparent);
}
.field.is-valid .input {
border-color: var(--ok);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%232f9e6f' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 40px;
}
.field.is-valid .input:focus-visible {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 18%, transparent);
}
/* ── Radio group ────────────────────────────────────── */
.radios {
clear: both;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.radio {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 13px 13px 13px 38px;
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--white);
cursor: pointer;
transition:
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.radio input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.radio__box {
position: absolute;
top: 14px;
left: 13px;
width: 16px;
height: 16px;
border: 2px solid var(--line-2);
border-radius: 50%;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.radio__box::after {
content: "";
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--brand);
transform: scale(0);
transition: transform 0.15s ease;
}
.radio:hover {
border-color: var(--ink-2);
}
.radio input:checked ~ .radio__box {
border-color: var(--brand);
}
.radio input:checked ~ .radio__box::after {
transform: scale(1);
}
.radio:has(input:checked) {
border-color: var(--brand);
background: var(--brand-50);
}
.radio input:focus-visible ~ .radio__box {
box-shadow: 0 0 0 4px var(--brand-50);
}
.radio__text strong {
display: block;
font-size: 14px;
color: var(--ink);
}
.radio__text small {
display: block;
margin-top: 2px;
font-size: 12px;
color: var(--muted);
}
.field.is-error .radio {
border-color: var(--danger);
}
/* ── Checkbox ───────────────────────────────────────── */
.field--check {
margin-top: 2px;
}
.check {
display: flex;
align-items: flex-start;
gap: 11px;
cursor: pointer;
font-size: 14.5px;
color: var(--ink-2);
}
.check input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.check__box {
flex: none;
display: grid;
place-items: center;
width: 22px;
height: 22px;
margin-top: 1px;
border: 1.5px solid var(--line-2);
border-radius: 6px;
background: var(--white);
color: var(--white);
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.check__box svg {
opacity: 0;
transform: scale(0.6);
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.check input:checked ~ .check__box {
background: var(--brand);
border-color: var(--brand);
}
.check input:checked ~ .check__box svg {
opacity: 1;
transform: scale(1);
}
.check input:focus-visible ~ .check__box {
box-shadow: 0 0 0 4px var(--brand-50);
}
.field--check.is-error .check__box {
border-color: var(--danger);
}
/* ── Actions ────────────────────────────────────────── */
.form__actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
font: inherit;
font-size: 15px;
font-weight: 600;
border-radius: var(--r-sm);
border: 1.5px solid transparent;
cursor: pointer;
transition:
transform 0.12s ease,
box-shadow 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--brand-50);
}
.btn--primary {
background: var(--brand);
color: var(--white);
box-shadow: var(--sh-1);
}
.btn--primary:hover {
background: var(--brand-d);
}
.btn--ghost {
background: var(--white);
color: var(--ink-2);
border-color: var(--line-2);
}
.btn--ghost:hover {
border-color: var(--ink-2);
background: #fafbff;
}
.form__status {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.form__status.is-error {
color: var(--danger);
}
.form__status.is-ok {
color: var(--ok);
}
/* ── Success panel ──────────────────────────────────── */
.success {
text-align: center;
padding: 18px 12px 6px;
animation: summaryIn 0.3s ease;
}
.success:focus-visible {
outline: none;
}
.success__badge {
display: inline-grid;
place-items: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--ok);
margin-bottom: 14px;
}
.success__title {
margin: 0 0 8px;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
}
.success__text {
margin: 0 auto 20px;
max-width: 42ch;
color: var(--muted);
font-size: 15px;
}
/* ── Toast ──────────────────────────────────────────── */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(16px);
max-width: calc(100vw - 40px);
padding: 12px 18px;
background: var(--ink);
color: var(--white);
font-size: 14px;
font-weight: 600;
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: 60;
}
.toast.is-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 520px) {
body {
padding: 24px 14px 48px;
}
.card {
padding: 22px 18px;
border-radius: var(--r-md);
}
.card__title {
font-size: 23px;
}
.form {
grid-template-columns: 1fr;
gap: 16px;
}
.field--half {
grid-column: 1 / -1;
}
.radios {
grid-template-columns: 1fr;
}
.form__actions {
flex-direction: column;
align-items: stretch;
}
.form__actions .btn {
width: 100%;
}
.form__status {
text-align: center;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}/* Form — Top error summary + jump-to-field
* GOV.UK style accessible error-summary pattern.
* - On invalid submit, an error summary (role=alert) lists every invalid field
* as links. The summary receives focus; activating a link focuses the field.
* - Each field shows its own inline error with aria-invalid + aria-describedby.
* - Fixing a field removes it from the summary live (on blur / input).
* - A valid submit shows a success confirmation panel.
*/
(function () {
"use strict";
var form = document.getElementById("signup-form");
if (!form) return;
var summary = document.getElementById("error-summary");
var summaryList = document.getElementById("summary-list");
var summaryHeading = document.getElementById("summary-heading");
var statusEl = document.getElementById("form-status");
var successPanel = document.getElementById("success-panel");
var successText = document.getElementById("success-text");
var restartBtn = document.getElementById("restart");
var toastEl = document.getElementById("toast");
/* Field order matters: summary links follow document/visual order. */
var ORDER = ["fullName", "email", "memberId", "age", "plan", "password", "terms"];
var LABELS = {
fullName: "Full name",
email: "Email address",
memberId: "Member ID",
age: "Age",
plan: "Membership plan",
password: "Password",
terms: "Terms acceptance",
};
/* Whether the form has been submitted once. Before that we don't nag
on every keystroke — classic "validate on submit, then live-correct". */
var submitted = false;
/* ── Toast helper ─────────────────────────────────── */
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.hidden = false;
/* force reflow so the transition runs from hidden */
void toastEl.offsetWidth;
toastEl.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-visible");
setTimeout(function () {
toastEl.hidden = true;
}, 240);
}, 2600);
}
/* ── Validators ───────────────────────────────────── */
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
var validators = {
fullName: function () {
var v = form.elements.fullName.value.trim();
if (!v) return "Enter your full name.";
if (v.length < 2) return "Your name looks too short.";
if (!/\s/.test(v)) return "Enter your first and last name.";
return "";
},
email: function () {
var v = form.elements.email.value.trim();
if (!v) return "Enter your email address.";
if (!EMAIL_RE.test(v)) return "Enter a valid email, like [email protected].";
return "";
},
memberId: function () {
var v = form.elements.memberId.value.trim();
if (!v) return "Enter your 6-digit member ID.";
if (!/^\d{6}$/.test(v)) return "Member ID must be exactly 6 digits.";
return "";
},
age: function () {
var raw = form.elements.age.value.trim();
if (!raw) return "Enter your age.";
if (!/^\d{1,3}$/.test(raw)) return "Age must be a whole number.";
var n = parseInt(raw, 10);
if (n < 18) return "You must be 18 or older to apply.";
if (n > 120) return "Enter a realistic age.";
return "";
},
plan: function () {
var checked = form.querySelector('input[name="plan"]:checked');
if (!checked) return "Choose a membership plan.";
return "";
},
password: function () {
var v = form.elements.password.value;
if (!v) return "Create a password.";
if (v.length < 8) return "Use at least 8 characters.";
if (!/\d/.test(v)) return "Include at least one number.";
return "";
},
terms: function () {
if (!form.elements.terms.checked) return "You must accept the terms to continue.";
return "";
},
};
/* The control that should receive focus when jumping to a field. */
function focusTargetFor(name) {
if (name === "plan") {
var checked = form.querySelector('input[name="plan"]:checked');
return checked || form.querySelector('input[name="plan"]');
}
return form.elements[name];
}
function wrapperFor(name) {
return form.querySelector('[data-field="' + name + '"]');
}
/* ── Per-field UI ─────────────────────────────────── */
function showFieldError(name, message) {
var wrap = wrapperFor(name);
var errEl = document.getElementById(name + "-error");
var control = focusTargetFor(name);
if (!wrap || !errEl) return;
wrap.classList.add("is-error");
wrap.classList.remove("is-valid");
errEl.textContent = message;
errEl.hidden = false;
if (control) {
control.setAttribute("aria-invalid", "true");
linkDescribedBy(name, control, true);
}
}
function clearFieldError(name, opts) {
var wrap = wrapperFor(name);
var errEl = document.getElementById(name + "-error");
var control = focusTargetFor(name);
if (!wrap || !errEl) return;
wrap.classList.remove("is-error");
errEl.hidden = true;
errEl.textContent = "";
if (control) {
control.removeAttribute("aria-invalid");
linkDescribedBy(name, control, false);
}
/* Mark valid (green tick) only for text-like inputs that have content. */
if (opts && opts.markValid && control && "value" in control && control.value) {
wrap.classList.add("is-valid");
} else {
wrap.classList.remove("is-valid");
}
}
/* Keep aria-describedby pointing at hint (+ error when present). */
function linkDescribedBy(name, control, hasError) {
var hintId = name + "-hint";
var errId = name + "-error";
var ids = [];
if (document.getElementById(hintId)) ids.push(hintId);
if (hasError) ids.push(errId);
if (ids.length) control.setAttribute("aria-describedby", ids.join(" "));
else if (document.getElementById(hintId)) control.setAttribute("aria-describedby", hintId);
else control.removeAttribute("aria-describedby");
}
/* ── Summary ──────────────────────────────────────── */
function buildSummary(errors) {
summaryList.innerHTML = "";
ORDER.forEach(function (name) {
if (!errors[name]) return;
var li = document.createElement("li");
var a = document.createElement("a");
a.href = "#" + name;
a.textContent = errors[name];
a.dataset.target = name;
li.appendChild(a);
summaryList.appendChild(li);
});
var count = summaryList.children.length;
summaryHeading.textContent =
count === 1
? "There is a problem with 1 field"
: "There are problems with " + count + " fields";
}
function hideSummary() {
summary.hidden = true;
summaryList.innerHTML = "";
}
/* Update the summary in place (after a fix), without stealing focus. */
function refreshSummary() {
if (summary.hidden) return;
var errors = collectErrors();
if (Object.keys(errors).length === 0) {
hideSummary();
setStatus("All fields look good. Ready to submit.", "ok");
return;
}
buildSummary(errors);
setStatus("", "");
}
function collectErrors() {
var errors = {};
ORDER.forEach(function (name) {
var msg = validators[name]();
if (msg) errors[name] = msg;
});
return errors;
}
function setStatus(msg, kind) {
statusEl.textContent = msg;
statusEl.classList.remove("is-error", "is-ok");
if (kind === "error") statusEl.classList.add("is-error");
if (kind === "ok") statusEl.classList.add("is-ok");
}
/* ── Summary link clicks → jump to field ──────────── */
summaryList.addEventListener("click", function (e) {
var link = e.target.closest("a[data-target]");
if (!link) return;
e.preventDefault();
var name = link.dataset.target;
var control = focusTargetFor(name);
var wrap = wrapperFor(name);
if (wrap) wrap.scrollIntoView({ behavior: "smooth", block: "center" });
if (control) {
/* slight delay lets the scroll start before focus */
setTimeout(function () {
control.focus({ preventScroll: true });
}, 60);
}
});
/* ── Live correction after first submit ───────────── */
function validateSingle(name, markValid) {
var msg = validators[name]();
if (msg) {
showFieldError(name, msg);
} else {
clearFieldError(name, { markValid: markValid });
}
return !msg;
}
ORDER.forEach(function (name) {
var wrap = wrapperFor(name);
if (!wrap) return;
var controls = wrap.querySelectorAll("input");
controls.forEach(function (control) {
/* Re-validate on blur once the form has been submitted once. */
control.addEventListener("blur", function () {
if (!submitted) return;
validateSingle(name, true);
refreshSummary();
});
/* If a field is already in error, clear it as soon as it becomes valid. */
var ev = control.type === "radio" || control.type === "checkbox" ? "change" : "input";
control.addEventListener(ev, function () {
if (!submitted) return;
var wasError = wrap.classList.contains("is-error");
if (wasError && !validators[name]()) {
clearFieldError(name, { markValid: control.type === "text" || control.type === "email" || control.type === "password" });
refreshSummary();
} else if (wrap.classList.contains("is-error")) {
/* still invalid — keep summary text fresh */
var m = validators[name]();
if (m) showFieldError(name, m);
}
});
});
});
/* Keep member ID / age numeric-only for a tidier UX. */
["memberId", "age"].forEach(function (name) {
form.elements[name].addEventListener("input", function () {
this.value = this.value.replace(/\D/g, "");
});
});
/* ── Submit ───────────────────────────────────────── */
form.addEventListener("submit", function (e) {
e.preventDefault();
submitted = true;
var errors = collectErrors();
/* Apply every field's state (errors + valid ticks). */
ORDER.forEach(function (name) {
if (errors[name]) showFieldError(name, errors[name]);
else clearFieldError(name, { markValid: true });
});
var keys = Object.keys(errors);
if (keys.length > 0) {
buildSummary(errors);
summary.hidden = false;
/* Move focus to the summary so screen-reader users hear it first. */
summary.focus();
summary.scrollIntoView({ behavior: "smooth", block: "start" });
setStatus(
keys.length === 1
? "1 field needs your attention."
: keys.length + " fields need your attention.",
"error"
);
toast("Check the highlighted fields.");
return;
}
/* Valid — show success state. */
hideSummary();
var name = form.elements.fullName.value.trim().split(/\s+/)[0];
var email = form.elements.email.value.trim();
successText.textContent =
"Thanks, " + name + ". We have emailed your confirmation to " + email +
" and your membership is being set up.";
form.hidden = true;
successPanel.hidden = false;
successPanel.focus();
toast("Application submitted successfully.");
});
/* ── Reset ────────────────────────────────────────── */
form.addEventListener("reset", function () {
submitted = false;
hideSummary();
setStatus("", "");
ORDER.forEach(function (name) {
var wrap = wrapperFor(name);
if (wrap) wrap.classList.remove("is-error", "is-valid");
var errEl = document.getElementById(name + "-error");
if (errEl) {
errEl.hidden = true;
errEl.textContent = "";
}
var control = focusTargetFor(name);
if (control) control.removeAttribute("aria-invalid");
});
toast("Form cleared.");
});
/* ── Restart from success panel ───────────────────── */
if (restartBtn) {
restartBtn.addEventListener("click", function () {
successPanel.hidden = true;
form.hidden = false;
form.reset();
submitted = false;
form.elements.fullName.focus();
});
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Form — Top error summary + jump-to-field</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="eyebrow">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.5l2 2 4-4M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"
/>
</svg>
Membership application
</span>
<h1 id="form-title" class="card__title">Open your Northwind account</h1>
<p class="card__lede">
Submit the form with mistakes and an error summary appears at the top.
Each entry links straight to the field that needs fixing, GOV.UK style.
</p>
</header>
<!-- Error summary: injected/toggled by JS, gets focus on submit-fail -->
<div
id="error-summary"
class="summary"
role="alert"
tabindex="-1"
hidden
>
<h2 class="summary__title">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2" />
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
d="M12 7.5v5.5M12 16.2v.2"
/>
</svg>
<span id="summary-heading">There is a problem</span>
</h2>
<ul class="summary__list" id="summary-list"></ul>
</div>
<form id="signup-form" class="form" novalidate>
<div class="field" data-field="fullName">
<label class="field__label" for="fullName">
Full name <span class="req" aria-hidden="true">*</span>
</label>
<p class="field__hint" id="fullName-hint">As it appears on your ID, e.g. Marisol Quintero.</p>
<p class="field__error" id="fullName-error" hidden></p>
<input
class="input"
id="fullName"
name="fullName"
type="text"
autocomplete="name"
placeholder="Marisol Quintero"
aria-describedby="fullName-hint"
required
/>
</div>
<div class="field" data-field="email">
<label class="field__label" for="email">
Email address <span class="req" aria-hidden="true">*</span>
</label>
<p class="field__hint" id="email-hint">We send your confirmation here. No spam, ever.</p>
<p class="field__error" id="email-error" hidden></p>
<input
class="input"
id="email"
name="email"
type="email"
autocomplete="email"
inputmode="email"
placeholder="[email protected]"
aria-describedby="email-hint"
required
/>
</div>
<div class="field field--half" data-field="memberId">
<label class="field__label" for="memberId">
Member ID <span class="req" aria-hidden="true">*</span>
</label>
<p class="field__hint" id="memberId-hint">Six digits from your welcome letter, e.g. 480293.</p>
<p class="field__error" id="memberId-error" hidden></p>
<input
class="input"
id="memberId"
name="memberId"
type="text"
inputmode="numeric"
autocomplete="off"
placeholder="480293"
maxlength="6"
aria-describedby="memberId-hint"
required
/>
</div>
<div class="field field--half" data-field="age">
<label class="field__label" for="age">
Age <span class="req" aria-hidden="true">*</span>
</label>
<p class="field__hint" id="age-hint">You must be 18 or older to apply.</p>
<p class="field__error" id="age-error" hidden></p>
<input
class="input"
id="age"
name="age"
type="text"
inputmode="numeric"
autocomplete="off"
placeholder="29"
maxlength="3"
aria-describedby="age-hint"
required
/>
</div>
<fieldset class="field field--group" data-field="plan">
<legend class="field__label">
Membership plan <span class="req" aria-hidden="true">*</span>
</legend>
<p class="field__hint" id="plan-hint">Pick the tier that fits — you can change later.</p>
<p class="field__error" id="plan-error" hidden></p>
<div class="radios" role="radiogroup" aria-describedby="plan-hint">
<label class="radio">
<input type="radio" name="plan" value="basic" aria-describedby="plan-hint" />
<span class="radio__box" aria-hidden="true"></span>
<span class="radio__text">
<strong>Basic</strong>
<small>Core access, monthly billing</small>
</span>
</label>
<label class="radio">
<input type="radio" name="plan" value="plus" aria-describedby="plan-hint" />
<span class="radio__box" aria-hidden="true"></span>
<span class="radio__text">
<strong>Plus</strong>
<small>Priority support + perks</small>
</span>
</label>
<label class="radio">
<input type="radio" name="plan" value="founder" aria-describedby="plan-hint" />
<span class="radio__box" aria-hidden="true"></span>
<span class="radio__text">
<strong>Founder</strong>
<small>Everything, billed yearly</small>
</span>
</label>
</div>
</fieldset>
<div class="field" data-field="password">
<label class="field__label" for="password">
Create a password <span class="req" aria-hidden="true">*</span>
</label>
<p class="field__hint" id="password-hint">At least 8 characters with a number.</p>
<p class="field__error" id="password-error" hidden></p>
<input
class="input"
id="password"
name="password"
type="password"
autocomplete="new-password"
placeholder="••••••••"
aria-describedby="password-hint"
required
/>
</div>
<div class="field field--check" data-field="terms">
<label class="check">
<input type="checkbox" id="terms" name="terms" aria-describedby="terms-error" />
<span class="check__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12.5l4.5 4.5L19 7"
/>
</svg>
</span>
<span class="check__text">
I accept the membership terms and privacy policy
<span class="req" aria-hidden="true">*</span>
</span>
</label>
<p class="field__error field__error--check" id="terms-error" hidden></p>
</div>
<div class="form__actions">
<button type="submit" class="btn btn--primary">
Create account
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12h14M13 6l6 6-6 6"
/>
</svg>
</button>
<button type="reset" class="btn btn--ghost">Reset</button>
<p class="form__status" id="form-status" role="status" aria-live="polite"></p>
</div>
</form>
<!-- Success confirmation, swapped in after a valid submit -->
<div class="success" id="success-panel" hidden tabindex="-1">
<span class="success__badge" aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="2" />
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M7 12.5l3.2 3.2L17 9"
/>
</svg>
</span>
<h2 class="success__title">Application received</h2>
<p class="success__text" id="success-text">
Thanks. We have emailed your confirmation and your account is being set up.
</p>
<button type="button" class="btn btn--ghost" id="restart">Start another application</button>
</div>
</section>
</main>
<!-- Toast region -->
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Top error summary + jump-to-field
A membership signup form that follows the GOV.UK error-summary pattern. When you submit with mistakes, an alert box appears at the top of the form listing every invalid field in document order. Each entry is a real link — activating it smooth-scrolls to the field and moves keyboard focus straight onto the control, so users never have to hunt for what went wrong. The summary itself receives focus on a failed submit, which means screen-reader users hear the full list of problems immediately.
Every field also carries its own inline state: a red border, a helper error message, aria-invalid="true", and an updated aria-describedby that points at both the hint and the error text. Validation is real and field-specific — full name, email format, a six-digit member ID, a minimum age of 18, a chosen plan, an 8-character password with a digit, and an accepted terms checkbox. After the first submit the form switches to live correction: fixing a field removes it from the summary, flips it to a green valid state, and refreshes the heading count without stealing focus.
A clean submit hides the form and reveals a personalised confirmation panel, while a lightweight toast helper surfaces transient feedback. The layout uses a two-column grid that stacks to a single column under 520px, with full focus, error, success, and disabled field states, and it respects prefers-reduced-motion.