Most service businesses start every enquiry the same way: a generic contact form, then an email chain to find out what the customer actually wants. A cleaning company needs the number of bedrooms for a house clean, the square footage for an office, and the number of windows and floors for window cleaning. One form can’t ask all of those without overwhelming people, so it asks none of them.
A multi-step quote form fixes that. The customer picks a service first, and the questions that follow depend on that choice. Here’s how I build them so they’re easy to maintain, work without JavaScript, and don’t need a developer every time a service changes.
Start with the data, not the form
The mistake I see most is hard-coding each path through the form in JavaScript: if service is X, show fields A, B and C. It works until the business adds a service, and then a developer has to edit the script.
Instead, describe the questions as data. Each service has a list of steps, and each step has its fields:
{
"house-clean": {
"label": "House clean",
"steps": [
{ "title": "Your home", "fields": ["bedrooms", "bathrooms"] },
{ "title": "How often", "fields": ["frequency"] }
]
},
"window-clean": {
"label": "Window cleaning",
"steps": [
{ "title": "Your windows", "fields": ["window_count", "floors"] },
{ "title": "Access", "fields": ["access_notes"] }
]
}
}
On WordPress, that data can come straight from the CMS. If services are a custom post type with an ACF repeater for “quote questions”, the client adds a question in the admin and it appears in the form. The theme outputs the structure as JSON for the script to read:
<script type="application/json" id="quote-config">
<?php echo wp_json_encode( mytheme_quote_config() ); ?>
</script>
Using wp_json_encode() rather than echoing values into a script by hand matters. It escapes everything correctly, so a question with an apostrophe or a slash can’t break the page.
Render every field on the server
For the form to work without JavaScript, the HTML has to contain every field. I render all of them, grouped in fieldsets labelled by service:
<fieldset data-service="window-clean">
<legend>Window cleaning</legend>
<label>Number of windows <input type="number" name="window_count" min="1"></label>
…
</fieldset>
Without JavaScript, the customer sees one long form and fills in the section that applies. It isn’t elegant, but every enquiry still arrives with the right details. The script then turns that markup into steps: it hides every fieldset except the chosen service’s, and shows one step at a time.
Showing and hiding steps
The step logic is small once the data drives it:
const config = JSON.parse(document.getElementById('quote-config').textContent);
let service = null;
let stepIndex = 0;
function render() {
document.querySelectorAll('[data-service]').forEach((set) => {
set.hidden = set.dataset.service !== service;
});
const steps = config[service]?.steps ?? [];
steps.forEach((step, i) => {
step.fields.forEach((name) => {
const field = form.elements[name]?.closest('label');
if (field) field.hidden = i !== stepIndex;
});
});
progress.textContent = `Step ${stepIndex + 1} of ${steps.length}`;
}
Two accessibility details are easy to miss:
- Hidden fields must also be disabled if they’re required. A required field the customer can’t see blocks submission with an error they can’t find. I set
disabledon every field outside the active service, which also stops them being submitted. - Move focus when the step changes. When “Next” swaps the visible fields, focus the new step’s heading so screen reader users hear where they are. An
aria-liveregion on the “Step 2 of 3” text helps too.
Validate each step before moving on
Nobody wants to reach the end of a form and be sent back to step one. Validate each step as the customer leaves it, using the browser’s own constraint validation so the rules live in the HTML:
function stepIsValid() {
const fields = currentStepFields();
const firstInvalid = fields.find((field) => !field.checkValidity());
if (firstInvalid) {
firstInvalid.reportValidity();
return false;
}
return true;
}
The server validates everything again on submit. Client-side checks are for the customer’s convenience; they aren’t a security measure.
Gravity Forms or custom?
Gravity Forms has conditional logic and multi-page forms built in, and for a few branches it’s the quicker route. I reach for a custom form when:
- The questions should come from the service content itself, so adding a service adds its questions automatically.
- The form needs to show an estimate as the customer goes.
- The design needs more control than form styling allows.
Either way, the principle is the same: describe the questions as data, and let the form be generated from it.
What it changes for the business
The difference shows up in the inbox. Instead of “Hi, can I get a quote?”, each enquiry arrives with the service, the size of the job and the frequency. The first reply can be a price rather than a list of questions, which is the reply most likely to win the job.
If your enquiry form is costing you that back-and-forth, a custom WordPress build can fix it.