// Checkout form (demo): nothing is sent and no card is charged. On submit every field is checked with the
// browser's own validity rules; a failing field gets aria-invalid and the message from its data-missing or
// data-bad attribute in the element with id "<field id>-err" (already in its aria-describedby), and focus
// moves to the first problem. [data-ship-option] radios update shipping and the total in the summary.
document.querySelectorAll('form[data-checkout]').forEach(function (form) {
var money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
var cents = function (v) { return Math.round(parseFloat(v) * 100); };
var fmt = function (c) { return money.format(c / 100); };
var rate = parseFloat(form.dataset.tax || 0), status = form.querySelector('[data-status]');
function put(sel, text) { form.querySelectorAll(sel).forEach(function (el) { el.textContent = text; }); }
function render() {
var sub = cents(form.dataset.subtotal), opt = form.querySelector('[data-ship-option]:checked');
var ship = opt ? cents(opt.dataset.price) : 0, tax = Math.round(sub * rate / 100), total = sub + ship + tax;
put('[data-subtotal]', fmt(sub)); put('[data-tax]', fmt(tax)); put('[data-total]', fmt(total));
put('[data-shipping]', ship ? fmt(ship) : 'Free');
if (opt) put('[data-ship-label]', opt.dataset.label);
return total;
}
function check(field) {
var v = field.validity, msg = v.valid ? '' : v.valueMissing ? field.dataset.missing : (field.dataset.bad || field.validationMessage);
var out = document.getElementById(field.id + '-err');
field.classList.toggle('is-invalid', !!msg);
if (msg) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
if (out) out.textContent = msg || '';
return !msg;
}
form.noValidate = true;
form.addEventListener('change', function (e) {
if (!e.target.matches('[data-ship-option]')) return;
var total = render();
if (status) status.textContent = e.target.dataset.label + ' selected. Total ' + fmt(total) + '.';
});
form.addEventListener('input', function (e) { if (e.target.hasAttribute('aria-invalid')) check(e.target); });
form.addEventListener('submit', function (e) {
e.preventDefault(); // demo only: nothing is sent anywhere
var bad = Array.prototype.filter.call(form.elements, function (f) { return f.willValidate && f.id && !check(f); });
if (bad.length) { if (status) status.textContent = ''; bad[0].focus(); return; }
if (status) status.textContent = 'Demo order placed. Nothing was charged: this form is not connected to a payment provider.';
});
render();
});
// Multi-step checkout: one form split into [data-step-panel] sections. Continue checks only the fields in the
// current step, then shows the next one, moves aria-current="step" in the progress list and puts focus on the
// new step's heading. Without the script every step stays visible and the form works as one long page.
document.querySelectorAll('form[data-steps]').forEach(function (form) {
var panels = [].slice.call(form.querySelectorAll('[data-step-panel]'));
var marks = [].slice.call(form.querySelectorAll('[data-step-mark]'));
var at = 0;
function check(field) {
var v = field.validity, msg = v.valid ? '' : v.valueMissing ? field.dataset.missing : (field.dataset.bad || field.validationMessage);
var out = document.getElementById(field.id + '-err');
field.classList.toggle('is-invalid', !!msg);
if (msg) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
if (out) out.textContent = msg || '';
return !msg;
}
function show(n, focus) {
at = n;
panels.forEach(function (p, i) { p.hidden = i !== n; });
marks.forEach(function (m, i) {
if (i === n) m.setAttribute('aria-current', 'step'); else m.removeAttribute('aria-current');
m.dataset.state = i < n ? 'done' : i === n ? 'current' : 'next';
m.querySelector('[data-step-sr]').textContent = i < n ? ', completed' : '';
});
form.querySelectorAll('[data-review]').forEach(function (el) {
var f = document.getElementById(el.dataset.review);
el.textContent = f && f.value.trim() ? f.value.trim() : 'Not given';
});
form.querySelectorAll('[data-review-card]').forEach(function (el) {
var digits = document.getElementById(el.dataset.reviewCard).value.replace(/\D/g, '');
el.textContent = digits ? 'Card ending ' + digits.slice(-4) : 'Not given';
});
if (focus) panels[n].querySelector('[tabindex="-1"]').focus();
}
form.querySelectorAll('[data-next],[data-prev]').forEach(function (b) { b.hidden = false; });
form.addEventListener('click', function (e) {
if (e.target.closest('[data-prev]')) return show(at - 1, true);
if (!e.target.closest('[data-next]')) return;
var bad = [].filter.call(panels[at].querySelectorAll('input, select, textarea'), function (f) { return f.willValidate && f.id && !check(f); });
if (bad.length) bad[0].focus(); else show(at + 1, true);
});
show(0, false);
});
// Checkout form (demo): nothing is sent and no card is charged. On submit every field is checked with the
// browser's own validity rules; a failing field gets aria-invalid and the message from its data-missing or
// data-bad attribute in the element with id "<field id>-err" (already in its aria-describedby), and focus
// moves to the first problem. [data-ship-option] radios update shipping and the total in the summary.
document.querySelectorAll('form[data-checkout]').forEach(function (form) {
var money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
var cents = function (v) { return Math.round(parseFloat(v) * 100); };
var fmt = function (c) { return money.format(c / 100); };
var rate = parseFloat(form.dataset.tax || 0), status = form.querySelector('[data-status]');
function put(sel, text) { form.querySelectorAll(sel).forEach(function (el) { el.textContent = text; }); }
function render() {
var sub = cents(form.dataset.subtotal), opt = form.querySelector('[data-ship-option]:checked');
var ship = opt ? cents(opt.dataset.price) : 0, tax = Math.round(sub * rate / 100), total = sub + ship + tax;
put('[data-subtotal]', fmt(sub)); put('[data-tax]', fmt(tax)); put('[data-total]', fmt(total));
put('[data-shipping]', ship ? fmt(ship) : 'Free');
if (opt) put('[data-ship-label]', opt.dataset.label);
return total;
}
function check(field) {
var v = field.validity, msg = v.valid ? '' : v.valueMissing ? field.dataset.missing : (field.dataset.bad || field.validationMessage);
var out = document.getElementById(field.id + '-err');
field.classList.toggle('is-invalid', !!msg);
if (msg) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
if (out) out.textContent = msg || '';
return !msg;
}
form.noValidate = true;
form.addEventListener('change', function (e) {
if (!e.target.matches('[data-ship-option]')) return;
var total = render();
if (status) status.textContent = e.target.dataset.label + ' selected. Total ' + fmt(total) + '.';
});
form.addEventListener('input', function (e) { if (e.target.hasAttribute('aria-invalid')) check(e.target); });
form.addEventListener('submit', function (e) {
e.preventDefault(); // demo only: nothing is sent anywhere
var bad = Array.prototype.filter.call(form.elements, function (f) { return f.willValidate && f.id && !check(f); });
if (bad.length) { if (status) status.textContent = ''; bad[0].focus(); return; }
if (status) status.textContent = 'Demo order placed. Nothing was charged: this form is not connected to a payment provider.';
});
render();
});
// Multi-step checkout: one form split into [data-step-panel] sections. Continue checks only the fields in the
// current step, then shows the next one, moves aria-current="step" in the progress list and puts focus on the
// new step's heading. Without the script every step stays visible and the form works as one long page.
document.querySelectorAll('form[data-steps]').forEach(function (form) {
var panels = [].slice.call(form.querySelectorAll('[data-step-panel]'));
var marks = [].slice.call(form.querySelectorAll('[data-step-mark]'));
var at = 0;
function check(field) {
var v = field.validity, msg = v.valid ? '' : v.valueMissing ? field.dataset.missing : (field.dataset.bad || field.validationMessage);
var out = document.getElementById(field.id + '-err');
field.classList.toggle('is-invalid', !!msg);
if (msg) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
if (out) out.textContent = msg || '';
return !msg;
}
function show(n, focus) {
at = n;
panels.forEach(function (p, i) { p.hidden = i !== n; });
marks.forEach(function (m, i) {
if (i === n) m.setAttribute('aria-current', 'step'); else m.removeAttribute('aria-current');
m.dataset.state = i < n ? 'done' : i === n ? 'current' : 'next';
m.querySelector('[data-step-sr]').textContent = i < n ? ', completed' : '';
});
form.querySelectorAll('[data-review]').forEach(function (el) {
var f = document.getElementById(el.dataset.review);
el.textContent = f && f.value.trim() ? f.value.trim() : 'Not given';
});
form.querySelectorAll('[data-review-card]').forEach(function (el) {
var digits = document.getElementById(el.dataset.reviewCard).value.replace(/\D/g, '');
el.textContent = digits ? 'Card ending ' + digits.slice(-4) : 'Not given';
});
if (focus) panels[n].querySelector('[tabindex="-1"]').focus();
}
form.querySelectorAll('[data-next],[data-prev]').forEach(function (b) { b.hidden = false; });
form.addEventListener('click', function (e) {
if (e.target.closest('[data-prev]')) return show(at - 1, true);
if (!e.target.closest('[data-next]')) return;
var bad = [].filter.call(panels[at].querySelectorAll('input, select, textarea'), function (f) { return f.willValidate && f.id && !check(f); });
if (bad.length) bad[0].focus(); else show(at + 1, true);
});
show(0, false);
});
// Checkout form (demo): nothing is sent and no card is charged. On submit every field is checked with the
// browser's own validity rules; a failing field gets aria-invalid and the message from its data-missing or
// data-bad attribute in the element with id "<field id>-err" (already in its aria-describedby), and focus
// moves to the first problem. [data-ship-option] radios update shipping and the total in the summary.
document.querySelectorAll('form[data-checkout]').forEach(function (form) {
var money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
var cents = function (v) { return Math.round(parseFloat(v) * 100); };
var fmt = function (c) { return money.format(c / 100); };
var rate = parseFloat(form.dataset.tax || 0), status = form.querySelector('[data-status]');
function put(sel, text) { form.querySelectorAll(sel).forEach(function (el) { el.textContent = text; }); }
function render() {
var sub = cents(form.dataset.subtotal), opt = form.querySelector('[data-ship-option]:checked');
var ship = opt ? cents(opt.dataset.price) : 0, tax = Math.round(sub * rate / 100), total = sub + ship + tax;
put('[data-subtotal]', fmt(sub)); put('[data-tax]', fmt(tax)); put('[data-total]', fmt(total));
put('[data-shipping]', ship ? fmt(ship) : 'Free');
if (opt) put('[data-ship-label]', opt.dataset.label);
return total;
}
function check(field) {
var v = field.validity, msg = v.valid ? '' : v.valueMissing ? field.dataset.missing : (field.dataset.bad || field.validationMessage);
var out = document.getElementById(field.id + '-err');
field.classList.toggle('is-invalid', !!msg);
if (msg) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
if (out) out.textContent = msg || '';
return !msg;
}
form.noValidate = true;
form.addEventListener('change', function (e) {
if (!e.target.matches('[data-ship-option]')) return;
var total = render();
if (status) status.textContent = e.target.dataset.label + ' selected. Total ' + fmt(total) + '.';
});
form.addEventListener('input', function (e) { if (e.target.hasAttribute('aria-invalid')) check(e.target); });
form.addEventListener('submit', function (e) {
e.preventDefault(); // demo only: nothing is sent anywhere
var bad = Array.prototype.filter.call(form.elements, function (f) { return f.willValidate && f.id && !check(f); });
if (bad.length) { if (status) status.textContent = ''; bad[0].focus(); return; }
if (status) status.textContent = 'Demo order placed. Nothing was charged: this form is not connected to a payment provider.';
});
render();
});
// Multi-step checkout: one form split into [data-step-panel] sections. Continue checks only the fields in the
// current step, then shows the next one, moves aria-current="step" in the progress list and puts focus on the
// new step's heading. Without the script every step stays visible and the form works as one long page.
document.querySelectorAll('form[data-steps]').forEach(function (form) {
var panels = [].slice.call(form.querySelectorAll('[data-step-panel]'));
var marks = [].slice.call(form.querySelectorAll('[data-step-mark]'));
var at = 0;
function check(field) {
var v = field.validity, msg = v.valid ? '' : v.valueMissing ? field.dataset.missing : (field.dataset.bad || field.validationMessage);
var out = document.getElementById(field.id + '-err');
field.classList.toggle('is-invalid', !!msg);
if (msg) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
if (out) out.textContent = msg || '';
return !msg;
}
function show(n, focus) {
at = n;
panels.forEach(function (p, i) { p.hidden = i !== n; });
marks.forEach(function (m, i) {
if (i === n) m.setAttribute('aria-current', 'step'); else m.removeAttribute('aria-current');
m.dataset.state = i < n ? 'done' : i === n ? 'current' : 'next';
m.querySelector('[data-step-sr]').textContent = i < n ? ', completed' : '';
});
form.querySelectorAll('[data-review]').forEach(function (el) {
var f = document.getElementById(el.dataset.review);
el.textContent = f && f.value.trim() ? f.value.trim() : 'Not given';
});
form.querySelectorAll('[data-review-card]').forEach(function (el) {
var digits = document.getElementById(el.dataset.reviewCard).value.replace(/\D/g, '');
el.textContent = digits ? 'Card ending ' + digits.slice(-4) : 'Not given';
});
if (focus) panels[n].querySelector('[tabindex="-1"]').focus();
}
form.querySelectorAll('[data-next],[data-prev]').forEach(function (b) { b.hidden = false; });
form.addEventListener('click', function (e) {
if (e.target.closest('[data-prev]')) return show(at - 1, true);
if (!e.target.closest('[data-next]')) return;
var bad = [].filter.call(panels[at].querySelectorAll('input, select, textarea'), function (f) { return f.willValidate && f.id && !check(f); });
if (bad.length) bad[0].focus(); else show(at + 1, true);
});
show(0, false);
});
About this set
A multi-step checkout for a jewellery studio and two other ways to show checkout progress. Numbered stepper checkout splits one form into Details, Delivery, Payment and Review. A numbered step indicator marks the current step with aria-current=”step”, fills in finished steps with a tick and visually hidden completed text, and joins them with a line. Continue checks only the fields in the current step, shows an inline message for each problem and moves focus to the first, and once the step passes it shows the next panel and moves focus to its heading. The delivery step updates the order summary beside the form, the payment step is a clearly labelled demo with no name attributes on the card fields, and the review step reads back the email, address, delivery choice and the last four card digits before Place demo order. Without JavaScript every step stays visible and the form works as one long page. Compact progress bar shows Step 2 of 4 with four segments, and Vertical steps lists each step with a short detail underneath, both marked up as navigation lists with aria-current.
What’s included
Four-step checkout in a single form with a sticky order summary
Step indicator with aria-current="step" and completed states
Per-step validation with inline errors and focus management
Review step that reads back the entered details
Compact progress bar and vertical step list variants
Accessibility
The progress list is a nav with an ordered list; the current step has aria-current="step" and finished steps add visually hidden completed text. Moving between steps puts focus on the new step's heading, errors are linked with aria-describedby and marked with aria-invalid, and every field has a visible label and an autocomplete token. The demo order message is announced in a polite status region, and transitions stop under prefers-reduced-motion.
Customise it
Change --st-accent, --st-line and --st-field on .st; the step names live in the progress list and the panel headings. Delivery prices are data-price on each radio. In Bootstrap the fields use .row and .col-*; in Tailwind edit the st-* colours in @theme.