Design Lounge
Install

Nº 038 of 520

Multi-step form stepper

A three-step workspace setup with a horizontal stepper whose connectors fill on completion, direction-aware 40px slide transitions, validation on Continue and a review step with Edit links.

Preview

Open full size
1280 × 800 · 12.9 KB · 178 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 "Multi-step form stepper" (piece multi-step-form-stepper) 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

249 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 640px setup card for a fictional SaaS (“Mira”) that collects an account, a workspace and then shows a review before creating it. Three numbered 28px dots sit across the top joined by 2px connector tracks; when a step completes, its dot fills terracotta with a check and the connector after it fills left-to-right over 320ms. The step panels slide horizontally: going forward the old panel exits 40px to the left and the new one enters from 40px right; going back the directions reverse. Continue validates the current step before moving; the review step lists every answer with an Edit link that jumps back to the right step. Warm off-white, one accent, no shadows heavier than a whisper.

Reference behaviour

  1. Initial state: card centred with 64px above it; brand “Mira” and a lead line. Stepper shows 1 Account current (accent ring + 4px --accent-soft halo), 2 Workspace and 3 Review upcoming (grey ring). Panel 1 is visible: Full name (pre-filled “Ines Okafor”), Company (empty), Work email (empty). Footer: Back (disabled), “Step 1 of 3”, Continue.
  2. Click Continue with Company empty: Company gets aria-invalid="true", a red border with a 3px #f6e0dc halo, and the message “Company is required.” appears beneath it; focus moves to the first invalid field. No slide.
  3. Type in an invalid field: its error clears immediately on input.
  4. Enter “x” in Work email and Continue: “Enter a valid email address.” (regex ^[^\s@]+@[^\s@]+\.[^\s@]+$).
  5. With valid fields, Continue: panel 1 slides to translateX(-40px) and fades out; panel 2 arrives from translateX(40px) — both 320ms. Dot 1 fills accent with a check; connector 1 fills over 320ms; dot 2 becomes current. Footer reads “Step 2 of 3”, Back enables. After the slide (330ms) focus lands on the first field of the new panel.
  6. Panel 2: Workspace name (pre-filled “marrow”), Data region select (Frankfurt, Dublin, Oregon, Singapore), Team size radio segment (Just me / 2–10 / 11–50 / 50+; 2–10 checked). Continue → panel 3 (Review) with Continue relabelled “Create workspace”.
  7. Review lists six rows (Full name, Company, Work email, Workspace name, Data region, Team size) each with an accent Edit link. Clicking Edit on a row goes to that row’s step; the slide runs in the backward direction (panels to the right of the target enter from the left… i.e. current panel exits to the right, target enters from the left).
  8. Back: same backward slide; connectors un-fill (scaleX 0) and dots revert.
  9. Create workspace: a fourth “finish” panel slides in — 56px accent circle with a check, “Workspace created”, “marrow.mira.app is ready in Frankfurt.” and a “Start over” link. The footer is hidden. Start over returns to step 1 with the backward slide.

Structure

