Skip to main content

Command Palette

Search for a command to run...

Progressive Disclosure in a Booking Form: Filter Without Losing Users" subtitle: "How to model a multi-step qualification form as a state machine, where to validate, and which fields to cut.

How to model a multi-step qualification form as a state machine, where to validate, and which fields to cut.

Updated
8 min readView as Markdown
Progressive Disclosure in a Booking Form: Filter Without Losing Users"
subtitle: "How to model a multi-step qualification form as a state machine, where to validate, and which fields to cut.
X
Xenon Builds helps car detailers, PPF, ceramic coating, tint, and wrap shops turn their work into booked-out businesses. This blog is where I break down what actually works: how detailers get found on Google, why online booking beats taking every job through DMs, what makes a detailing website convert visitors into paying customers, and the real business side most detailers never get taught. I build custom websites with booking systems and local SEO built in for the automotive service industry, and everything here comes from doing that work with real shops. One client booked 4 jobs in his first week after launch. If you run a detailing or automotive service business and you want to stop chasing bookings and start getting found, you're in the right place.

Every lead form carries the same unresolved tension: each field you add increases the information value of a submission and decreases the number of submissions you get. Most teams resolve it by guessing, then argue about it in review.

This is a write up of the pattern we settled on for small business service booking, where the form is not a checkout but a qualification step. The shape is a four step flow modelled as a finite state machine, one question per step, contact details last. Below is the state model, the validation timing, the field selection method, and the instrumentation that tells you which step is actually leaking.

The constraint you are designing against

Two industry benchmarks set the boundaries, and both are worth having in front of you before anyone picks field count.

Baymard Institute's meta analysis of fifty studies puts average checkout and form abandonment at 70.22%, rising to 85.2% on mobile, with a process that felt too long or too complicated cited in 18% of cases (Baymard Institute).

These are industry figures, not our own measurements, so treat them as a prior rather than a target. The useful part is the mobile delta. A 15 point gap between desktop and mobile abandonment means a form that tests fine on a desktop mockup can be functionally broken in the context where it is actually completed. For service businesses that context is a phone, one handed, often outdoors.

The second constraint is the opposite pressure. A submission with a name, an email and a free text message is unroutable. You cannot branch on it, score it, or send it anywhere useful without a human reading it first. So the goal is not a shorter form. It is a form whose fields are structured and sequenced well enough to stay short while producing routable data.

Model the flow as a state machine, not a page with conditionals

The failure mode we kept hitting was step logic implemented as a pile of booleans inside a component. showStep2, hasSelectedService, isMobileJob. It works for three steps and becomes unmaintainable at five, because the valid states are implicit and nothing stops you reaching an impossible one.

Declaring the steps as data and driving transitions through a reducer fixes that. The valid states become enumerable, the branching lives in one place, and the back button stops being a special case.

type StepId = "service" | "vehicle" | "timeline" | "location" | "contact";

type Answers = Partial<{
  service: "coating" | "film" | "correction" | "maintenance";
  vehicle: string;
  timeline: "this-week" | "this-month" | "researching";
  location: string;
  name: string;
  phone: string;
}>;

type Step = {
  id: StepId;
  validate: (a: Answers) => string | null;
  skipIf?: (a: Answers) => boolean;
};

const STEPS: Step[] = [
  {
    id: "service",
    validate: (a) => (a.service ? null : "Pick a service to continue"),
  },
  {
    id: "vehicle",
    validate: (a) =>
      a.vehicle && a.vehicle.trim().length > 1 ? null : "Add your vehicle",
  },
  {
    id: "timeline",
    validate: (a) => (a.timeline ? null : "Choose a rough timeline"),
  },
  {
    id: "location",
    // only mobile-serviced jobs need a service radius check
    skipIf: (a) => a.service === "correction",
    validate: (a) => (a.location ? null : "Add a ZIP code"),
  },
  {
    id: "contact",
    validate: (a) =>
      a.phone && /^[\d\s()+-]{7,}$/.test(a.phone) ? null : "Add a phone number",
  },
];

type State = { index: number; answers: Answers; error: string | null };

type Action =
  | { type: "answer"; patch: Answers }
  | { type: "next" }
  | { type: "back" };

