Design Lounge
Install

Nº 046 of 520

One-page mobile checkout

A 390px single-page checkout: collapsible order summary, address form with correct input types and autocomplete, payment radio cards, inline validation and a sticky Pay button carrying the total.

Preview

Open full size
390 × 844 · 15.5 KB · 156 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "One-page mobile checkout" (piece mobile-one-page-checkout) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

247 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

A complete mobile checkout for a fictional homeware shop (“Tessel”) on one scrolling page: a collapsible order summary pinned at the top, a contact + delivery address form whose inputs use the right type, inputmode and autocomplete tokens so browsers autofill it in one tap, three payment-method radio cards, and a fixed Pay button that carries the total. Validation is inline and calm: fields are only marked after you leave them, green hairlines confirm good input, and a submit with errors scrolls to the first one and reports the count in a toast. One family (Public Sans) at four weights; one terracotta accent used for the selected card and the Pay button only.

Reference behaviour

  1. Initial state: header “TESSEL · Secure checkout”, order summary open showing two items and the totals (£148.00), then Contact, Delivery address and Payment fieldsets. “Debit or credit card” is pre-selected and its card-number fields are visible. The Pay bar sits at bottom: 80px with “Pay now” left and “£148.00” right.
  2. Tap the summary row: the body collapses; the chevron rotates 180° over 280ms. The total stays visible in the collapsed row. Tap again to reopen (the body fades in with a 4px rise).
  3. Focus an input: border becomes --ink and a 3px rgba(24,24,27,.10) ring appears. No label movement.
  4. Leave a required input empty (blur): the field turns invalid — border and 12px message in --error, background --error-soft, message row fades in over 160ms. Typing in it re-validates on every keystroke; once valid, the error clears and the border turns --ok green.
  5. Choose “Device wallet” or “Pay by bank”: the selected card gets an accent border + inset ring + --accent-soft fill, the radio dot scales in over 160ms, and the card-number fields hide (their required flag is removed). Choosing “Debit or credit card” shows them again with a fade.
  6. Tap “Pay now” with errors: every visible required field is validated, a toast at the top reads “3 fields need attention”, the first invalid field receives focus and scrolls to the vertical centre.
  7. Tap “Pay now” with everything valid: the button turns --ink and reads “Processing” for 1200ms, then turns --ok green and reads “Order placed · tap to reset”.
  8. Tap the green button: the form resets to the initial state (replay).

Structure

390 × 844 (54px status reserve above, 80px browser bar below)
┌──────────────────────────────────────┐
│ TESSEL         (lock) Secure checkout│ header 52
├──────────────────────────────────────┤
│ Order summary · 2 items   £148.00 ⌄ │ <details> summary row 56
│ ┌──┐ Ridge stoneware bowl…   £96.00 │ items: 52px thumb grid
│ └──┘ Oat glaze · 18 cm              │
│ ┌──┐ Fjord serving platter   £48.00 │
│ ── Subtotal £144 · Delivery £4      │
│    Total £148.00                     │
├──────────────────────────────────────┤
│ Contact                              │ legend 17/600
│ [ Email                            ] │ 48px inputs
│ Delivery address                     │
│ [ First name    ] [ Last name      ] │ 2-col grid, 12 gap
│ [ Address                          ] │
│ [ Town or city  ] [ Postcode       ] │
│ [ Country ⌄                        ] │
│ [ Phone (for the courier)          ] │
│ Payment                              │
│ ( ) CARD  Debit or credit card       │ radio cards 56px
│ ( ) PAY   Device wallet              │
│ ( ) BANK  Pay by bank                │
│ [ Card number                      ] │ shown for card only
│ [ Expiry        ] [ Security code  ] │
├──────────────────────────────────────┤
│ [ Pay now                  £148.00 ] │ fixed bar, bottom: 80
│   By paying you agree to…            │
└──────────────────────────────────────┘
  • <header> — brand span + lock icon label. margin-top: 54px.
  • <main> → <details class="summary" open> with <summary> (label, total, chevron SVG) and .body (two .item grids of 52px 1fr auto, then .totals).
  • <form novalidate> with three <fieldset>s, each with a <legend>. Inputs sit in .f wrappers (label, input, .msg[role=alert]), laid out in a two-column .grid; .full spans both columns.
  • Payment: <div role="radiogroup"> of .card blocks: visually hidden <input type="radio"> + <label> containing .radio dot, .ic scheme chip and .txt. #cardfields below toggles with .on.
  • <div class="bar"> (fixed) holds <button type="submit" form="form"> and a 11px note.
  • <div class="toast" role="status" aria-live="polite"> fixed at top: 62px.