1280 × 800
┌──────────────────────────────────────────────────────────────────────────┐
│                              ◆ Mira                                      │ 64 top pad
│                    Set up your workspace in three short steps.          │
│              ┌────────────────── 640 ──────────────────┐                 │
│              │  (1)────────────(2)────────────(3)      │ stepper 28/32/24│
│              │ Account       Workspace      Review     │ dots 28, 96 wide│
│              ├────────────────────────────────────────┤ hairline        │
│              │ Your account                            │ panel 344 tall  │
│              │ We'll send a confirmation…              │ pad 28/32       │
│              │ ┌ Full name ─────┐ ┌ Company ────────┐  │ 2-col, 16 gap   │
│              │ │ Ines Okafor    │ │ Marrow Studio   │  │ inputs 40       │
│              │ └────────────────┘ └─────────────────┘  │                 │
│              │ ┌ Work email ─────────────────────────┐ │                 │
│              │ │ ines@marrow.studio                  │ │                 │
│              │ └─────────────────────────────────────┘ │                 │
│              ├────────────────────────────────────────┤ hairline        │
│              │ [‹ Back]  STEP 1 OF 3        [Continue ›]│ footer 18/32    │
│              └────────────────────────────────────────┘                 │
└──────────────────────────────────────────────────────────────────────────┘
  • <form class="card" novalidate> — 640px, white, 1px --line border, 16px radius, overflow:hidden, shadow 0 1px 2px rgba(34,30,27,.04), 0 24px 48px -24px rgba(34,30,27,.18).
    • <ol class="steps" aria-label="Progress"> — flex, padding 28px 32px 24px, bottom hairline. Children alternate <li class="step" data-i> (96px wide column: .dot + <b> label) and <li class="conn" aria-hidden><i></i></li> (flex 1, 2px tall, margin-top:13px so it centres on the dot).
    • .panels — position:relative; height:344px; overflow:hidden. Four <section class="panel" data-pos="left|on|right" aria-labelledby>: absolute inset:0, padding 28px 32px, flex column, 16px gap.
      • Fields: <label class="f">Label<input …><span class="msg" aria-live="polite"></span></label>; two-column rows use .two (grid 1fr 1fr, 16px gap). Radio segment: <fieldset><legend> + .seg of hidden radios with sibling labels.
      • Review: <ul class="review"> of <li> with grid 140px 1fr auto.
      • Finish panel: .finish centred content.
    • .foot — flex, padding 18px 32px, top hairline, --bg background: Back button, step counter, primary submit button (margin-left:auto).

Fields and validation

StepFieldControlInitial valueRuleMessage
1Full nameinput[name=name] required“Ines Okafor”non-empty after trim“Full name is required.”
1Companyinput[name=company] required"" (placeholder “Marrow Studio”)non-empty“Company is required.”
1Work emailinput[type=email][name=email] required"" (placeholder “ines@marrow.studio”)non-empty, then ^[^\s@]+@[^\s@]+\.[^\s@]+$“Work email is required.” / “Enter a valid email address.”
2Workspace nameinput[name=ws] required“marrow”non-empty“Workspace name is required.”
2Data regionselect[name=region]Frankfurt (eu-central)always valid; options Dublin (eu-west), Oregon (us-west), Singapore (ap-south)—
2Team sizeradio name=size“2–10”always valid (Just me / 2–10 / 11–50 / 50+)—

Validation runs only for input[required] inside the current panel; the select and radios are never invalid. Messages are set on the sibling .msg span (12px 500 --err), which is display:none when empty so the layout doesn’t shift for valid fields.

Review rows

Row labelSource fieldEdit → step
Full namename1
Companycompany1
Work emailemail1
Workspace namews2
Data regionregion2
Team sizesize2

Values come from new FormData(form) at the moment step 3 opens; an empty value renders as ”—”. The finish message is ${ws}.mira.app is ready in ${region.split(' ')[0]}. (e.g. “marrow.mira.app is ready in Frankfurt.”).

Tokens