function nextIndex(from: number, answers: Answers, dir: 1 | -1) {
  let i = from + dir;
  while (STEPS[i]?.skipIf?.(answers)) i += dir;
  return Math.min(Math.max(i, 0), STEPS.length - 1);
}

export function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "answer":
      // clear the error on input, never re-validate mid-typing
      return {
        ...state,
        answers: { ...state.answers, ...action.patch },
        error: null,
      };
    case "next": {
      const error = STEPS[state.index].validate(state.answers);
      if (error) return { ...state, error };
      return {
        ...state,
        index: nextIndex(state.index, state.answers, 1),
        error: null,
      };
    }
    case "back":
      return {
        ...state,
        index: nextIndex(state.index, state.answers, -1),
        error: null,
      };
  }
}

Three things this buys you. skipIf handles conditional steps without the component knowing anything about business rules. Going back skips the same steps going forward skipped, which is where hand rolled implementations usually break. And because validation is colocated with each step definition, the submit handler never needs a second copy of the rules.

Validation timing is the part that gets rushed

Validate on step advance, not on change and not on blur.

On change punishes people mid typing, which is the single most common complaint we see in session recordings of forms like this. On blur is subtler but still wrong for a one question step, because tabbing out of the only field on screen fires an error before the user has done anything wrong. Advance is the natural commitment point, and it is the only one where the user has signalled they consider the answer finished.

Two details that matter on mobile more than desktop:

  • Move focus to the new step's first input on transition, and announce the change with aria-live="polite". Without it, a screen reader user gets a silent DOM swap and keyboard users get dropped back to the top of the document.

  • Set inputMode and autoComplete per field. inputMode="numeric" with autoComplete="tel" on the phone step, autoComplete="postal-code" on the ZIP step. It is two attributes and it removes an entire class of typing friction on the step where abandonment is highest.

Choosing which fields survive

The selection rule we use: a field earns a place only if it changes how the inquiry is handled. If two different answers produce the same next action, the field is decoration.

Field Signal it produces Verdict
Service (structured select) Routing, price band, which bay Keep, step 1
Vehicle Surface area, condition, effort estimate Keep, step 2
Timeline Active buyer vs researcher Keep, step 3
ZIP or location Serviceability, travel time Keep, conditional
Phone The only reliable reply channel Keep, last step
Email Duplicates phone for this use case Drop or make optional
Free text message Unroutable, invites an essay Drop
"How did you hear about us" Attribution, better from analytics Drop
Company name Irrelevant for consumer work Drop

Structured beats free text every time, because structured answers are filterable, routable and countable without a human in the loop. A select with your real package names also forces a self assessment against your own service structure, which does more filtering than any wording change to a message box.

We build Framer sites and booking flows for automotive shops, so this pattern comes up constantly, and the drop list above is consistently the argument. Owners want the message box. The message box is where routable data goes to die.

Implementation order

  1. Export the last sixty submissions and tag each one actionable or not, with the reason. This is your disqualification profile, and it is usually not what the owner assumes.

  2. Promote the top two disqualification reasons to steps one and two. Build the filter that matches the observed leak.

  3. Declare the steps as data, as above. Resist putting business rules in the component.

  4. Set validation to fire on advance only, with the message rendered adjacent to the input rather than at the top of the form.

  5. Add focus management and aria-live on transition, plus inputMode and autoComplete per field.

  6. Emit an analytics event on every step entry and every validation failure, with the step id as a property. Total submissions is a useless metric here, per step drop off is the whole point.

  7. Route on the answers. High intent selections go to a calendar or deposit step, low intent selections go to a pricing or FAQ page rather than into an inbox.

What the instrumentation actually tells you

Once per step events are flowing, the diagnosis becomes mechanical. A cliff at one step means that question is worded badly or asked too early. Repeated validation failures on a single field mean the input type is wrong, not that users are careless. Users going backwards repeatedly usually means the step order does not match how they think about the job.

The instinct when submissions drop is to remove a step. Usually the right move is to move a step, because the offending question was fine and its position was not.


Written by the team at Xenon Builds. We build Framer sites and booking systems for automotive shops across the US, which means we spend a lot of time on conversion, page speed and form UX for a very specific kind of small business.