Content

  • Header: “TESSEL” and “Secure checkout” with a lock icon.
  • Items: “Ridge stoneware bowl, set of 4” · “Oat glaze · 18 cm” · £96.00 (tan gradient thumb); “Fjord serving platter” · “Slate · 32 cm” · £48.00 (slate gradient thumb).
  • Totals: Subtotal £144.00 · Delivery (tracked, 2–3 days) £4.00 · Total £148.00.
  • Legends: “Contact”, “Delivery address”, “Payment”.
  • Fields in order: Email · First name · Last name · Address (“House number and street”) · Town or city · Postcode (UK pattern [A-Za-z]{1,2}\d[A-Za-z\d]? ?\d[A-Za-z]{2}) · Country (United Kingdom, Ireland, Netherlands, Denmark) · Phone “(for the courier)” optional.
  • Payment cards: “Debit or credit card — Visa, Mastercard, Amex” (chip CARD, default) · “Device wallet — Confirm with your fingerprint or face” (chip PAY) · “Pay by bank — Approve in your banking app” (chip BANK).
  • Card fields: Card number ([\d ]{16,19}), Expiry (“MM / YY”, (0[1-9]|1[0-2]) ?/ ?\d{2}), Security code (\d{3,4}, maxlength 4).
  • Error messages: “Enter a valid email address”, “Required”, “Enter your street address”, “Enter a UK postcode”, “Enter the 16-digit card number”, “MM / YY”, “3 or 4 digits”.
  • Bar: “Pay now” + “£148.00”; note “By paying you agree to Tessel’s terms. Returns within 30 days.”
  • Button states: “Processing”, “Order placed · tap to reset”. Toast: “n field(s) need(s) attention”.

Tokens

