Form — Long sectioned form (sticky nav)
A long single-page settings form split into five cards — Profile, Account, Notifications, Billing, and Security — with a sticky scroll-spy side nav that highlights the active section as you move through the page. Dirty-field tracking reveals a floating Save changes bar, real vanilla-JS validation surfaces inline errors and an accessible error summary on save, and a confirmation toast lands once everything is stored.
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 {
scroll-behavior: smooth;
}
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;
}
h1,
h2 {
margin: 0;
letter-spacing: -0.02em;
}
p {
margin: 0;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 40px 24px 140px;
}
/* ---------- Header ---------- */
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--brand);
margin-bottom: 6px;
}
.page-head h1 {
font-size: 30px;
font-weight: 800;
}
.lede {
margin-top: 8px;
color: var(--muted);
max-width: 46ch;
}
.lede strong {
color: var(--ink-2);
font-weight: 600;
}
.save-pill {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
padding: 7px 13px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--white);
box-shadow: var(--sh-1);
white-space: nowrap;
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.save-pill .ic {
width: 16px;
height: 16px;
}
.save-pill[data-state="saved"] {
color: var(--ok);
}
.save-pill[data-state="dirty"] {
color: var(--warn);
background: #fdf6ec;
border-color: rgba(217, 138, 43, 0.3);
}
.save-pill[data-state="saving"] {
color: var(--brand);
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 32px;
align-items: start;
}
/* ---------- Side nav ---------- */
.sidenav {
position: sticky;
top: 24px;
}
.navlist {
list-style: none;
margin: 0;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-1);
}
.navlink {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 600;
color: var(--ink-2);
text-decoration: none;
transition: background 0.18s, color 0.18s;
position: relative;
}
.navlink .ic {
width: 18px;
height: 18px;
color: var(--muted);
flex: none;
transition: color 0.18s;
}
.navlink:hover {
background: var(--bg);
color: var(--ink);
}
.navlink.is-active {
background: var(--brand-50);
color: var(--brand-700);
}
.navlink.is-active .ic {
color: var(--brand);
}
.navlink:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* ---------- Content / sections ---------- */
.content {
display: flex;
flex-direction: column;
gap: 24px;
min-width: 0;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
padding: 26px 28px;
}
.section {
scroll-margin-top: 24px;
}
.section:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 4px;
}
.section__head {
margin-bottom: 20px;
}
.section__head h2 {
font-size: 19px;
font-weight: 700;
}
.section__sub {
margin-top: 4px;
color: var(--muted);
font-size: 14px;
}
/* ---------- Error summary ---------- */
.errsummary {
display: flex;
gap: 12px;
padding: 16px 18px;
background: #fdeeec;
border: 1px solid rgba(212, 80, 62, 0.35);
border-radius: var(--r-md);
color: var(--danger);
}
.errsummary .ic {
width: 22px;
height: 22px;
flex: none;
margin-top: 1px;
}
.errsummary__title {
font-weight: 700;
font-size: 14px;
}
.errsummary__list {
margin: 6px 0 0;
padding-left: 18px;
font-size: 13.5px;
}
.errsummary__list a {
color: var(--danger);
font-weight: 600;
}
/* ---------- Fields ---------- */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.field--full {
grid-column: 1 / -1;
}
.field label {
font-size: 13.5px;
font-weight: 600;
color: var(--ink-2);
}
.req {
color: var(--danger);
}
.field input,
.field textarea,
.selectwrap select {
width: 100%;
font: inherit;
font-size: 14.5px;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
padding: 11px 13px;
transition: border-color 0.16s, box-shadow 0.16s, background 0.16s;
}
.field textarea {
resize: vertical;
min-height: 78px;
}
.field input::placeholder,
.field textarea::placeholder {
color: #a3a8bf;
}
.field input:hover,
.field textarea:hover,
.selectwrap select:hover {
border-color: #b7bcd0;
}
.field input:focus-visible,
.field textarea:focus-visible,
.selectwrap select:focus-visible {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.18);
}
.field input:disabled,
.field textarea:disabled {
background: var(--bg);
color: var(--muted);
cursor: not-allowed;
}
.help {
font-size: 12.5px;
color: var(--muted);
}
/* prefixed input (@handle) */
.prefixed {
display: flex;
align-items: stretch;
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--white);
overflow: hidden;
transition: border-color 0.16s, box-shadow 0.16s;
}
.prefixed .prefix {
display: flex;
align-items: center;
padding: 0 12px;
background: var(--bg);
color: var(--muted);
font-weight: 600;
border-right: 1.5px solid var(--line-2);
}
.prefixed input {
border: none;
border-radius: 0;
}
.prefixed input:focus-visible {
box-shadow: none;
}
.prefixed:focus-within {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.18);
}
/* select */
.selectwrap {
position: relative;
}
.selectwrap select {
appearance: none;
-webkit-appearance: none;
padding-right: 38px;
cursor: pointer;
}
.selectwrap .caret {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--muted);
pointer-events: none;
}
/* ---- error / success states ---- */
.field.has-error input,
.field.has-error textarea {
border-color: var(--danger);
background: #fdf7f6;
}
.field.has-error.prefixed-host .prefixed {
border-color: var(--danger);
background: #fdf7f6;
}
.field.has-error input:focus-visible,
.field.has-error textarea:focus-visible {
box-shadow: 0 0 0 3px rgba(212, 80, 62, 0.18);
}
.field.has-error .help {
color: var(--danger);
font-weight: 600;
}
.field.is-valid input,
.field.is-valid textarea {
border-color: var(--ok);
}
.field.is-valid .help {
color: var(--ok);
}
/* ---------- Switches ---------- */
.switchgroup,
.radiogroup {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 8px 18px 16px;
margin: 0 0 18px;
}
.switchgroup:last-child,
.radiogroup:last-child {
margin-bottom: 0;
}
.switchgroup legend,
.radiogroup legend {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
padding: 0 6px;
}
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
cursor: pointer;
border-top: 1px solid var(--line);
}
.switchgroup .switch:first-of-type {
border-top: none;
}
.switch--standalone {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 18px;
margin-top: 4px;
}
.switch__text {
display: flex;
flex-direction: column;
gap: 2px;
}
.switch__title {
font-size: 14.5px;
font-weight: 600;
color: var(--ink);
}
.switch__desc {
font-size: 13px;
color: var(--muted);
}
.switch input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.switch__track {
flex: none;
width: 42px;
height: 24px;
border-radius: 999px;
background: #c9cde0;
position: relative;
transition: background 0.2s;
}
.switch__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--white);
box-shadow: var(--sh-1);
transition: transform 0.2s;
}
.switch input:checked + .switch__track {
background: var(--brand);
}
.switch input:checked + .switch__track .switch__thumb {
transform: translateX(18px);
}
.switch input:focus-visible + .switch__track {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* ---------- Radios ---------- */
.radio {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 0;
cursor: pointer;
font-size: 14.5px;
color: var(--ink);
}
.radio input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.radio__mark {
flex: none;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--line-2);
position: relative;
transition: border-color 0.16s;
}
.radio__mark::after {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--brand);
transform: scale(0);
transition: transform 0.16s;
}
.radio input:checked + .radio__mark {
border-color: var(--brand);
}
.radio input:checked + .radio__mark::after {
transform: scale(1);
}
.radio input:focus-visible + .radio__mark {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.radiogroup .help {
margin-top: 6px;
}
/* ---------- Plan row ---------- */
.planrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: linear-gradient(180deg, var(--brand-50), var(--white));
margin-bottom: 18px;
}
.plan__badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brand-700);
}
.plan__name {
font-size: 20px;
font-weight: 800;
margin-top: 2px;
}
.plan__name span {
font-size: 13px;
font-weight: 500;
color: var(--muted);
}
.plan__meta {
font-size: 13px;
color: var(--muted);
margin-top: 2px;
}
/* ---------- Password meter ---------- */
.meter {
display: flex;
align-items: center;
gap: 10px;
}
.meter__bar {
flex: 1;
height: 6px;
border-radius: 999px;
background: #e4e6f0;
overflow: hidden;
}
.meter__fill {
display: block;
height: 100%;
width: 0;
border-radius: 999px;
background: var(--danger);
transition: width 0.25s, background 0.25s;
}
.meter__label {
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
min-width: 56px;
text-align: right;
}
/* ---------- Danger zone ---------- */
.dangerzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-top: 16px;
padding: 16px 18px;
border: 1px solid rgba(212, 80, 62, 0.3);
border-radius: var(--r-md);
background: #fdf7f6;
}
.dangerzone__title {
font-weight: 700;
font-size: 14.5px;
color: var(--danger);
}
.dangerzone__desc {
font-size: 13px;
color: var(--muted);
margin-top: 2px;
}
.content__foot {
text-align: center;
font-size: 13px;
color: var(--muted);
padding: 4px 0 8px;
}
/* ---------- Buttons ---------- */
.btn {
font: inherit;
font-size: 14px;
font-weight: 600;
border-radius: var(--r-sm);
padding: 10px 18px;
border: 1.5px solid transparent;
cursor: pointer;
transition: background 0.16s, border-color 0.16s, transform 0.06s, color 0.16s;
white-space: nowrap;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.btn--primary {
background: var(--brand);
color: var(--white);
}
.btn--primary:hover {
background: var(--brand-d);
}
.btn--primary:disabled {
opacity: 0.6;
cursor: progress;
}
.btn--ghost {
background: var(--white);
border-color: var(--line-2);
color: var(--ink-2);
}
.btn--ghost:hover {
background: var(--bg);
border-color: #b7bcd0;
}
.btn--danger {
background: var(--white);
border-color: rgba(212, 80, 62, 0.5);
color: var(--danger);
}
.btn--danger:hover {
background: var(--danger);
color: var(--white);
border-color: var(--danger);
}
/* ---------- Sticky save bar ---------- */
.savebar {
position: fixed;
left: 50%;
bottom: 22px;
transform: translate(-50%, 140%);
width: min(760px, calc(100% - 32px));
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 13px 16px 13px 20px;
background: var(--ink);
color: var(--white);
border-radius: 999px;
box-shadow: var(--sh-2), 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
opacity: 0;
transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.28s;
z-index: 30;
}
.savebar[data-open="true"] {
transform: translate(-50%, 0);
opacity: 1;
}
.savebar__msg {
display: inline-flex;
align-items: center;
gap: 9px;
font-size: 14px;
font-weight: 600;
}
.savebar__msg .ic {
width: 19px;
height: 19px;
color: var(--warn);
flex: none;
}
.savebar__actions {
display: flex;
gap: 8px;
}
.savebar .btn--ghost {
background: transparent;
border-color: rgba(255, 255, 255, 0.22);
color: #d7daea;
}
.savebar .btn--ghost:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.4);
color: var(--white);
}
/* ---------- Toasts ---------- */
.toasts {
position: fixed;
top: 18px;
right: 18px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 50;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--white);
border: 1px solid var(--line);
border-left: 4px solid var(--ok);
border-radius: var(--r-md);
box-shadow: var(--sh-2);
font-size: 14px;
font-weight: 600;
color: var(--ink);
max-width: 320px;
transform: translateX(120%);
transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
.toast.is-in {
transform: translateX(0);
}
.toast .ic {
width: 19px;
height: 19px;
flex: none;
color: var(--ok);
}
.toast[data-tone="error"] {
border-left-color: var(--danger);
}
.toast[data-tone="error"] .ic {
color: var(--danger);
}
.toast[data-tone="info"] {
border-left-color: var(--brand);
}
.toast[data-tone="info"] .ic {
color: var(--brand);
}
/* ---------- Responsive ---------- */
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
gap: 16px;
}
.sidenav {
position: sticky;
top: 0;
z-index: 20;
margin: 0 -24px;
padding: 8px 24px;
background: rgba(246, 247, 251, 0.92);
backdrop-filter: blur(8px);
}
.navlist {
flex-direction: row;
overflow-x: auto;
scrollbar-width: none;
border-radius: 999px;
padding: 5px;
}
.navlist::-webkit-scrollbar {
display: none;
}
.navlink {
white-space: nowrap;
}
.navlink span {
display: inline;
}
}
@media (max-width: 520px) {
.page {
padding: 24px 16px 130px;
}
.sidenav {
margin: 0 -16px;
padding: 8px 16px;
}
.page-head h1 {
font-size: 24px;
}
.grid {
grid-template-columns: 1fr;
}
.card {
padding: 20px 18px;
}
.navlink .ic {
width: 17px;
height: 17px;
}
.navlink span {
display: none;
}
.navlink.is-active span {
display: inline;
}
.savebar {
border-radius: var(--r-lg);
flex-direction: column;
align-items: stretch;
gap: 12px;
padding: 14px 16px;
}
.savebar__actions {
justify-content: stretch;
}
.savebar__actions .btn {
flex: 1;
text-align: center;
}
.toasts {
left: 12px;
right: 12px;
}
.toast {
max-width: none;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
}
}(function () {
"use strict";
var form = document.getElementById("settingsForm");
var navLinks = Array.prototype.slice.call(document.querySelectorAll(".navlink"));
var sections = navLinks
.map(function (l) { return document.getElementById(l.dataset.target); })
.filter(Boolean);
var saveBar = document.getElementById("saveBar");
var savePill = document.getElementById("savePill");
var saveBtn = document.getElementById("saveBtn");
var resetBtn = document.getElementById("resetBtn");
var dirtyCount = document.getElementById("dirtyCount");
var errSummary = document.getElementById("errSummary");
var errSummaryList = document.getElementById("errSummaryList");
var toastHost = document.getElementById("toasts");
/* ---------- Toast helper ---------- */
var ICONS = {
success: '<path d="M20 6 9 17l-5-5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>',
error: '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 7v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="16.4" r="1.1" fill="currentColor"/>',
info: '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 11v5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="7.8" r="1.1" fill="currentColor"/>'
};
function toast(msg, tone) {
tone = tone || "success";
var el = document.createElement("div");
el.className = "toast";
el.setAttribute("data-tone", tone);
el.setAttribute("role", tone === "error" ? "alert" : "status");
el.innerHTML =
'<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">' +
(ICONS[tone] || ICONS.info) +
"</svg><span>" + msg + "</span>";
toastHost.appendChild(el);
requestAnimationFrame(function () { el.classList.add("is-in"); });
setTimeout(function () {
el.classList.remove("is-in");
setTimeout(function () { el.remove(); }, 360);
}, 3200);
}
/* ---------- Dirty tracking ---------- */
var initial = {};
var fields = Array.prototype.slice.call(
form.querySelectorAll("input, textarea, select")
);
function valueOf(el) {
if (el.type === "checkbox") return el.checked ? "1" : "0";
if (el.type === "radio") return el.checked ? el.value : "";
return el.value;
}
function snapshot() {
initial = {};
fields.forEach(function (el) {
var key = el.type === "radio" ? el.name + ":" + el.value : (el.id || el.name);
initial[key] = valueOf(el);
});
}
function isDirty() {
return fields.some(function (el) {
var key = el.type === "radio" ? el.name + ":" + el.value : (el.id || el.name);
return initial[key] !== valueOf(el);
});
}
function refreshDirty() {
var dirty = isDirty();
saveBar.setAttribute("data-open", dirty ? "true" : "false");
savePill.setAttribute("data-state", dirty ? "dirty" : "saved");
savePill.lastChild.textContent = dirty ? " Unsaved changes" : " All changes saved";
if (dirty) {
dirtyCount.textContent = "You have unsaved changes";
}
return dirty;
}
/* ---------- Validation ---------- */
function fieldEl(input) {
return input.closest(".field");
}
function setError(input, message) {
var f = fieldEl(input);
if (!f) return;
f.classList.add("has-error");
f.classList.remove("is-valid");
input.setAttribute("aria-invalid", "true");
var help = f.querySelector(".help");
if (help) {
if (help.dataset.base === undefined) help.dataset.base = help.textContent;
help.textContent = message;
}
}
function clearError(input, markValid) {
var f = fieldEl(input);
if (!f) return;
f.classList.remove("has-error");
input.removeAttribute("aria-invalid");
var help = f.querySelector(".help");
if (help && help.dataset.base !== undefined) {
help.textContent = help.dataset.base;
}
if (markValid) f.classList.add("is-valid");
else f.classList.remove("is-valid");
}
// returns null if valid, or an error message string
function validateField(input) {
var v = (input.value || "").trim();
var id = input.id;
if (id === "fullName") {
if (!v) return "Full name is required.";
if (v.length < 2) return "Please enter your full name.";
}
if (id === "displayHandle") {
if (!v) return "A handle is required.";
if (!/^[a-z0-9.]{3,20}$/i.test(v)) return "3–20 chars: letters, numbers, dots only.";
}
if (id === "email") {
if (!v) return "Email address is required.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return "Enter a valid email address.";
}
if (id === "phone") {
if (v && !/^[+()\d\s-]{7,20}$/.test(v)) return "Enter a valid phone number.";
}
if (id === "cardName") {
if (!v) return "Name on card is required.";
}
if (id === "cardNumber") {
var digits = v.replace(/\s/g, "");
if (!digits) return "Card number is required.";
if (!/^\d{16}$/.test(digits)) return "Card number must be 16 digits.";
}
if (id === "cardZip") {
if (!v) return "Billing ZIP is required.";
if (!/^\d{5}$/.test(v)) return "Enter a 5-digit ZIP code.";
}
if (id === "newPassword") {
if (v && (v.length < 8 || !/\d/.test(v) || !/[a-zA-Z]/.test(v))) {
return "Min 8 chars with a letter and a number.";
}
}
if (id === "confirmPassword") {
var pw = (document.getElementById("newPassword").value || "");
if (pw && v !== pw) return "Passwords do not match.";
if (!pw && v) return "Enter the new password above first.";
}
return null;
}
// Section id -> friendly name, for the error summary
function sectionOf(input) {
var sec = input.closest(".section");
return sec ? sec.id : null;
}
function validateAll() {
var errors = [];
fields.forEach(function (el) {
if (el.type === "checkbox" || el.type === "radio" || el.tagName === "SELECT") return;
var msg = validateField(el);
if (msg) {
setError(el, msg);
errors.push({ input: el, msg: msg, section: sectionOf(el) });
} else if (el.value.trim() || el.hasAttribute("required")) {
clearError(el, el.value.trim().length > 0);
} else {
clearError(el, false);
}
});
return errors;
}
function showErrorSummary(errors) {
errSummaryList.innerHTML = "";
errors.forEach(function (e) {
var li = document.createElement("li");
var a = document.createElement("a");
a.href = "#" + e.input.id;
a.textContent =
(e.input.previousElementSibling && e.input.previousElementSibling.tagName === "LABEL"
? e.input.previousElementSibling.childNodes[0].textContent.trim()
: e.input.name) +
" — " + e.msg;
a.addEventListener("click", function (ev) {
ev.preventDefault();
focusField(e.input);
});
li.appendChild(a);
errSummaryList.appendChild(li);
});
errSummary.hidden = false;
errSummary.focus();
}
function focusField(input) {
var sec = input.closest(".section");
if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
setTimeout(function () { input.focus({ preventScroll: true }); }, 220);
}
/* ---------- Live field handlers ---------- */
fields.forEach(function (el) {
var ev = el.tagName === "SELECT" || el.type === "checkbox" || el.type === "radio"
? "change" : "input";
el.addEventListener(ev, function () {
refreshDirty();
});
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
el.addEventListener("blur", function () {
if (el.type === "checkbox" || el.type === "radio") return;
var msg = validateField(el);
if (msg) setError(el, msg);
else clearError(el, el.value.trim().length > 0 && el.hasAttribute("required"));
});
}
});
/* ---------- Card number auto-format ---------- */
var cardNumber = document.getElementById("cardNumber");
cardNumber.addEventListener("input", function () {
var digits = cardNumber.value.replace(/\D/g, "").slice(0, 16);
cardNumber.value = digits.replace(/(.{4})/g, "$1 ").trim();
});
/* ---------- Bio counter ---------- */
var bio = document.getElementById("bio");
var bioCount = document.getElementById("bioCount");
function updateBio() { bioCount.textContent = String(bio.value.length); }
bio.addEventListener("input", updateBio);
updateBio();
/* ---------- Password strength ---------- */
var pw = document.getElementById("newPassword");
var meterWrap = document.getElementById("pwMeterWrap");
var meterFill = document.getElementById("pwMeterFill");
var meterLabel = document.getElementById("pwStrength");
pw.addEventListener("input", function () {
var v = pw.value;
if (!v) { meterWrap.hidden = true; return; }
meterWrap.hidden = false;
var score = 0;
if (v.length >= 8) score++;
if (v.length >= 12) score++;
if (/[a-z]/.test(v) && /[A-Z]/.test(v)) score++;
if (/\d/.test(v)) score++;
if (/[^A-Za-z0-9]/.test(v)) score++;
var pct = Math.min(100, (score / 5) * 100);
var tone, label;
if (score <= 1) { tone = "var(--danger)"; label = "Weak"; }
else if (score <= 3) { tone = "var(--warn)"; label = "Fair"; }
else { tone = "var(--ok)"; label = "Strong"; }
meterFill.style.width = pct + "%";
meterFill.style.background = tone;
meterLabel.textContent = label;
});
/* ---------- Scroll spy ---------- */
function setActive(id) {
navLinks.forEach(function (l) {
var on = l.dataset.target === id;
l.classList.toggle("is-active", on);
if (on) l.setAttribute("aria-current", "true");
else l.removeAttribute("aria-current");
});
}
var spy = null;
if ("IntersectionObserver" in window) {
var visible = {};
spy = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
visible[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0;
});
var best = null, bestRatio = 0;
sections.forEach(function (s) {
var r = visible[s.id] || 0;
if (r > bestRatio) { bestRatio = r; best = s.id; }
});
if (best) setActive(best);
}, { rootMargin: "-20% 0px -55% 0px", threshold: [0, 0.25, 0.5, 0.75, 1] });
sections.forEach(function (s) { spy.observe(s); });
}
// Clicking a nav link: smooth scroll + immediate highlight + focus target
navLinks.forEach(function (link) {
link.addEventListener("click", function (ev) {
ev.preventDefault();
var id = link.dataset.target;
var target = document.getElementById(id);
if (!target) return;
setActive(id);
target.scrollIntoView({ behavior: "smooth", block: "start" });
setTimeout(function () { target.focus({ preventScroll: true }); }, 240);
});
});
/* ---------- Misc buttons ---------- */
document.getElementById("changePlanBtn").addEventListener("click", function () {
toast("Plan management opens in billing portal.", "info");
});
document.getElementById("signOutAllBtn").addEventListener("click", function () {
toast("Signed out of all other sessions.", "info");
});
/* ---------- Discard ---------- */
resetBtn.addEventListener("click", function () {
fields.forEach(function (el) {
var key = el.type === "radio" ? el.name + ":" + el.value : (el.id || el.name);
var prev = initial[key];
if (el.type === "checkbox") el.checked = prev === "1";
else if (el.type === "radio") { if (prev) el.checked = true; }
else el.value = prev;
clearError(el, false);
});
errSummary.hidden = true;
meterWrap.hidden = true;
updateBio();
refreshDirty();
toast("Changes discarded.", "info");
});
/* ---------- Submit / Save ---------- */
form.addEventListener("submit", function (ev) {
ev.preventDefault();
var errors = validateAll();
if (errors.length) {
showErrorSummary(errors);
focusField(errors[0].input);
toast(errors.length + " field" + (errors.length > 1 ? "s" : "") + " need attention.", "error");
return;
}
errSummary.hidden = true;
// Simulated async save
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
savePill.setAttribute("data-state", "saving");
savePill.lastChild.textContent = " Saving…";
setTimeout(function () {
snapshot();
saveBtn.disabled = false;
saveBtn.textContent = "Save changes";
saveBar.setAttribute("data-open", "false");
savePill.setAttribute("data-state", "saved");
savePill.lastChild.textContent = " All changes saved";
// clear transient password/meter
pw.value = "";
document.getElementById("confirmPassword").value = "";
meterWrap.hidden = true;
fields.forEach(function (el) { clearError(el, false); });
toast("Settings saved successfully.", "success");
}, 900);
});
/* ---------- Warn on unload if dirty ---------- */
window.addEventListener("beforeunload", function (e) {
if (isDirty()) {
e.preventDefault();
e.returnValue = "";
}
});
/* ---------- Init ---------- */
snapshot();
refreshDirty();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Long sectioned form — sticky nav</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>
<div class="page">
<header class="page-head">
<div class="page-head__text">
<p class="eyebrow">Account</p>
<h1>Workspace settings</h1>
<p class="lede">Update your profile, security, and billing preferences for <strong>Helio Studio</strong>.</p>
</div>
<div class="page-head__meta" aria-live="polite">
<span class="save-pill" id="savePill" data-state="saved">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
All changes saved
</span>
</div>
</header>
<div class="layout">
<!-- Sticky section nav -->
<aside class="sidenav" aria-label="Settings sections">
<nav class="sidenav__inner">
<ul class="navlist" id="navList" role="list">
<li>
<a class="navlink is-active" href="#sec-profile" data-target="sec-profile" aria-current="true">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 20c0-3.3 3.6-6 8-6s8 2.7 8 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span>Profile</span>
</a>
</li>
<li>
<a class="navlink" href="#sec-account" data-target="sec-account">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M3 9h18" stroke="currentColor" stroke-width="2"/></svg>
<span>Account</span>
</a>
</li>
<li>
<a class="navlink" href="#sec-notifications" data-target="sec-notifications">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M10 21a2 2 0 0 0 4 0" fill="none" stroke="currentColor" stroke-width="2"/></svg>
<span>Notifications</span>
</a>
</li>
<li>
<a class="navlink" href="#sec-billing" data-target="sec-billing">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2.5 10h19" stroke="currentColor" stroke-width="2"/><path d="M6 15h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span>Billing</span>
</a>
</li>
<li>
<a class="navlink" href="#sec-security" data-target="sec-security">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 5 6v6c0 4.4 3 7.6 7 9 4-1.4 7-4.6 7-9V6l-7-3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M9 12l2 2 4-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Security</span>
</a>
</li>
</ul>
</nav>
</aside>
<!-- Form sections -->
<form class="content" id="settingsForm" novalidate>
<!-- Error summary -->
<div class="errsummary" id="errSummary" role="alert" tabindex="-1" hidden>
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 7v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="16.5" r="1.2" fill="currentColor"/></svg>
<div>
<p class="errsummary__title">Please fix the following before saving</p>
<ul class="errsummary__list" id="errSummaryList"></ul>
</div>
</div>
<!-- PROFILE -->
<section class="card section" id="sec-profile" aria-labelledby="h-profile" tabindex="-1">
<div class="section__head">
<h2 id="h-profile">Profile</h2>
<p class="section__sub">How you appear to teammates and clients.</p>
</div>
<div class="grid">
<div class="field">
<label for="fullName">Full name <span class="req" aria-hidden="true">*</span></label>
<input id="fullName" name="fullName" type="text" autocomplete="name" placeholder="Mara Quintero" value="Mara Quintero" aria-describedby="fullName-help" required />
<p class="help" id="fullName-help">Shown on your public profile and comments.</p>
</div>
<div class="field">
<label for="displayHandle">Handle <span class="req" aria-hidden="true">*</span></label>
<div class="prefixed">
<span class="prefix" aria-hidden="true">@</span>
<input id="displayHandle" name="displayHandle" type="text" autocomplete="off" placeholder="mara" value="mara.q" aria-describedby="displayHandle-help" required />
</div>
<p class="help" id="displayHandle-help">Letters, numbers, dots — 3 to 20 characters.</p>
</div>
<div class="field field--full">
<label for="bio">Bio</label>
<textarea id="bio" name="bio" rows="3" maxlength="160" placeholder="Product designer focused on calm, accessible interfaces." aria-describedby="bio-help">Product designer at Helio Studio. I care about calm, accessible interfaces.</textarea>
<p class="help" id="bio-help"><span id="bioCount">0</span>/160 characters</p>
</div>
<div class="field">
<label for="timezone">Time zone</label>
<div class="selectwrap">
<select id="timezone" name="timezone" aria-describedby="timezone-help">
<option value="utc-8">Pacific — UTC−8</option>
<option value="utc-5" selected>Eastern — UTC−5</option>
<option value="utc+0">London — UTC±0</option>
<option value="utc+1">Central Europe — UTC+1</option>
<option value="utc+9">Tokyo — UTC+9</option>
</select>
<svg class="caret" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<p class="help" id="timezone-help">Used to schedule digests and reminders.</p>
</div>
</div>
</section>
<!-- ACCOUNT -->
<section class="card section" id="sec-account" aria-labelledby="h-account" tabindex="-1">
<div class="section__head">
<h2 id="h-account">Account</h2>
<p class="section__sub">Contact details and login email.</p>
</div>
<div class="grid">
<div class="field">
<label for="email">Email address <span class="req" aria-hidden="true">*</span></label>
<input id="email" name="email" type="email" autocomplete="email" placeholder="[email protected]" value="[email protected]" aria-describedby="email-help" required />
<p class="help" id="email-help">We send sign-in links and receipts here.</p>
</div>
<div class="field">
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" placeholder="+1 555 010 4477" value="" aria-describedby="phone-help" />
<p class="help" id="phone-help">Optional — used only for security alerts.</p>
</div>
<div class="field field--full">
<label for="language">Preferred language</label>
<div class="selectwrap">
<select id="language" name="language">
<option value="en" selected>English (US)</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="ja">日本語</option>
</select>
<svg class="caret" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
</section>
<!-- NOTIFICATIONS -->
<section class="card section" id="sec-notifications" aria-labelledby="h-notifications" tabindex="-1">
<div class="section__head">
<h2 id="h-notifications">Notifications</h2>
<p class="section__sub">Choose what reaches your inbox and devices.</p>
</div>
<fieldset class="switchgroup">
<legend>Email</legend>
<label class="switch">
<span class="switch__text">
<span class="switch__title">Product updates</span>
<span class="switch__desc">New features and improvements, about twice a month.</span>
</span>
<input type="checkbox" name="notifyUpdates" checked />
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
</label>
<label class="switch">
<span class="switch__text">
<span class="switch__title">Comment mentions</span>
<span class="switch__desc">When a teammate @mentions you on a file.</span>
</span>
<input type="checkbox" name="notifyMentions" checked />
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
</label>
<label class="switch">
<span class="switch__text">
<span class="switch__title">Weekly digest</span>
<span class="switch__desc">A Monday summary of activity across your projects.</span>
</span>
<input type="checkbox" name="notifyDigest" />
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
</label>
</fieldset>
<fieldset class="radiogroup" aria-describedby="cadence-help">
<legend>Push cadence</legend>
<label class="radio">
<input type="radio" name="cadence" value="instant" checked />
<span class="radio__mark" aria-hidden="true"></span>
<span class="radio__label">Instant — as it happens</span>
</label>
<label class="radio">
<input type="radio" name="cadence" value="hourly" />
<span class="radio__mark" aria-hidden="true"></span>
<span class="radio__label">Hourly batch</span>
</label>
<label class="radio">
<input type="radio" name="cadence" value="off" />
<span class="radio__mark" aria-hidden="true"></span>
<span class="radio__label">Off — email only</span>
</label>
<p class="help" id="cadence-help">Applies to mobile and desktop push.</p>
</fieldset>
</section>
<!-- BILLING -->
<section class="card section" id="sec-billing" aria-labelledby="h-billing" tabindex="-1">
<div class="section__head">
<h2 id="h-billing">Billing</h2>
<p class="section__sub">Plan and payment details.</p>
</div>
<div class="planrow">
<div class="plan">
<span class="plan__badge">Current plan</span>
<p class="plan__name">Team — $24<span>/seat · mo</span></p>
<p class="plan__meta">5 of 8 seats used · renews May 1, 2026</p>
</div>
<button type="button" class="btn btn--ghost" id="changePlanBtn">Change plan</button>
</div>
<div class="grid">
<div class="field field--full">
<label for="cardName">Name on card <span class="req" aria-hidden="true">*</span></label>
<input id="cardName" name="cardName" type="text" autocomplete="cc-name" placeholder="Mara Quintero" value="Mara Quintero" required aria-describedby="cardName-help" />
<p class="help" id="cardName-help">Exactly as printed on the card.</p>
</div>
<div class="field">
<label for="cardNumber">Card number <span class="req" aria-hidden="true">*</span></label>
<input id="cardNumber" name="cardNumber" type="text" inputmode="numeric" autocomplete="cc-number" placeholder="4242 4242 4242 4242" maxlength="19" required aria-describedby="cardNumber-help" />
<p class="help" id="cardNumber-help">16 digits, spaces added automatically.</p>
</div>
<div class="field">
<label for="cardZip">Billing ZIP <span class="req" aria-hidden="true">*</span></label>
<input id="cardZip" name="cardZip" type="text" inputmode="numeric" autocomplete="postal-code" placeholder="94107" maxlength="5" value="94107" required aria-describedby="cardZip-help" />
<p class="help" id="cardZip-help">5-digit postal code.</p>
</div>
</div>
</section>
<!-- SECURITY -->
<section class="card section" id="sec-security" aria-labelledby="h-security" tabindex="-1">
<div class="section__head">
<h2 id="h-security">Security</h2>
<p class="section__sub">Protect your account and active sessions.</p>
</div>
<div class="grid">
<div class="field field--full">
<label for="newPassword">New password</label>
<input id="newPassword" name="newPassword" type="password" autocomplete="new-password" placeholder="Leave blank to keep current" aria-describedby="newPassword-help pwStrength" />
<div class="meter" id="pwMeterWrap" hidden>
<div class="meter__bar"><span class="meter__fill" id="pwMeterFill"></span></div>
<span class="meter__label" id="pwStrength" aria-live="polite">Strength</span>
</div>
<p class="help" id="newPassword-help">At least 8 characters with a number and a letter.</p>
</div>
<div class="field field--full">
<label for="confirmPassword">Confirm new password</label>
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" placeholder="Re-enter new password" aria-describedby="confirmPassword-help" />
<p class="help" id="confirmPassword-help">Must match the password above.</p>
</div>
</div>
<label class="switch switch--standalone">
<span class="switch__text">
<span class="switch__title">Two-factor authentication</span>
<span class="switch__desc">Require a one-time code from your authenticator app on sign-in.</span>
</span>
<input type="checkbox" name="twoFactor" checked />
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
</label>
<div class="dangerzone">
<div>
<p class="dangerzone__title">Sign out everywhere</p>
<p class="dangerzone__desc">End all sessions on other browsers and devices.</p>
</div>
<button type="button" class="btn btn--danger" id="signOutAllBtn">Sign out all</button>
</div>
</section>
<div class="content__foot">You're up to date with everything across these five sections.</div>
</form>
</div>
<!-- Sticky save bar -->
<div class="savebar" id="saveBar" data-open="false" role="region" aria-label="Unsaved changes">
<span class="savebar__msg">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 8v4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="16.2" r="1.1" fill="currentColor"/></svg>
<span id="dirtyCount">You have unsaved changes</span>
</span>
<div class="savebar__actions">
<button type="button" class="btn btn--ghost" id="resetBtn">Discard</button>
<button type="submit" class="btn btn--primary" id="saveBtn" form="settingsForm">Save changes</button>
</div>
</div>
<!-- Toast region -->
<div class="toasts" id="toasts" aria-live="polite" aria-atomic="false"></div>
</div>
<script src="script.js"></script>
</body>
</html>Long sectioned form (sticky nav)
A workspace settings page broken into five card sections — Profile, Account, Notifications, Billing, and Security — laid out beside a sticky left side-nav. An IntersectionObserver scroll-spy keeps the active nav item in sync as you scroll, and clicking any item smoothly scrolls to that card while moving focus to it for keyboard users. On narrow screens the side-nav collapses into a horizontal, scrollable tab strip pinned to the top.
Every field carries a clear label, placeholder, and helper text, with toggles, radios, a live character counter, a card-number auto-formatter, and a password-strength meter. The moment any field changes, the form is marked dirty: a header pill flips to “Unsaved changes” and a floating Save changes bar slides up with Discard and Save actions. The browser also warns before you navigate away with pending edits.
Saving runs real validation — required fields, email and handle patterns, a 16-digit card check, ZIP and password rules, and a confirm-password match. Failures populate an accessible role="alert" error summary with jump links, mark fields with aria-invalid plus inline messages, and scroll focus to the first problem. A clean pass simulates an async save, resets the dirty state, and confirms with a success toast.