Real-world Forms: Multi-step & Settings Pages
Office software is mostly forms. Admission portals, KYC screens, insurance claims, checkout pages and account settings can each have 20 or more fields. Put all of them in one long column and users give up halfway. Professional teams use two patterns again and again: the multi-step form (a wizard), which splits a long form into short screens, and the settings page, which groups many small options into labelled sections with one Save button.
In this lesson you will build both, step by step, using the form classes from Forms and the validation pattern from Form Validation.
Plan before you code
Write the fields on paper first and group them by topic. Each group becomes a <fieldset> with a <legend>: this is plain HTML, and it lets screen readers announce "Student details, group" before the first field. Then decide:
- Which groups are steps? Three to five steps is comfortable. One step with two fields is too small; one step with fifteen is too big.
- What is checked at each step? Users should fix mistakes before moving on, not on the last screen.
- What does the review screen show? Long forms need a "check your answers" page before submitting.
For the layout, use one column on phones and two columns from md for short fields such as date of birth and mobile number. Long fields such as the address always take the full width.
Building a multi-step admission form
You will build a three-step admission form for "Jaipur Institute of Design": student details, course choice, then review and submit.
Step 1: one fieldset per step
Put every step in the same <form> so the browser can still submit all fields together. Only one fieldset is visible at a time; the others carry the hidden attribute (which d-none would also do, but hidden is meaningful HTML).
<form id="admission" novalidate>
<fieldset class="step" data-title="Student details">
<legend class="visually-hidden">Student details</legend>
<!-- name, date of birth, mobile -->
</fieldset>
<fieldset class="step" data-title="Course" hidden> ... </fieldset>
<fieldset class="step" data-title="Review and submit" hidden> ... </fieldset>
</form>novalidate turns off the browser's own pop-up messages, so Bootstrap's styled feedback can be shown instead.
Step 2: a heading and a progress bar that show where you are
Above the form, add a heading that will say "Step 1 of 3: Student details" and a progress bar. In Bootstrap 5.3 the role="progressbar" and the aria-value* attributes go on the outer progress element, and the inner progress-bar only sets the width. Give the heading tabindex="-1" so your script can move keyboard focus to it.
Step 3: validate only the current step
When the user presses Next, check just the fields inside the visible fieldset. Every form field has a checkValidity() method that runs the HTML rules (required, pattern, min...). If any field fails, add was-validated to that fieldset: Bootstrap's selectors such as .was-validated .form-control:invalid work on any ancestor, so the red messages appear only for this step.
Step 4: change the step and move focus
Hide the current fieldset, show the next one, update the heading text and the progress values, then call focus() on the heading. Without that, a screen reader user hears nothing after pressing Next, and a keyboard user's focus is left on a button that may now be hidden.
Step 5: fill the review screen and submit
Before showing the last step, copy the answers into a summary list. Use textContent, never innerHTML, because the values are typed by users. On submit, check the consent box, then show a success message. In a real project this is where you would send the data to the server with fetch.
Here is the complete form:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admission form</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<main class="container py-4" style="max-width: 640px">
<h1 class="h4">Jaipur Institute of Design: admission 2027</h1>
<h2 class="h6 mt-3" id="stepTitle" tabindex="-1">Step 1 of 3: Student details</h2>
<div class="progress mb-4" role="progressbar" aria-label="Form progress" aria-valuenow="33" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" id="bar" style="width: 33%"></div>
</div>
<form id="admission" novalidate>
<fieldset class="step" data-title="Student details">
<legend class="visually-hidden">Student details</legend>
<div class="mb-3">
<label for="name" class="form-label">Full name</label>
<input id="name" class="form-control" required autocomplete="name">
<div class="invalid-feedback">Enter your name as on your marksheet.</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="dob" class="form-label">Date of birth</label>
<input id="dob" type="date" class="form-control" required>
<div class="invalid-feedback">Choose your date of birth.</div>
</div>
<div class="col-md-6">
<label for="mobile" class="form-label">Mobile number</label>
<input id="mobile" type="tel" class="form-control" required pattern="[6-9][0-9]{9}" inputmode="numeric" autocomplete="tel-national" aria-describedby="mobileHelp">
<div id="mobileHelp" class="form-text">10 digits, starting with 6, 7, 8 or 9.</div>
<div class="invalid-feedback">Enter a valid 10-digit mobile number.</div>
</div>
</div>
</fieldset>
<fieldset class="step" data-title="Course" hidden>
<legend class="visually-hidden">Course</legend>
<div class="mb-3">
<label for="course" class="form-label">Course</label>
<select id="course" class="form-select" required>
<option value="">Choose...</option>
<option>B.Des Fashion Design</option>
<option>B.Des Interior Design</option>
<option>Diploma in Graphic Design</option>
</select>
<div class="invalid-feedback">Choose a course.</div>
</div>
<p class="mb-2">Batch</p>
<div class="form-check">
<input class="form-check-input" type="radio" name="batch" id="b1" value="Weekday" required>
<label class="form-check-label" for="b1">Weekday</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="radio" name="batch" id="b2" value="Weekend" required>
<label class="form-check-label" for="b2">Weekend</label>
<div class="invalid-feedback">Choose a batch.</div>
</div>
</fieldset>
<fieldset class="step" data-title="Review and submit" hidden>
<legend class="visually-hidden">Review and submit</legend>
<ul class="list-group mb-3" id="summary"></ul>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="agree" required>
<label class="form-check-label" for="agree">The details above are correct.</label>
<div class="invalid-feedback">Please confirm before submitting.</div>
</div>
</fieldset>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" id="back" hidden>Back</button>
<button type="button" class="btn btn-primary ms-auto" id="next">Next</button>
<button type="submit" class="btn btn-success ms-auto" id="submit" hidden>Submit application</button>
</div>
</form>
<div class="alert alert-success mt-3" id="done" role="status" hidden></div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
<script>
const form = document.getElementById("admission");
const steps = [...form.querySelectorAll(".step")];
const title = document.getElementById("stepTitle");
const progress = document.querySelector(".progress");
let current = 0;
const valueOf = (id) => document.getElementById(id).value;
function stepIsValid(step) {
const fields = [...step.querySelectorAll("input, select, textarea")];
const ok = fields.every((field) => field.checkValidity());
step.classList.toggle("was-validated", !ok);
if (!ok) fields.find((field) => !field.checkValidity()).focus();
return ok;
}
function fillSummary() {
const rows = [
["Name", valueOf("name")],
["Date of birth", valueOf("dob")],
["Mobile", valueOf("mobile")],
["Course", valueOf("course")],
["Batch", form.elements.batch.value], // the checked radio in the "batch" group
];
const list = document.getElementById("summary");
list.replaceChildren(...rows.map(([label, value]) => {
const li = document.createElement("li");
li.className = "list-group-item";
li.textContent = `${label}: ${value}`;
return li;
}));
}
function show(index) {
steps[current].hidden = true;
current = index;
steps[current].hidden = false;
const percent = Math.round(((current + 1) / steps.length) * 100);
title.textContent = `Step ${current + 1} of ${steps.length}: ${steps[current].dataset.title}`;
progress.setAttribute("aria-valuenow", percent);
document.getElementById("bar").style.width = percent + "%";
document.getElementById("back").hidden = current === 0;
document.getElementById("next").hidden = current === steps.length - 1;
document.getElementById("submit").hidden = current !== steps.length - 1;
title.focus();
}
document.getElementById("next").addEventListener("click", () => {
if (!stepIsValid(steps[current])) return;
if (current + 1 === steps.length - 1) fillSummary();
show(current + 1);
});
document.getElementById("back").addEventListener("click", () => show(current - 1));
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!stepIsValid(steps[current])) return;
form.hidden = true;
const done = document.getElementById("done");
done.textContent = `Thank you, ${valueOf("name")}. Your application has been received.`;
done.hidden = false;
});
</script>
</body>
</html>Press Next with empty fields: only the first step turns red and focus jumps to the first wrong field. Fill it in (try 9876543210 as the mobile number), choose a course and batch, and the review step lists your answers.
The settings page pattern
Settings pages have the opposite shape: many small, independent options that users change one or two at a time. The usual layout is a side menu of sections, cards for each section, labels on the left and controls on the right on wide screens, switches for on/off options, and one Save button that stays visible.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shop settings</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-body-tertiary">
<main class="container py-4">
<h1 class="h4 mb-3">Settings: Priya's Saree House, Pune</h1>
<div class="row g-4">
<nav class="col-md-3" aria-label="Settings sections">
<div class="list-group sticky-md-top" style="top: 1rem">
<a class="list-group-item list-group-item-action" href="#shop">Shop details</a>
<a class="list-group-item list-group-item-action" href="#alerts">Notifications</a>
</div>
</nav>
<form class="col-md-9" id="settings">
<section class="card mb-4" id="shop" aria-labelledby="shopTitle">
<div class="card-header"><h2 class="h6 mb-0" id="shopTitle">Shop details</h2></div>
<div class="card-body">
<div class="row mb-3">
<label for="shopName" class="col-sm-4 col-form-label">Shop name</label>
<div class="col-sm-8"><input id="shopName" class="form-control" value="Priya's Saree House"></div>
</div>
<div class="row">
<label for="gstin" class="col-sm-4 col-form-label">GSTIN (optional)</label>
<div class="col-sm-8"><input id="gstin" class="form-control" maxlength="15" autocapitalize="characters"></div>
</div>
</div>
</section>
<section class="card mb-4" id="alerts" aria-labelledby="alertsTitle">
<div class="card-header"><h2 class="h6 mb-0" id="alertsTitle">Notifications</h2></div>
<div class="card-body">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch" id="smsOrders" checked>
<label class="form-check-label" for="smsOrders">SMS me for every new order</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="weekly">
<label class="form-check-label" for="weekly">Weekly sales summary by email</label>
</div>
</div>
</section>
<div class="sticky-bottom bg-body border rounded p-2 d-flex align-items-center gap-2">
<span class="small text-body-secondary me-auto" id="saveState" aria-live="polite">No unsaved changes</span>
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
<script>
const settings = document.getElementById("settings");
const state = document.getElementById("saveState");
settings.addEventListener("input", () => { state.textContent = "You have unsaved changes"; });
settings.addEventListener("submit", (event) => {
event.preventDefault();
state.textContent = "Saved at " + new Date().toLocaleTimeString("en-IN");
});
</script>
</body>
</html>The pieces worth noting:
- Horizontal fields: a
rowwithcol-sm-4 col-form-labelon the label andcol-sm-8around the control. Below thesmbreakpoint they stack, which is what phones need. - Switches:
form-check form-switchwithrole="switch"on the checkbox, so screen readers say "switch, on" rather than "checkbox, checked". - Sticky menu and Save bar:
sticky-md-topkeeps the menu in view frommdup, andsticky-bottomkeeps the Save bar visible while scrolling long forms. - Unsaved changes: listening for the
inputevent on the whole form (event bubbling) is enough to warn users before they leave.
Older code
Bootstrap 4 forms used form-group for each field, form-row for tighter rows and custom-control custom-switch for switches. Bootstrap 5 replaced them with margin utilities (mb-3), normal grid rows with gutters (g-3) and form-check form-switch. Older wizard pages were also often built with jQuery plugins; the plain JavaScript above does the same job in about 50 lines.
Common mistakes
- One giant form with no grouping. Users lose their place. Use fieldsets, legends and steps.
- Validating only at the very end. Users must go back three screens to find the error. Validate each step before Next.
- Not moving focus when the step changes. Keyboard and screen reader users are left on a hidden button. Focus the new step's heading.
- Using separate
<form>elements per step and losing the data. Keep one form, and hide or show its fieldsets. - Building the review list with
innerHTMLand user input. That opens the door to script injection. UsetextContent.
Key points
- Group long forms into
<fieldset>sections with legends, then decide which groups become steps. - Keep all steps inside one
<form>withnovalidate, showing one fieldset at a time withhidden. - Check the current step with
checkValidity()and addwas-validatedto that fieldset only. - Update a heading and the
progresselement'saria-valuenow, and move focus to the heading on each step. - Settings pages use cards per section, horizontal
col-form-labelrows,form-switchtoggles and a sticky Save bar.
Exercise
Extend the admission form with a fourth step, "Guardian details", placed before the review step. It needs a guardian name (required) and a relationship select (Father, Mother, Other; required). Make sure the step counter says "of 4", the progress bar moves in quarters, and the review list includes both new answers.
Show solution
<fieldset class="step" data-title="Guardian details" hidden>
<legend class="visually-hidden">Guardian details</legend>
<div class="mb-3">
<label for="guardian" class="form-label">Guardian name</label>
<input id="guardian" class="form-control" required>
<div class="invalid-feedback">Enter the guardian's name.</div>
</div>
<div class="mb-3">
<label for="relation" class="form-label">Relationship</label>
<select id="relation" class="form-select" required>
<option value="">Choose...</option>
<option>Father</option>
<option>Mother</option>
<option>Other</option>
</select>
<div class="invalid-feedback">Choose a relationship.</div>
</div>
</fieldset>
<script>
// Add these two rows to the array in fillSummary():
// ["Guardian", valueOf("guardian")],
// ["Relationship", valueOf("relation")],
</script>Insert the fieldset just before the review fieldset and add the two summary rows. The script counts steps.length, so the heading changes to "of 4" and the progress bar moves 25% per step without further changes. Also set the starting values to 25: style="width: 25%" on the bar and aria-valuenow="25" on the progress element.
Quick Quiz
1. How do you show Bootstrap's validation styles for only the current step of a multi-step form?
Bootstrap's .was-validated .form-control:invalid selector works on any ancestor, so the fieldset is enough.
2. Why should the script move focus to the step heading after Next?
Without moving focus, assistive technology announces nothing and focus may be left on a hidden button.
3. In Bootstrap 5.3, which element gets role="progressbar" and aria-valuenow?
Since 5.3 the ARIA attributes go on the .progress wrapper; the inner bar only sets the width.
4. Which markup makes a settings label sit beside its input on wide screens and above it on phones?
Grid columns from sm up place them side by side, and they stack below sm.
Learn Bootstrap with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