:root {
  /* colour — cool paper neutrals, terracotta accent, one error red */
  --bg: #fafaf9;
  --surface: #ffffff;       /* header, inputs, cards, bar */
  --surface-2: #f4f4f2;     /* scheme chips */
  --line: #e4e3df;          /* hairlines */
  --line-strong: #c8c6bf;   /* input + card borders */
  --ink: #18181b;
  --ink-2: #5b5b60;         /* labels, totals */
  --ink-3: #8a8a90;         /* meta, placeholders */
  --accent: #c2410c;        /* selected card, pay button */
  --accent-2: #a3360a;      /* pay hover */
  --accent-ink: #ffffff;
  --accent-soft: #fdeee6;   /* selected card fill */
  --error: #b42318;
  --error-soft: #fdecea;
  --ok: #1f7a4d;            /* valid border, done button */

  /* type */
  --font: "Public Sans", system-ui, sans-serif;

  /* layout */
  --safe-top: 54px;
  --safe-bottom: 80px;
  --gutter: 16px;
  --input-h: 48px;
  --card-h: 56px;
  --r: 10px;                /* inputs, cards, toast */
  --r-sm: 6px;              /* thumbnails */
  --r-pill: 999px;          /* pay button */
  --shadow-bar: 0 -6px 20px rgba(24, 24, 27, .08);
  --ring-focus: 0 0 0 3px rgba(24, 24, 27, .10);
  --ring-error: 0 0 0 3px rgba(180, 35, 24, .15);

  /* motion */
  --t-fast: 160ms;
  --t-layout: 280ms;
  --t-process: 1200ms;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Body / inputsPublic Sans15px4001.450sentence
BrandPublic Sans14px7001+0.14emUPPERCASE
LegendPublic Sans17px6001.3−0.01emsentence
Field labelPublic Sans13px5001.30sentence
Error messagePublic Sans12px4001.30sentence
Summary rowPublic Sans15px500 (total 600)1.30sentence
Item namePublic Sans14px5001.350sentence
Item meta, notePublic Sans12px / 11px4001.40sentence
Scheme chipPublic Sans9px7001+0.06emUPPERCASE
Pay buttonPublic Sans16px60010sentence

All prices use font-variant-numeric: tabular-nums.

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
summary chevrondetails togglerotate0 → 180°280ms--ease-out
summary .bodyopenopacity, translateY0, −4px → 1, 0280ms--ease-out@keyframes fade
input border/ringfocusborder-color, box-shadow--line-strong → --ink, ring160mslinear (colour)
.msgfield becomes invalidopacity, translateY0, −4px → 1, 0160ms--ease
radio dot ::aftercheckedscale0 → 1160ms--ease-out
card border/fillcheckedborder, background--line-strong → --accent, white → --accent-soft160mslinear
#cardfieldsscheme = cardopacity, translateY0, −4px → 1, 0280ms--ease-outdisplay toggles first
pay buttonsubmit validbackground--accent → --ink → --ok160ms each, 1200ms apartlinear
pay buttonactivescale1 → .985160ms--ease
toastinvalid submitopacity, translateY0, −12px → 1, 0280ms--ease-outauto-hides after 2600ms

Reduced motion: all durations 1ms; the 1200ms processing delay stays (it is feedback, not decoration).

States

  • Input default: 48px tall, 1px --line-strong border, --surface background, radius 10px.
  • Input focus: border --ink, box-shadow: var(--ring-focus), no outline.
  • Input invalid (.f.invalid, aria-invalid="true"): border --error, background --error-soft, message shown; focus ring becomes --ring-error.
  • Input valid with content (.f.valid): border --ok.
  • Radio card default / checked: see Motion; checked adds box-shadow: inset 0 0 0 1px var(--accent) so the border reads as 2px without layout shift.
  • Radio card focus-visible: 2px --accent outline, 2px offset, applied to the label via input:focus-visible + label.
  • Pay button: default --accent; hover --accent-2; .busy → --ink and pointer-events: none; .done → --ok.
  • Summary collapsed: body removed from flow (native <details>), total still in the summary row.

Accessibility

  • Every input has a visible <label for>; placeholder is never the only label. Fieldsets have <legend>s.
  • Input attributes: email → type=email inputmode=email autocomplete=email; names → autocomplete="shipping given-name" / "shipping family-name"; address → shipping address-line1, shipping address-level2, shipping postal-code, shipping country; phone → type=tel inputmode=tel autocomplete=tel; card → cc-number, cc-exp, cc-csc with inputmode=numeric.
  • novalidate on the form so the native bubbles do not fire; validity is still read with checkValidity() and mirrored to aria-invalid.
  • Error messages are role="alert" spans that become visible when the field is invalid; they sit directly after the input in DOM order.
  • Payment radios are real <input type=radio> in a role="radiogroup"; arrow keys move selection natively.
  • Toast uses role="status" aria-live="polite".
  • The Pay button is type="submit" with form="form" so Enter in any field submits.
  • Hit targets: inputs 48px, radio cards 56px, Pay 52px, summary row 56px.
  • Contrast: --ink-2 on white 6.9:1; --ink-3 3.9:1 used only for 11–12px meta and placeholders; --accent-ink on --accent 5.4:1; --error on --error-soft 6.2:1.

Responsive rules

  • 390 (reference): two-column grid for name, city/postcode and expiry/CVC.
  • 360 wide: same grid; legend 16px; scheme chips shrink to 32×22.
  • ≥ 600: centre a 560px column; the bar keeps a full-width background but its button is capped at 560px and centred; --safe-bottom: 0.
  • Landscape (height < 500): the bar’s note is hidden so the button plus 12px padding is the whole bar.

Acceptance checklist

  • Order summary is a native <details open>; its chevron rotates 180° over 280ms and the total stays visible when collapsed.
  • Every input carries the listed type, inputmode and autocomplete values; browser autofill populates the whole address.
  • Inputs are 48px tall with a 10px radius and show a 3px focus ring without an outline.
  • Blurring an empty required field marks it invalid (red border, soft red fill, 12px message with icon); typing a valid value clears it and turns the border green.
  • Selecting a payment card animates its radio dot (scale 0 → 1, 160ms) and fills the card with #fdeee6 + accent border.
  • Card-number fields are shown only for the card scheme and lose required when hidden.
  • Submitting with errors shows a toast naming the count, focuses the first invalid field and scrolls it to centre.
  • Submitting valid data shows “Processing” on a --ink button for 1200ms, then “Order placed” on --ok.
  • Tapping the done button resets the form to its initial state.
  • The Pay bar is fixed at bottom: 80px, the main content has enough bottom padding (80px + 92px) that the last field can scroll clear of it.
  • Nothing fixed sits in the top 54px or bottom 80px; the toast starts at 62px.
  • Keyboard-only: Tab reaches every field, radio and the Pay button; focus is visible everywhere.

Implementation notes

Validate only visible required fields (hidden card fields must not block wallet or bank payments), and mark invalid only after blur or submit, never on first keystroke:

const fields = () => [...form.querySelectorAll('input[required]')].filter(i => i.offsetParent !== null);
function check(i, show) {
  const ok = i.checkValidity(), f = i.closest('.f');
  if (show || f.classList.contains('invalid')) f.classList.toggle('invalid', !ok);
  f.classList.toggle('valid', ok && i.value.length > 0);
  i.setAttribute('aria-invalid', String(!ok));
  return ok;
}
form.addEventListener('focusout', e => { if (e.target.matches('input[required]')) check(e.target, true); });
form.addEventListener('input',    e => { if (e.target.matches('input[required]')) check(e.target, false); });

Radio cards without extra JS. Hide the input but keep it focusable, then style the sibling label from :checked and :focus-visible:

.card input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.card input:checked + label { border-color: var(--accent); background: var(--accent-soft);
                              box-shadow: inset 0 0 0 1px var(--accent); }
.card input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }
.card .radio::after { content: ""; width: 10px; height: 10px; border-radius: 50%;
                      background: var(--accent); transform: scale(0);
                      transition: transform var(--t-fast) var(--ease-out); }
.card input:checked + label .radio::after { transform: scale(1); }

Toggle card fields’ required when the scheme changes, otherwise checkValidity() fails on hidden inputs:

form.addEventListener('change', e => {
  if (e.target.name !== 'pay') return;
  const on = e.target.value === 'card';
  cardfields.classList.toggle('on', on);
  cardfields.querySelectorAll('input').forEach(i => i.required = on);
});

Common mistakes: using a data: SVG for the select chevron (blocked in strict CSP sandboxes; use an absolutely positioned inline SVG); display:none on the radio input (removes it from the tab order); forgetting novalidate so the browser’s own bubbles appear over your inline messages; putting the Pay button outside the form without the form attribute.

Details

Palette
Type
Public Sans
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Abel

A catalogue. One product at a time.

All sources