<div><span class="lbl">Single-page checkout</span>
<form class="co" action="#" method="post" data-checkout data-subtotal="80.00" data-tax="7" aria-labelledby="co-title">
<div class="co-layout">
<div class="co-main">
<p class="co-brand"><span aria-hidden="true">R</span>Ridgeline Coffee</p>
<h2 class="sr-only" id="co-title">Checkout</h2>
<p class="co-note">All fields are required unless marked optional.</p>
<fieldset class="co-set"><legend class="co-legend"><i aria-hidden="true">1</i>Contact</legend>
<div class="co-grid">
<div class="co-s6 co-m6"><label class="co-label" for="co-email">Email</label><input class="co-input" id="co-email" name="email" type="email" autocomplete="email" required aria-describedby="co-email-err" data-missing="Enter your email address." data-bad="Enter an email address like [email protected]."><p class="co-err" id="co-email-err"></p></div>
</div>
<label class="co-check"><input type="checkbox" name="news"><span>Email me when new roasts land (optional)</span></label>
</fieldset>
<fieldset class="co-set"><legend class="co-legend"><i aria-hidden="true">2</i>Shipping address</legend>
<div class="co-grid">
<div class="co-s6 co-m6"><label class="co-label" for="co-country">Country or region</label><select class="co-input" id="co-country" name="country" autocomplete="country"><option value="US" selected>United States</option><option value="CA">Canada</option><option value="GB">United Kingdom</option><option value="AU">Australia</option></select></div>
<div class="co-s3 co-m6"><label class="co-label" for="co-first">First name</label><input class="co-input" id="co-first" name="first-name" type="text" autocomplete="shipping given-name" required aria-describedby="co-first-err" data-missing="Enter your first name."><p class="co-err" id="co-first-err"></p></div>
<div class="co-s3 co-m6"><label class="co-label" for="co-last">Last name</label><input class="co-input" id="co-last" name="last-name" type="text" autocomplete="shipping family-name" required aria-describedby="co-last-err" data-missing="Enter your last name."><p class="co-err" id="co-last-err"></p></div>
<div class="co-s6 co-m6"><label class="co-label" for="co-address">Street address</label><input class="co-input" id="co-address" name="address" type="text" autocomplete="shipping address-line1" required aria-describedby="co-address-err" data-missing="Enter your street address."><p class="co-err" id="co-address-err"></p></div>
<div class="co-s6 co-m6"><label class="co-label" for="co-address2">Apartment, suite, etc. (optional)</label><input class="co-input" id="co-address2" name="address2" type="text" autocomplete="shipping address-line2"></div>
<div class="co-s2 co-m6"><label class="co-label" for="co-city">City</label><input class="co-input" id="co-city" name="city" type="text" autocomplete="shipping address-level2" required aria-describedby="co-city-err" data-missing="Enter your city."><p class="co-err" id="co-city-err"></p></div>
<div class="co-s2 co-m3"><label class="co-label" for="co-state">State</label><input class="co-input" id="co-state" name="state" type="text" autocomplete="shipping address-level1" required aria-describedby="co-state-err" data-missing="Enter your state."><p class="co-err" id="co-state-err"></p></div>
<div class="co-s2 co-m3"><label class="co-label" for="co-zip">ZIP code</label><input class="co-input" id="co-zip" name="zip" type="text" autocomplete="shipping postal-code" inputmode="numeric" pattern="[0-9]{5}(-[0-9]{4})?" required aria-describedby="co-zip-err" data-missing="Enter your ZIP code." data-bad="Enter a 5-digit ZIP code, like 97214."><p class="co-err" id="co-zip-err"></p></div>
<div class="co-s6 co-m6"><label class="co-label" for="co-phone">Phone (optional)</label><input class="co-input" id="co-phone" name="phone" type="tel" autocomplete="shipping tel" aria-describedby="co-phone-hint co-phone-err"><p class="co-hint" id="co-phone-hint">Only used to text you about this delivery.</p></div>
</div>
</fieldset>
<fieldset class="co-set"><legend class="co-legend"><i aria-hidden="true">3</i>Delivery</legend>
<div class="co-options">
<label class="co-radio"><input type="radio" name="delivery" value="std" data-ship-option data-label="Standard" data-price="5.00" checked><span><strong>Standard</strong><span>3 to 5 business days</span></span><b>$5.00</b></label>
<label class="co-radio"><input type="radio" name="delivery" value="exp" data-ship-option data-label="Express" data-price="14.00"><span><strong>Express</strong><span>Next business day</span></span><b>$14.00</b></label>
<label class="co-radio"><input type="radio" name="delivery" value="pick" data-ship-option data-label="Pick up at the roastery" data-price="0.00"><span><strong>Pick up at the roastery</strong><span>Ready in 2 hours · 18 Alder Street</span></span><b>Free</b></label>
</div>
</fieldset>
<fieldset class="co-set"><legend class="co-legend"><i aria-hidden="true">4</i>Payment</legend>
<div class="co-demo"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 3v6c0 4.5-3.4 8-8 9-4.6-1-8-4.5-8-9V6z"/><path d="M12 8v4M12 16h.01"/></svg><p><strong>Demo payment form</strong>These fields are not connected to a payment provider. Nothing you type is sent or stored and no card is charged. In production, use your provider's secure card fields.</p></div>
<div class="co-grid">
<div class="co-s6 co-m6"><label class="co-label" for="co-card">Card number</label><input class="co-input" id="co-card" type="text" autocomplete="cc-number" inputmode="numeric" pattern="[0-9 ]{13,23}" placeholder="1234 1234 1234 1234" required aria-describedby="co-card-err" data-missing="Enter a card number." data-bad="Enter the 13 to 19 digits on the front of the card."><p class="co-err" id="co-card-err"></p></div>
<div class="co-s3 co-m3"><label class="co-label" for="co-exp">Expiry date</label><input class="co-input" id="co-exp" type="text" autocomplete="cc-exp" inputmode="numeric" pattern="(0[1-9]|1[0-2]) ?/ ?[0-9]{2}" placeholder="MM / YY" required aria-describedby="co-exp-err" data-missing="Enter the expiry date." data-bad="Use the format MM / YY."><p class="co-err" id="co-exp-err"></p></div>
<div class="co-s3 co-m3"><label class="co-label" for="co-cvc">Security code</label><input class="co-input" id="co-cvc" type="text" autocomplete="cc-csc" inputmode="numeric" pattern="[0-9]{3,4}" required aria-describedby="co-cvc-hint co-cvc-err" data-missing="Enter the security code." data-bad="Enter the 3 or 4 digit code."><p class="co-hint" id="co-cvc-hint">3 or 4 digits on the back.</p><p class="co-err" id="co-cvc-err"></p></div>
<div class="co-s6 co-m6"><label class="co-label" for="co-ccname">Name on card</label><input class="co-input" id="co-ccname" type="text" autocomplete="cc-name" required aria-describedby="co-ccname-err" data-missing="Enter the name on the card."><p class="co-err" id="co-ccname-err"></p></div>
</div>
</fieldset>
</div>
<aside class="co-sum" aria-labelledby="co-sum-title">
<h2 class="co-sum-title" id="co-sum-title">Order summary</h2>
<ul class="co-items">
<li class="co-item"><div class="co-thumb"><img src="https://images.unsplash.com/photo-1447933601403-0c6688de566e?w=112&h=112&fit=crop&auto=format&q=70" alt="" width="56" height="56"><span class="co-qty" aria-hidden="true">2</span></div>
<div><p class="co-name">Ethiopia Guji, whole bean</p><p class="co-meta">340 g · Light roast<span class="sr-only">, quantity 2</span></p></div><p class="co-price">$38.00</p></li>
<li class="co-item"><div class="co-thumb"><img src="https://images.unsplash.com/photo-1517668808822-9ebb02f2a0e6?w=112&h=112&fit=crop&auto=format&q=70" alt="" width="56" height="56"><span class="co-qty" aria-hidden="true">1</span></div>
<div><p class="co-name">House espresso, whole bean</p><p class="co-meta">1 kg · Medium-dark roast<span class="sr-only">, quantity 1</span></p></div><p class="co-price">$42.00</p></li>
</ul>
<dl class="co-rows">
<div class="co-row"><dt>Subtotal</dt><dd data-subtotal>$80.00</dd></div>
<div class="co-row"><dt>Shipping (<span data-ship-label>Standard</span>)</dt><dd data-shipping>$5.00</dd></div>
<div class="co-row"><dt>Estimated tax (7%)</dt><dd data-tax>$5.60</dd></div>
</dl>
<dl class="co-total"><dt>Total</dt><dd data-total>$90.60</dd></dl>
<button class="co-btn" type="submit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>Place demo order</button>
<p class="co-fine">Demo checkout. No payment is taken.</p>
<p class="co-status" role="status" data-status></p>
</aside>
</div>
</form>
</div>
<div><span class="lbl">Order confirmation</span>
<section class="co co-done" aria-labelledby="co-done-title">
<div class="co-done-mark"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></div>
<h2 id="co-done-title">Thanks, Maya. Your order is in.</h2>
<p class="co-done-lead">Order RC-20417. We roast on Tuesdays and Fridays, so your beans will be at most two days old when they ship.</p>
<dl class="co-facts">
<div><dt>Delivery</dt><dd>Standard, 3 to 5 business days</dd></div>
<div><dt>Ship to</dt><dd>Maya Lindqvist, 2210 SE Clinton St, Portland</dd></div>
<div><dt>Items</dt><dd>3 items</dd></div>
<div><dt>Total</dt><dd>$90.60</dd></div>
</dl>
<div class="co-done-actions"><a href="#">Track your order</a><a href="#">Continue shopping</a></div>
</section>
</div>
CSS
/* Single-page checkout. One <form> holds the fields and the summary; every field has a visible label and a
checkout autocomplete token. The card fields are a front-end mock: they have no name attribute, so the form
would never submit them, and the script stops the demo submit anyway. */
.co{--co-accent:#7a3e0c;--co-accent-dark:#62310a;--co-ink:#211812;--co-muted:#5f544b;--co-line:#e7e0d8;--co-field:#8b8178;--co-soft:#faf6f1;
--co-ring:rgba(122,62,12,.25);--co-error:#b42318;--co-ok:#166534;color:var(--co-ink);font-size:.93rem;line-height:1.45}
.co [hidden]{display:none!important}
.co .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}
.co :where(button,input,select){font:inherit;color:inherit}
.co :is(a,button):focus-visible{outline:2px solid var(--co-accent);outline-offset:2px}
.co-layout{display:grid;grid-template-columns:minmax(0,1fr) 350px;gap:28px;align-items:start}
.co-main{background:#fff;border-radius:18px;padding:28px;box-shadow:0 1px 2px rgba(33,24,18,.05),0 16px 40px -24px rgba(33,24,18,.3)}
.co-brand{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;font-size:.88rem;margin-bottom:6px}
.co-brand span{width:30px;height:30px;border-radius:50%;background:var(--co-accent);color:#fff;display:grid;place-items:center;font-size:.8rem}
.co-note{color:var(--co-muted);font-size:.84rem;margin-bottom:6px}
.co-set{border:0;padding:22px 0 4px;margin:0;min-width:0}
.co-set+.co-set{border-top:1px solid var(--co-line);margin-top:18px}
.co-legend{float:left;width:100%;font-size:1.1rem;font-weight:700;padding:0;margin-bottom:14px;display:flex;align-items:center;gap:10px}
.co-legend+*{clear:left}
.co-legend i{font-style:normal;width:26px;height:26px;border-radius:50%;background:var(--co-soft);border:1px solid var(--co-line);display:grid;place-items:center;font-size:.78rem;font-weight:700}
.co-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px}
.co-s6{grid-column:span 6}.co-s3{grid-column:span 3}.co-s2{grid-column:span 2}
.co-label{display:block;font-size:.84rem;font-weight:600;margin-bottom:6px}
.co-input{display:block;width:100%;padding:11px 13px;border:1px solid var(--co-field);border-radius:10px;background:#fff;transition:border-color .15s,box-shadow .15s}
.co-input::placeholder{color:#7d7268}
select.co-input{appearance:none;padding-right:38px;cursor:pointer;
background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' fill='none' stroke='%235f544b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") right 12px center/16px no-repeat}
.co-input:focus{outline:none;border-color:var(--co-accent);box-shadow:0 0 0 3px var(--co-ring)}
.co-input[aria-invalid="true"]{border-color:var(--co-error)}
.co-input[aria-invalid="true"]:focus{box-shadow:0 0 0 3px rgba(180,35,24,.2)}
.co-hint{font-size:.78rem;color:var(--co-muted);margin-top:5px}
.co-err{display:flex;gap:6px;color:var(--co-error);font-size:.8rem;font-weight:600;margin-top:6px}
.co-err::before{content:"!";flex:none;width:16px;height:16px;margin-top:1px;border-radius:50%;background:var(--co-error);color:#fff;font-size:.7rem;font-weight:800;display:grid;place-items:center}
.co-err:empty{display:none}
.co-check{display:flex;gap:10px;align-items:flex-start;margin-top:12px;font-size:.88rem;cursor:pointer}
.co-check input,.co-radio input{flex:none;width:18px;height:18px;margin:1px 0 0;accent-color:var(--co-accent);cursor:pointer}
.co-check input:focus-visible,.co-radio input:focus-visible{outline:2px solid var(--co-accent);outline-offset:2px}
/* delivery radio cards */
.co-options{display:grid;gap:10px}
.co-radio{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:start;padding:14px 16px;border:1px solid var(--co-field);border-radius:12px;cursor:pointer;transition:border-color .15s,background-color .15s}
.co-radio:hover{border-color:var(--co-ink)}
.co-radio:has(:checked){border-color:var(--co-accent);background:var(--co-soft);box-shadow:inset 0 0 0 1px var(--co-accent)}
.co-radio strong{display:block;font-weight:600}
.co-radio span span{font-size:.82rem;color:var(--co-muted)}
.co-radio b{font-variant-numeric:tabular-nums}
.co-demo{display:flex;gap:12px;padding:14px 16px;margin-bottom:16px;border-radius:12px;background:#fff7e6;border:1px solid #e7c98f;color:#5c3b06;font-size:.85rem}
.co-demo svg{flex:none;margin-top:1px}
.co-demo strong{display:block}
/* summary */
.co-sum{position:sticky;top:20px;background:#fff;border-radius:18px;padding:24px;box-shadow:0 1px 2px rgba(33,24,18,.05),0 16px 40px -24px rgba(33,24,18,.3)}
.co-sum-title{font-size:1.1rem;font-weight:700;margin-bottom:8px}
.co-items{list-style:none;border-bottom:1px solid var(--co-line);padding-bottom:8px}
.co-item{display:grid;grid-template-columns:56px minmax(0,1fr) auto;gap:12px;align-items:center;padding:8px 0}
.co-thumb{position:relative}
.co-thumb img{width:56px;height:56px;border-radius:10px;object-fit:cover;background:var(--co-soft)}
.co-qty{position:absolute;top:-6px;right:-6px;min-width:21px;height:21px;padding:0 5px;border-radius:999px;background:var(--co-muted);color:#fff;font-size:.7rem;font-weight:700;display:grid;place-items:center}
.co-name{font-weight:600;line-height:1.3;font-size:.9rem}
.co-meta{font-size:.8rem;color:var(--co-muted)}
.co-price{font-weight:600;font-variant-numeric:tabular-nums}
.co-rows{display:grid;gap:8px;padding:14px 0;border-bottom:1px solid var(--co-line)}
.co-row{display:flex;justify-content:space-between;gap:12px}
.co-row dt{color:var(--co-muted)}
.co-row dd{font-weight:600;font-variant-numeric:tabular-nums}
.co-total{display:flex;justify-content:space-between;align-items:baseline;padding:14px 0 4px;font-size:1.05rem;font-weight:700}
.co-total dd{font-size:1.35rem;font-weight:800;font-variant-numeric:tabular-nums}
.co-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:16px;padding:14px 18px;border:0;border-radius:12px;background:var(--co-accent);color:#fff;
font-weight:700;cursor:pointer;transition:background-color .15s}
.co-btn:hover{background:var(--co-accent-dark)}
.co-fine{font-size:.78rem;color:var(--co-muted);text-align:center;margin-top:10px}
.co-status:not(:empty){margin-top:14px;padding:12px 14px;border-radius:10px;background:#f0fdf4;color:var(--co-ok);font-weight:600;font-size:.85rem}
/* confirmation */
.co-done{max-width:560px;background:#fff;border-radius:18px;padding:30px;box-shadow:0 1px 2px rgba(33,24,18,.05),0 16px 40px -24px rgba(33,24,18,.3)}
.co-done-mark{width:56px;height:56px;border-radius:50%;background:#e8f5ec;color:var(--co-ok);display:grid;place-items:center;margin-bottom:14px}
.co-done h2{font-family:Georgia,"Times New Roman",serif;font-weight:400;font-size:1.7rem;line-height:1.15}
.co-done-lead{color:var(--co-muted);margin:6px 0 18px}
.co-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;padding:16px 0;border-block:1px solid var(--co-line);margin-bottom:18px}
.co-facts dt{font-size:.75rem;letter-spacing:.08em;text-transform:uppercase;color:var(--co-muted);font-weight:600}
.co-facts dd{font-weight:600;margin-top:2px}
.co-done-actions{display:flex;flex-wrap:wrap;gap:10px}
.co-done-actions a{flex:1;min-width:160px;display:flex;justify-content:center;padding:12px 16px;border-radius:12px;font-weight:700;text-decoration:none;border:1px solid var(--co-accent)}
.co-done-actions a:first-child{background:var(--co-accent);color:#fff}
.co-done-actions a:last-child{color:var(--co-accent)}
@media (max-width:900px){.co-layout{grid-template-columns:1fr}.co-sum{position:static}}
@media (max-width:575px){.co-main{padding:22px 18px}.co-m6{grid-column:span 6}.co-m3{grid-column:span 3}.co-facts{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.co-input,.co-radio,.co-btn{transition:none}}
JavaScript
// 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();
});
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<div><span class="lbl">Single-page checkout</span>
<form class="co" action="#" method="post" data-checkout data-subtotal="80.00" data-tax="7" aria-labelledby="co-title">
<div class="co-layout">
<div class="card co-main"><div class="card-body">
<p class="co-brand"><span aria-hidden="true">R</span>Ridgeline Coffee</p>
<h2 class="visually-hidden" id="co-title">Checkout</h2>
<p class="co-note">All fields are required unless marked optional.</p>
<fieldset class="co-set"><legend class="co-legend mb-0"><i aria-hidden="true">1</i>Contact</legend>
<div class="row">
<div class="col-12 col-sm-12"><label class="form-label" for="co-email">Email</label><input class="form-control" id="co-email" name="email" type="email" autocomplete="email" required aria-describedby="co-email-err" data-missing="Enter your email address." data-bad="Enter an email address like [email protected]."><p class="invalid-feedback" id="co-email-err"></p></div>
</div>
<div class="form-check"><input class="form-check-input" id="co-news" type="checkbox" name="news"><label class="form-check-label" for="co-news">Email me when new roasts land (optional)</label></div>
</fieldset>
<fieldset class="co-set"><legend class="co-legend mb-0"><i aria-hidden="true">2</i>Shipping address</legend>
<div class="row">
<div class="col-12 col-sm-12"><label class="form-label" for="co-country">Country or region</label><select class="form-select" id="co-country" name="country" autocomplete="country"><option value="US" selected>United States</option><option value="CA">Canada</option><option value="GB">United Kingdom</option><option value="AU">Australia</option></select></div>
<div class="col-12 col-sm-6"><label class="form-label" for="co-first">First name</label><input class="form-control" id="co-first" name="first-name" type="text" autocomplete="shipping given-name" required aria-describedby="co-first-err" data-missing="Enter your first name."><p class="invalid-feedback" id="co-first-err"></p></div>
<div class="col-12 col-sm-6"><label class="form-label" for="co-last">Last name</label><input class="form-control" id="co-last" name="last-name" type="text" autocomplete="shipping family-name" required aria-describedby="co-last-err" data-missing="Enter your last name."><p class="invalid-feedback" id="co-last-err"></p></div>
<div class="col-12 col-sm-12"><label class="form-label" for="co-address">Street address</label><input class="form-control" id="co-address" name="address" type="text" autocomplete="shipping address-line1" required aria-describedby="co-address-err" data-missing="Enter your street address."><p class="invalid-feedback" id="co-address-err"></p></div>
<div class="col-12 col-sm-12"><label class="form-label" for="co-address2">Apartment, suite, etc. (optional)</label><input class="form-control" id="co-address2" name="address2" type="text" autocomplete="shipping address-line2"></div>
<div class="col-12 col-sm-4"><label class="form-label" for="co-city">City</label><input class="form-control" id="co-city" name="city" type="text" autocomplete="shipping address-level2" required aria-describedby="co-city-err" data-missing="Enter your city."><p class="invalid-feedback" id="co-city-err"></p></div>
<div class="col-6 col-sm-4"><label class="form-label" for="co-state">State</label><input class="form-control" id="co-state" name="state" type="text" autocomplete="shipping address-level1" required aria-describedby="co-state-err" data-missing="Enter your state."><p class="invalid-feedback" id="co-state-err"></p></div>
<div class="col-6 col-sm-4"><label class="form-label" for="co-zip">ZIP code</label><input class="form-control" id="co-zip" name="zip" type="text" autocomplete="shipping postal-code" inputmode="numeric" pattern="[0-9]{5}(-[0-9]{4})?" required aria-describedby="co-zip-err" data-missing="Enter your ZIP code." data-bad="Enter a 5-digit ZIP code, like 97214."><p class="invalid-feedback" id="co-zip-err"></p></div>
<div class="col-12 col-sm-12"><label class="form-label" for="co-phone">Phone (optional)</label><input class="form-control" id="co-phone" name="phone" type="tel" autocomplete="shipping tel" aria-describedby="co-phone-hint co-phone-err"><p class="form-text" id="co-phone-hint">Only used to text you about this delivery.</p></div>
</div>
</fieldset>
<fieldset class="co-set"><legend class="co-legend"><i aria-hidden="true">3</i>Delivery</legend>
<div class="d-grid gap-2">
<label class="form-check co-radio"><input class="form-check-input" type="radio" name="delivery" value="std" data-ship-option data-label="Standard" data-price="5.00" checked><span><strong>Standard</strong><span>3 to 5 business days</span></span><b>$5.00</b></label>
<label class="form-check co-radio"><input class="form-check-input" type="radio" name="delivery" value="exp" data-ship-option data-label="Express" data-price="14.00"><span><strong>Express</strong><span>Next business day</span></span><b>$14.00</b></label>
<label class="form-check co-radio"><input class="form-check-input" type="radio" name="delivery" value="pick" data-ship-option data-label="Pick up at the roastery" data-price="0.00"><span><strong>Pick up at the roastery</strong><span>Ready in 2 hours · 18 Alder Street</span></span><b>Free</b></label>
</div>
</fieldset>
<fieldset class="co-set"><legend class="co-legend"><i aria-hidden="true">4</i>Payment</legend>
<div class="alert"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 3v6c0 4.5-3.4 8-8 9-4.6-1-8-4.5-8-9V6z"/><path d="M12 8v4M12 16h.01"/></svg><p><strong>Demo payment form</strong>These fields are not connected to a payment provider. Nothing you type is sent or stored and no card is charged. In production, use your provider's secure card fields.</p></div>
<div class="row">
<div class="col-12 col-sm-12"><label class="form-label" for="co-card">Card number</label><input class="form-control" id="co-card" type="text" autocomplete="cc-number" inputmode="numeric" pattern="[0-9 ]{13,23}" placeholder="1234 1234 1234 1234" required aria-describedby="co-card-err" data-missing="Enter a card number." data-bad="Enter the 13 to 19 digits on the front of the card."><p class="invalid-feedback" id="co-card-err"></p></div>
<div class="col-6 col-sm-6"><label class="form-label" for="co-exp">Expiry date</label><input class="form-control" id="co-exp" type="text" autocomplete="cc-exp" inputmode="numeric" pattern="(0[1-9]|1[0-2]) ?/ ?[0-9]{2}" placeholder="MM / YY" required aria-describedby="co-exp-err" data-missing="Enter the expiry date." data-bad="Use the format MM / YY."><p class="invalid-feedback" id="co-exp-err"></p></div>
<div class="col-6 col-sm-6"><label class="form-label" for="co-cvc">Security code</label><input class="form-control" id="co-cvc" type="text" autocomplete="cc-csc" inputmode="numeric" pattern="[0-9]{3,4}" required aria-describedby="co-cvc-hint co-cvc-err" data-missing="Enter the security code." data-bad="Enter the 3 or 4 digit code."><p class="form-text" id="co-cvc-hint">3 or 4 digits on the back.</p><p class="invalid-feedback" id="co-cvc-err"></p></div>
<div class="col-12 col-sm-12"><label class="form-label" for="co-ccname">Name on card</label><input class="form-control" id="co-ccname" type="text" autocomplete="cc-name" required aria-describedby="co-ccname-err" data-missing="Enter the name on the card."><p class="invalid-feedback" id="co-ccname-err"></p></div>
</div>
</fieldset>
</div></div>
<aside class="card co-sum" aria-labelledby="co-sum-title"><div class="card-body">
<h2 class="co-sum-title" id="co-sum-title">Order summary</h2>
<ul class="list-group">
<li class="list-group-item co-item"><div class="co-thumb"><img src="https://images.unsplash.com/photo-1447933601403-0c6688de566e?w=112&h=112&fit=crop&auto=format&q=70" alt="" width="56" height="56"><span class="badge rounded-pill" aria-hidden="true">2</span></div>
<div><p class="co-name">Ethiopia Guji, whole bean</p><p class="co-meta">340 g · Light roast<span class="visually-hidden">, quantity 2</span></p></div><p class="co-price">$38.00</p></li>
<li class="list-group-item co-item"><div class="co-thumb"><img src="https://images.unsplash.com/photo-1517668808822-9ebb02f2a0e6?w=112&h=112&fit=crop&auto=format&q=70" alt="" width="56" height="56"><span class="badge rounded-pill" aria-hidden="true">1</span></div>
<div><p class="co-name">House espresso, whole bean</p><p class="co-meta">1 kg · Medium-dark roast<span class="visually-hidden">, quantity 1</span></p></div><p class="co-price">$42.00</p></li>
</ul>
<dl class="co-rows">
<div class="co-row"><dt>Subtotal</dt><dd data-subtotal>$80.00</dd></div>
<div class="co-row"><dt>Shipping (<span data-ship-label>Standard</span>)</dt><dd data-shipping>$5.00</dd></div>
<div class="co-row"><dt>Estimated tax (7%)</dt><dd data-tax>$5.60</dd></div>
</dl>
<dl class="co-total"><dt>Total</dt><dd data-total>$90.60</dd></dl>
<button class="btn btn-primary" type="submit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>Place demo order</button>
<p class="co-fine">Demo checkout. No payment is taken.</p>
<p class="co-status" role="status" data-status></p>
</div></aside>
</div>
</form>
</div>
<div><span class="lbl">Order confirmation</span>
<section class="card co co-done" aria-labelledby="co-done-title"><div class="card-body">
<div class="co-done-mark"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></div>
<h2 id="co-done-title">Thanks, Maya. Your order is in.</h2>
<p class="co-done-lead">Order RC-20417. We roast on Tuesdays and Fridays, so your beans will be at most two days old when they ship.</p>
<dl class="co-facts">
<div><dt>Delivery</dt><dd>Standard, 3 to 5 business days</dd></div>
<div><dt>Ship to</dt><dd>Maya Lindqvist, 2210 SE Clinton St, Portland</dd></div>
<div><dt>Items</dt><dd>3 items</dd></div>
<div><dt>Total</dt><dd>$90.60</dd></div>
</dl>
<div class="co-done-actions"><a class="btn btn-primary" href="#">Track your order</a><a class="btn btn-outline-primary" href="#">Continue shopping</a></div>
</div></section>
</div>
// 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();
});
// 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();
});
About this set
A complete one-page checkout for a coffee roaster and the confirmation that follows it. Single-page checkout is one form in four numbered fieldsets: contact email with an optional newsletter tick, a shipping address, delivery options as radio cards for standard, express and pick-up, and a payment section. Beside it a sticky order summary lists the items and recalculates shipping, a 7 percent tax estimate and the total when the delivery choice changes. The payment section is clearly a front-end mock: a notice says it is not connected to a payment provider, the card fields have no name attribute so the form would never submit them, and the button reads Place demo order. Address fields carry the shipping autocomplete tokens, from given-name to postal-code, and the card fields use cc-number, cc-exp, cc-csc and cc-name, so browsers can fill them. On submit a short script checks every field with the browser’s own rules, writes a specific message under each problem, marks it with aria-invalid and moves focus to the first one. Order confirmation shows the thank-you card with the order number, delivery, address and total.
What’s included
Contact, shipping address, delivery and payment in one form
Delivery radio cards that update shipping and total
Checkout autocomplete tokens on every address and card field
Demo payment fields with no name attribute and a clear notice
Inline error messages with focus moved to the first problem
Accessibility
Every field has a visible label, optional fields say so in words, and each group sits in a fieldset with a legend. Hints and error messages are linked with aria-describedby, and failing fields get aria-invalid; focus moves to the first one on submit. Delivery cards are real radio inputs with a visible focus ring, the summary updates are announced in a polite status region, and transitions stop under prefers-reduced-motion.
Customise it
Change --co-accent, --co-line and --co-field on .co. Subtotal and tax rate are data-subtotal and data-tax on the form; each delivery option carries data-price and data-label. In Bootstrap the field grid is .row with .col-* classes; in Tailwind edit the co-* colours in @theme.