:root {
  /* colour — warm off-white, one terracotta accent */
  --bg: #fbf8f4;          /* page + footer */
  --panel: #ffffff;       /* card, dots */
  --field: #fdfcfa;       /* input background */
  --line: #e8e1d8;        /* hairlines, connector track */
  --line-2: #d3c9bc;      /* input + upcoming dot border */
  --ink: #221e1b;
  --ink-2: #5f5750;       /* labels, done step labels */
  --ink-3: #948a80;       /* upcoming labels, lead, counter */
  --accent: #d2603a;      /* current ring, done fill, connector fill, primary */
  --accent-hover: #b9502c;
  --accent-ink: #fff8f3;
  --accent-soft: #fbe9e1; /* halos, checked segment */
  --err: #b8321f;
  --err-soft: #f6e0dc;
  --ok: #2f7a4a;          /* reserved */

  /* type */
  --display: "Syne", system-ui, sans-serif;
  --font: "Manrope", system-ui, sans-serif;

  /* layout */
  --w: 640px;
  --dot: 28px;
  --step-w: 96px;
  --panel-h: 344px;
  --input-h: 40px;
  --r: 10px;              /* inputs, buttons */
  --r-card: 16px;
  --slide: 40px;          /* panel travel */
  --shadow: 0 1px 2px rgba(34,30,27,.04), 0 24px 48px -24px rgba(34,30,27,.18);

  /* motion */
  --t-fast: 140ms;        /* dot colour, borders */
  --t-slide: 320ms;
  --t-fill: 320ms;        /* connector */
  --focus-delay: 330ms;   /* focus after slide */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingNotes
BrandSyne18px7001−0.02em8px rotated-square accent glyph before
Panel headingSyne22px7001.2−0.02em
Lead / helperManrope14px4001.50--ink-3
Field labelManrope12px6001.30--ink-2
Input textManrope14px40040px height0
Error messageManrope12px5001.30--err
Step labelManrope12px6001.30--ink-3 → --ink current → --ink-2 done
Dot numberManrope12px60010
Step counterManrope12px6001+0.04em--ink-3
ButtonsManrope14px60040px height0
Review labelManrope12px6001.30--ink-3; value 13px 400 --ink

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
outgoing panelforwardtransform, opacitynone, 1 → translateX(-40px), 0320mstransform --ease-out, opacity --easevisibility hidden after
incoming panelforwardtransform, opacitytranslateX(40px), 0 → none, 1320mssamestarts simultaneously
panelsbackwardsame, mirroredcurrent → +40px; target from −40px320mssamedirection falls out of data-pos
.conn istep completestransform scaleX0 → 1 (origin left)320ms--easereverses on Back
.dotstate changebackground, border, colour, box-shadowper state140ms--ease
inputfocus / invalidborder-color, box-shadow--line-2 → --accent + 3px halo140mslinear (default)
focus moveafter slide—first field of new panel330ms delay—so focus doesn’t drag the panel

Reduced motion: all durations 1ms (panels swap in place; connectors fill instantly). Keep the 330ms focus delay at ~1ms too if you gate it on the same media query.

States

  • Step upcoming: dot white with 1.5px --line-2 border, number --ink-3; label --ink-3.
  • Step current: dot border --accent, number --accent, box-shadow: 0 0 0 4px var(--accent-soft); label --ink; aria-current="step".
  • Step done: dot filled --accent, check icon in --accent-ink (number hidden); label --ink-2; connector after it filled.
  • Input default: --field background, --line-2 border, 10px radius. Hover: border --ink-3. Focus-visible: border --accent + 3px --accent-soft halo. Invalid: border --err + 3px --err-soft halo, message visible.
  • Segment radio checked: border + text --accent, background --accent-soft. Focus-visible: 3px --accent-soft halo on the label.
  • Back disabled: opacity .45, default cursor.
  • Primary button: --accent; hover --accent-hover; focus-visible 2px accent outline offset 2px.
  • Edit link: --accent 12/600; hover underline; focus-visible 2px outline.
  • Finish: footer visibility:hidden; panel content centred.

Accessibility

  • The stepper is an <ol aria-label="Progress">; the current step has aria-current="step". Dots are aria-hidden (the label carries the name); connectors are aria-hidden.
  • Each panel is a <section aria-labelledby> its heading. Inactive panels are visibility:hidden so their fields leave the tab order.
  • Validation: novalidate on the form, custom messages in a <span aria-live="polite"> inside the label so they are announced and associated; aria-invalid toggled on the input; first invalid field receives focus.
  • Keyboard: Tab through fields; Enter in any field submits the form (Continue); Back is a type="button"; radios move with arrow keys natively; Edit links are real links with aria-label="Edit company" etc.
  • Focus management: after each slide, focus the first input/select (or the first link on Review) after 330ms.
  • Contrast: --ink-2 on white 7.6:1; --ink-3 on white 3.5:1 — used only for 12px 600 labels and helper text, never for values; --accent-ink on --accent 4.6:1.
  • Hit targets: inputs and buttons 40px; segment options 40px; dots are non-interactive.

Responsive rules

  • ≥ 1280: as specified.
  • 1024–1279: unchanged.
  • 768–1023: card width: min(640px, calc(100vw − 48px)); step columns shrink to 80px.
  • < 640: card full-width with 16px margins, radius 12px; .two becomes one column; --panel-h grows to 420px (or switch panels to position:static and animate height with a measured value); stepper labels hide except the current one, which renders inline next to its dot; footer buttons stretch to equal widths.

Acceptance checklist

  • Card is 640px, 16px radius, 1px #e8e1d8 border; stepper padding 28px 32px 24px; panel area 344px tall.
  • Dots are 28px; the current dot has an accent ring and a 4px #fbe9e1 halo; done dots fill #d2603a with a check.
  • Connector tracks are 2px #e8e1d8; the accent fill scales from 0 to 1 (origin left) over 320ms on step completion and back on Back.
  • Forward: outgoing panel to −40px, incoming from +40px; backward mirrors; both 320ms cubic-bezier(.16,1,.3,1) with an opacity crossfade.
  • Continue with empty Company shows “Company is required.” and focuses that field; no slide occurs.
  • Invalid email shows “Enter a valid email address.”; typing clears the error.
  • Review step lists six rows with the entered values and an Edit link per row; Edit jumps to the correct step with the backward slide.
  • Footer counter reads “Step N of 3”; Back is disabled on step 1; the submit button reads “Create workspace” on step 3.
  • Finish panel hides the footer and offers “Start over”, which returns to step 1.
  • Focus lands on the new panel’s first field ~330ms after each transition.
  • Inactive panels are visibility:hidden (not reachable by Tab).
  • aria-current="step" moves with the current step; aria-invalid toggles on fields.
  • Reduced motion: transitions collapse to 1ms; everything still works.

Implementation notes

Direction-aware slides with one attribute. Don’t track “direction” — derive each panel’s position from its index relative to the current step. Panels left of the current one park at −40px, right of it at +40px; a change of cur moves the two affected panels in opposite, correct directions automatically:

.panel { position: absolute; inset: 0; opacity: 0; visibility: hidden;
         transition: transform 320ms var(--ease-out), opacity 320ms var(--ease); }
.panel[data-pos="left"]  { transform: translateX(-40px); }
.panel[data-pos="right"] { transform: translateX(40px); }
.panel[data-pos="on"]    { transform: none; opacity: 1; visibility: visible; }
function go(i) {
  cur = i;
  panels.forEach((p, k) => p.dataset.pos = k < i ? 'left' : k > i ? 'right' : 'on');
  steps.forEach((s, k) => { s.classList.toggle('done', k < i); s.classList.toggle('current', k === i);
                            s.setAttribute('aria-current', k === i ? 'step' : 'false'); });
  conns.forEach((c, k) => c.classList.toggle('done', k < i));
  setTimeout(() => panels[i].querySelector('input:not([type=radio]),select,a')?.focus(), 330);
}

Validate only the visible panel, focus the first failure, and clear on input:

function validate(i) {
  let first = null;
  panels[i].querySelectorAll('input[required]').forEach(el => {
    const v = el.value.trim(); let m = '';
    if (!v) m = LABEL[el.name] + ' is required.';
    else if (el.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) m = 'Enter a valid email address.';
    el.setAttribute('aria-invalid', String(!!m));
    el.parentElement.querySelector('.msg').textContent = m;
    if (m && !first) first = el;
  });
  if (first) first.focus();
  return !first;
}

Common mistakes: focusing the new panel’s field immediately (the browser scrolls the still-translated panel and the slide jumps); using display:none for parked panels (no transition); animating the connector’s width instead of scaleX; forgetting novalidate (native bubbles fight your messages).

Details

Palette
Type
Syne · Manrope
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

MindMarket

A control. It studies this site for quiet, not for a show.

All sources