How We Built a Fully Accessible Multi-Step Form in Webflow
.webp)
.webp)
An accessible multi-step form needs three things a standard Webflow form doesn't give you. Each step is a labeled group of fields. Errors are announced and tied to the field that caused them. Focus moves to the right place every time the step changes. We built exactly that for the Accessibility Book Club's book review form, and this post walks through the markup, the validation script, and one production-only bug that nearly broke it.
You can try the live form on the A11Y Unraveled book page. It has three steps: Your Info, Book Review, and Submit.
A multi-step form hides part of itself at all times. Sighted mouse users see the step change and keep going. Someone using a screen reader or a keyboard only knows what the code tells them. If focus stays on a "Next" button that has just disappeared, or an error appears without being announced, the person is stuck. They usually leave.
Webflow's native form handles single-page forms reasonably well. Step logic, step-level validation, and focus management are up to you.
Each step lives inside a <fieldset> with a <legend>, so screen readers announce the group name before the first field. Every input has a visible <label> connected with for and id, and autocomplete values for personal details (WCAG 2.2 SC 1.3.1 Info and Relationships, SC 1.3.5 Identify Input Purpose).
<!-- Step 1: each step is a fieldset; the legend names the group -->
<fieldset class="gwf-form-step">
<legend id="step1-heading" tabindex="-1">Step 1 of 3: Your Information</legend>
<label for="full-name">Full Name</label>
<input type="text" id="full-name" name="fullName" autocomplete="name" required>
<label for="email-address">Email Address</label>
<input type="email" id="email-address" name="email" autocomplete="email" required>
</fieldset>
<!-- One status region for the whole form: announces step changes -->
<p id="form-status" class="sr-only" role="status" aria-live="polite"></p>The sr-only class hides the status text visually while keeping it available to screen readers; use your existing visually-hidden class. The tabindex="-1" on the legend lets the script move focus to it when the step loads, without adding it to the Tab order.
The step indicator shows progress visually with colored circles. We checked every color pair against WCAG 2.2 contrast minimums: 4.5:1 for text and 3:1 for the circle edges (SC 1.4.3 Contrast and SC 1.4.11 Non-text Contrast). These are the Book Club's brand colors.
/* Step indicator: brand colors checked for 4.5:1 text contrast */
.gwf-step-circle {
background-color: #F9D0DE;
color: #2D2D2D;
font-size: 2rem;
font-weight: 800;
}
/* Current step: color changes, but meaning is not carried by color alone */
.gwf-step-item.active .gwf-step-circle {
background-color: #BF9515;
color: #2D2D2D;
}
/* Visible focus on every field and button (SC 2.4.7 Focus Visible) */
.gwf-form-step input:focus-visible,
.gwf-form-step textarea:focus-visible,
.gwf-form-step button:focus-visible {
outline: 3px solid #2D2D2D;
outline-offset: 2px;
}Color alone can't tell a screen reader user which step they're on. The legend text ("Step 1 of 3") and the status message in Step 4 below carry that meaning.
Before the form moves forward, the script checks the current step. Each failing field gets a text error placed right after it, aria-invalid="true", and an aria-describedby link to the error, so a screen reader reads the error along with the field. Focus moves to the first field with a problem (SC 3.3.1 Error Identification and SC 3.3.3 Error Suggestion).
// Validate one step. Returns true when every field in the step is valid.
function validateStep(stepIndex) {
const inputs = steps[stepIndex].querySelectorAll("input, textarea");
let firstError = null;
inputs.forEach((input) => {
const errorId = input.id + "-error";
let errorEl = document.getElementById(errorId);
const labelText =
form.querySelector(`label[for="${input.id}"]`)?.textContent.trim() || "This field";
if (!input.validity.valid) {
// Create the error once, right after the field
if (!errorEl) {
errorEl = document.createElement("p");
errorEl.id = errorId;
errorEl.className = "gwf-error";
input.after(errorEl);
}
// Say what is wrong, not only that something is wrong
errorEl.textContent = input.validity.typeMismatch && input.type === "email"
? "Enter an email address in the format name@example.com."
: `${labelText} is required.`;
input.setAttribute("aria-invalid", "true");
addDescribedBy(input, errorId);
if (!firstError) firstError = input;
} else {
// Clear only this script's error, keep any other descriptions
if (errorEl) errorEl.remove();
input.removeAttribute("aria-invalid");
removeDescribedBy(input, errorId);
}
});
if (firstError) firstError.focus();
return !firstError;
}
// Add an ID to aria-describedby without overwriting existing hints
function addDescribedBy(el, id) {
const ids = (el.getAttribute("aria-describedby") || "").split(" ").filter(Boolean);
if (!ids.includes(id)) ids.push(id);
el.setAttribute("aria-describedby", ids.join(" "));
}
// Remove one ID from aria-describedby, and drop the attribute if empty
function removeDescribedBy(el, id) {
const ids = (el.getAttribute("aria-describedby") || "").split(" ").filter((x) => x && x !== id);
ids.length ? el.setAttribute("aria-describedby", ids.join(" ")) : el.removeAttribute("aria-describedby");
}When the step changes, the old "Next" button disappears. If focus stays there, keyboard and screen reader users lose their place (SC 2.4.3 Focus Order). The script moves focus to the new step's legend and updates the status region, so the change is announced once (SC 4.1.3 Status Messages).
// Show one step, move focus to its legend, and announce the change
function goToStep(stepIndex) {
steps.forEach((step, i) => {
step.hidden = i !== stepIndex;
});
const legend = steps[stepIndex].querySelector("legend");
if (legend) legend.focus();
const status = document.getElementById("form-status");
if (status) status.textContent = `Step ${stepIndex + 1} of ${steps.length}`;
}We don't move focus on page load. Focus only moves in response to something the person did.
The final step shows a preview of the review before submitting. On the staging domain it worked every time. On the live domain, reading the review field's value from the final step sometimes came back empty.
We didn't chase Webflow's internals. We changed the approach: capture the review text while the person types, then use the saved copy for the preview. The preview has worked in production ever since.
// Save the review text as the person types
let reviewCache = "";
form.querySelector("#book-review").addEventListener("input", (e) => {
reviewCache = e.target.value.trim();
});
// On the final step, show the saved copy in the preview
const reviewTextEl = document.getElementById("review-text");
if (reviewTextEl) {
reviewTextEl.textContent = reviewCache;
}The lesson applies well beyond this form. Test every multi-step flow on the published domain, not only on staging. A form that works in one place can still fail in the other.
Most Webflow sites we audit have forms that look finished and still fail people. Errors appear in red with no text explanation, the error isn't tied to its field, or focus is lost between steps. Those failures land on the most valuable pages of a site: quote requests, patient intake, appointment booking.
If your forms feel like that, our post on why Webflow forms fail accessibility covers single-page forms. For multi-step intake and booking forms on healthcare sites, see how we work with healthcare providers.
No. A long form split into short, clearly labeled steps can be easier for many people, including people with cognitive disabilities. The risk comes from how steps are built: lost focus, silent errors, and step changes that aren't announced.
Yes, when a step groups related fields. The <legend> names the group, so screen readers announce it before the first field. A fieldset with a legend doesn't also need aria-labelledby.
Move it to the new step's heading or legend, and announce the change with a status message. Avoid leaving focus on a button that is now hidden.
No, and you shouldn't ask for it twice. WCAG 2.2 SC 3.3.7 Redundant Entry says information already provided in the same process should be filled in or available to select. A review step that shows what they entered also helps people catch mistakes.
We build accessible forms into every new site and fix them on existing ones through Webflow accessibility remediation. If you have an intake, quote, or booking form that people aren't finishing, request a free project quote and we'll tell you what's in the way.
Written by Crystal Scott, founder of Graceful Web Studio. Crystal is a Certified Professional in Web Accessibility (CPWA), a Section 508 Trusted Tester, and a Certified Webflow Expert with over a decade of front-end experience.

When to build your own website and when to hire a pro, with a decision checklist covering accessibility, SEO, speed, conversion, and upkeep.

A realistic local SEO timeline, plus what works: Google Business Profile, an accessible site, and helpful content, with Yakima Valley client examples.

Our 10-step process for moving WordPress to Webflow, from sitemap and CSV import to 301 redirects and accessibility testing.
We would love to meet with you face-to-face. Whether virtually or for a coffee. Request a free project quote, and let’s find the right solution for you! We review your site, set-up a disocvery session and map out goals for a website that grows your business.
