More Forms Snippets
Multi-Step Checkout — Free HTML CSS JS Form Snippet
Multi-Step Checkout · Forms · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Multi-Step Checkout — Stepper Form with Per-Step Validation and Success Screen

Long forms scare buyers away. Splitting checkout into a few focused steps — contact, shipping, payment — lowers the perceived effort, lets you validate each part before moving on, and gives a clear sense of progress. This component is a complete multi-step checkout: a numbered progress stepper across the top, three sliding panels, per-step validation that blocks advancement until the current fields are valid, back and continue navigation, and a success screen when the order is placed. It is built in semantic HTML, CSS, and vanilla JavaScript with no form library.
The progress stepper
The three steps are an ordered list with a connecting track drawn by a ::before pseudo-element line behind the numbered circles. Each step has three visual states managed by classes: upcoming (grey circle), active (filled indigo circle, dark label), and done (green circle). As the user advances, the render() function recomputes every step's state from the current index — active for the current step, done for any step before it — so the stepper always reflects exactly where the user is. The colour transitions are CSS, so each state change animates smoothly.
One panel visible at a time
All three panels exist in the DOM at once; only the one matching the current index has the .active class and display: block, the rest are display: none. When the active panel changes, a short mscFade keyframe slides it in from the right with a fade, giving a sense of forward motion through the flow. Keeping every panel mounted means field values persist when the user steps back and forward — nothing is lost because nothing is destroyed.
Per-step validation that gates advancement
The heart of a good multi-step form is that you cannot skip ahead with invalid data. Clicking Continue calls validateStep(), which collects the current panel's input[required] elements and checks each with value.trim() !== '' plus the browser's native checkValidity() (so the email field enforces a valid email format for free). Invalid fields get an .invalid class — a red border and ring — and focus jumps to the first one. Only if every required field passes does the index advance. This validates one step at a time rather than dumping every error at the end.
Forgiving error clearing
Errors should not linger after the user fixes them. A single delegated input listener on the form removes the .invalid class from any field the moment the user starts typing in it. This is the forgiving pattern — strict on submit, lenient as the user corrects — which feels far better than leaving a red border until the next Continue click.
Adaptive navigation and the success screen
The Back button is disabled on the first step (you cannot go back from contact), and the primary button relabels itself: it reads "Continue" on steps one and two and "Place order" on the final step, computed in render() from whether the current index is the last panel. Placing the order adds a .complete class that hides the stepper and panels and reveals a success screen with an animated green checkmark and confirmation text. Because the state is index-driven, the whole flow is just three functions — render(), validateStep(), and the click handlers — with no tangled conditionals.
Customisation
Add or remove steps by editing the stepper <li> items and the matching .msc-panel blocks — the script reads them dynamically with querySelectorAll, so the counts stay in sync automatically. Mark which fields are required with the required attribute, swap the #6366f1 accent and #22c55e done/success green for your brand, and replace the hard-coded order total with a real value. To submit to a backend, replace the success block in the final-step branch with a fetch POST of the collected field values.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Rather than tracing the step-gating logic by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how validateStep() leans on the browser's native checkValidity() to get email-format checking for free, and why keeping all three panels mounted (rather than creating/destroying them) is what lets field values survive going back a step. The same assistant can help optimize it, for instance asking whether the querySelectorAll re-scan of steps and panels on every render() call would still be efficient with many more steps, or whether the invalid-clearing input listener should be scoped per-field instead of delegated at the form level. It's also useful for extending the flow: ask it to add an inline order-summary sidebar that updates per step, wire real-time card-number formatting into the payment panel, or persist in-progress checkout state to sessionStorage so a refresh doesn't lose it. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "multi-step checkout" form in plain HTML, CSS, and JavaScript using an index-driven stepper — no form library, no framework.
Requirements:
- A numbered progress stepper (contact, shipping, payment) with a connecting track line behind the step circles, where each step visually shows one of three states — upcoming, active, or done — computed purely from comparing its index to the current step index.
- Three form panels, one per step, all mounted in the DOM simultaneously; only the panel matching the current step index is visible (toggled via a class, not removed from the DOM), and switching panels must play a subtle slide-and-fade transition.
- Clicking "Continue" must first validate only the current panel's required fields, checking both that each is non-empty and that it passes the browser's native checkValidity() (so an email field enforces real email format with no custom regex); if any field fails, block advancing, visually mark the invalid fields, and move focus to the first one.
- Fields must clear their invalid state the moment the user starts correcting them, via one delegated input listener rather than per-field listeners.
- A Back button must be disabled on the first step, and the primary button's label must change to "Place order" only on the final step.
- Reaching the final step successfully and clicking Place order must hide the stepper and form entirely and reveal a distinct success view with a confirmation message.
- Adding or removing a step (a stepper item plus a matching panel) must require no changes to the JavaScript logic — the code must read the step/panel count dynamically.Step by step
How to Use
- 1Paste the HTML, CSS, and JSA checkout card renders on step 1 (Contact) with a three-step progress stepper and a disabled Back button.
- 2Click ContinueIf required fields are valid the form advances, the panel slides in, and the completed step turns green; invalid fields get a red ring and focus.
- 3Step back and forwardUse Back to return to a previous step — your entered values are preserved because panels stay mounted.
- 4Place the orderOn the final step the button reads "Place order"; clicking it reveals an animated success screen.
- 5Add or remove stepsEdit the stepper <li> items and matching .msc-panel blocks; the script reads them dynamically so counts stay in sync.
- 6Wire to a backendReplace the success branch with a fetch POST of the collected field values, keeping the same stepper and validation.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Add another <li class="msc-step"> to the stepper and a matching <div class="msc-panel" data-panel="3"> to the panels. The script reads steps and panels with querySelectorAll at load, so it adapts to the new count automatically — the last-step detection (current === panels.length - 1) and the stepper states all keep working without code changes.
validateStep() selects only the current panel's input[required] elements, so each step validates just its own fields. Each is checked for a non-empty trimmed value and the browser's native checkValidity(), which enforces type rules like email format and pattern attributes. Add the required attribute to any field you want to gate the step, and use input types (email, tel) or pattern for format rules.
Yes. All panels stay in the DOM the whole time — only the active one is shown via a class. Going back simply changes which panel is visible; the inputs and their values are never removed or re-created, so everything the user typed is still there when they return.
In the nextBtn handler's final-step branch (where it currently adds the .complete class), first gather the field values (e.g. with new FormData(form) or by reading each input), then await a fetch POST to your endpoint. Show the success screen on a successful response, or surface an error toast on failure. You can also disable the button and show a loading state during the request.
Keep the current step index and the form values in state. Render the active panel based on the index, and bind each step's stepper state (active/done) to comparisons with the index. Move validateStep() into a function that checks the current step's values before incrementing the index. Native checkValidity() still works on refs; or validate against your own schema. The CSS — stepper, panel fade, invalid styles — ports unchanged.