An ordered set of steps that collapse into editable summaries as they are completed, ending in one explicit commit.

A SetupFlow walks someone through an ordered set of dependent decisions that ends in one explicit commit. Exactly one step is open at a time; finished steps collapse to a one-line summary of what was chosen and stay editable; the last step reviews everything and holds the single action that commits it.

Reach for it when the decisions depend on each other and the whole configuration should be reviewable before it is committed. For a plain set of fields, use a Form with one submit. For a progress indicator across separate screens, use Stepper — see the wizard layout pattern. For sections that open in any order, use AccordionGroup.

Importing

import { SetupFlow, SetupFlowStep, SetupFlowFooter } from '@customerio/pluma-components/react';

Usage

Every step declares a value — a stable id, the same idea as a Tabs tab's — plus its label and the summary it collapses to. Everything the flow knows about a step is keyed by that value: which step is open, and which ones are done. The footer moves the flow on.

Loading editor

Statuses are derived, not set

A step is upcoming, active, complete, or invalidated, and that comes from the flow's own state — there is no status prop. A status a feature can set by hand is a status that drifts out of step with the flow it is describing.

Two things decide it, and both are answered by value:

  • Active — the step whose value is the flow's currentStep.
  • Complete — a step the flow has recorded as done. Everything else is upcoming.

Nothing here is positional, so a step works out how it renders the first time it renders, without having to find out where it sits first.

The one thing the flow cannot work out for itself is invalidation: only the feature knows that an edit to an earlier step broke a later one. Set isInvalidated for that, and name what changed in invalidatedSummary rather than silently clearing the step. An invalidated step has to be complete first — there is nothing to break in a step nobody has answered.

Controlling the flow

Leave currentStep off and the flow tracks its own; pass it with onStepChange to control it. Both are a step's value, so what onStepChange reports can be handed straight back.

Name the step to open first in defaultCurrentStep. Without it the flow has no step to open until its steps exist, and has to find the first of them once they do — one tick during which nothing is open.

It is required when the steps themselves render asynchronously. The flow looks for its first step once, as it mounts, so steps that only arrive after a fetch resolves are not there to be found and the flow stays open at none. Naming the step settles it before any of them exist.

const [step, setStep] = useState('name');

return (
	<SetupFlow currentStep={step} onStepChange={setStep}>
		<SetupFlowStep value="name" label="Name the export" summary={`${name} · ${format}`}>
			<TextField label="Export name" value={name} onChange={setName} />
			<SetupFlowFooter isNextDisabled={name === ''} />
		</SetupFlowStep>
		{/* ... */}
	</SetupFlow>
);

Which steps are done

By default the flow keeps its own tally: moving off a step records it. That holds through an edit — reopening the first step of a finished flow adds to the tally rather than winding it back, so the steps after it stay complete.

Pass completedSteps when the feature knows better than the flow does: a flow resumed from saved work, a step completed somewhere else, or one whose answers were thrown away. It is the whole answer while it is set — the flow stops keeping its own.

<SetupFlow currentStep={step} onStepChange={setStep} completedSteps={answeredSteps}>
	{/* ... */}
</SetupFlow>

Validation

isNextDisabled gates the step. Never let an invalid step advance — the review at the end is a summary, not a second chance to catch what the step should have caught.

The final step

Mark the review step isFinal. It has no summary and no way to collapse, and its footer holds the commit. Name the outcome in nextLabel — "Create export", "Send for previews" — never "Finish" or "Done". A final step's footer has nothing to advance to, so give it an onNext that commits.

Reopening a completed step

On a completed step the whole header row is the control — a real <button> spanning the step's columns, so the number, the title, the summary and the Edit affordance are all one click target and one tab stop, and Enter and Space both activate it. The Edit affordance at the trailing edge is a Link rendered as a span: it looks like a link and does nothing on its own, so there is no control nested inside a control. The row's accessible name is read off the row itself — the affordance, the title and the summary, so "Edit Name the export Weekly orders · CSV", or "Review Name the export 2 of 4 fields are no longer available" while the step is invalidated. Set editLabel to change the first part. A button's name replaces the text inside it, so the summary has to be part of the name or a screen reader never hears the record of what was chosen.

Use onBeforeEdit to intercept, returning false to leave the step closed; the veto covers the whole row, not just the affordance. Reopening an earlier step leaves later steps complete — the flow never un-completes a step on its own. Mark one isInvalidated when the change genuinely broke it, or drop it from completedSteps when its answers are gone.

Steps that are unavailable

isDisabled marks a step the flow cannot open. It keeps its place in the numbering, but the flow steps over it on the way to the next one — and the same walk decides which step an unseeded flow starts at, so a disabled first step is not where the flow opens. It renders no control to reopen it either.

Naming a disabled step in currentStep or defaultCurrentStep still opens it. That is the feature overruling the flow, and the flow takes the feature's word for it.

Only the step's marker recedes into the disabled palette; the title and summary stay at full strength. Say why the step is unavailable in its summary rather than leaving the grey marker to carry it.

<SetupFlowStep
	value="schedule"
	label="Set a schedule"
	summary="Scheduling needs a paid workspace"
	isDisabled
/>

Focus

Advancing the flow, or reopening an earlier step, moves focus to the newly opened step's title so a keyboard or screen reader user lands on the step they now have to fill in. A flow that has only just rendered never takes focus, including one that finds its first step a tick after mount.

What the flow does not own

The container header and the close guard belong to whatever holds the flow. A flow with unsaved input should guard closing with a ConfirmationModal; a pristine one closes silently. For a flow beside live preview content, see Setup flow with a preview.

Content

  • Step titles are short verb phrases, parallel in form and in sentence case: "Choose data", not "Data selection".
  • Summaries are the chosen values, not a description of the step: "Weekly orders · CSV", not "You picked a format".
  • Keep flows to two to five steps. If a step needs its own scrollbar, split it.

API

The value of every step to count as complete. Leave it off and the flow keeps its own tally: moving off a step records it. Set it when the feature knows better than the flow does — a step completed elsewhere, or one whose answers were thrown away.

The value of the step that is currently open. Pass it together with onStepChange to control the flow; leave it off to let the flow track its own.

The value of the step open on first render. Ignored when currentStep is set.

Prefer setting it. With nothing named, the flow has no step to open until its steps exist, and has to find the first of them once they do — one tick during which no step is open.

Required when the steps themselves render asynchronously. The flow looks for its first step once, as it mounts; steps that arrive after a fetch has resolved are not there to be found, and the flow stays open at none. Naming the step settles it before any of them exist, which is the only answer that doesn't make a step's arrival re-open the flow underneath the customer.

Called when the flow moves to another step, with that step's value.

Label for the control that reopens a completed step.

Replaces summary while the step is invalidated. Name what changed — "2 of 4 fields are no longer available" — rather than only that something did.

Whether the step is unavailable. A disabled step cannot be opened or edited: the flow steps over it on its way to the next one, and it offers no control to reopen it. Its marker recedes into the disabled palette; its words stay readable.

Naming it in currentStep or defaultCurrentStep still opens it — that is the feature overruling the flow, and the flow takes the feature's word for it.

Marks the step that reviews every earlier decision and holds the flow's single commit action. A final step has no summary and cannot be collapsed.

Whether an edit to an earlier step has broken this step's values. Set it from the feature — the flow can't know what invalidates what. An invalidated step shows a caution marker and must be re-confirmed before the flow can be committed.

The step's title. Keep it a short verb phrase — "Choose data", not "Data".

Called before a completed step reopens. Return false to leave it closed.

A one-line record of what was chosen, shown once the step is complete. Give the values themselves — "Weekly orders · CSV" — not a description of the step.

A stable identifier for the step, unique within the flow. Everything the flow knows about a step is keyed by it: which step is open, and which are done.

Whether the step's inputs are not yet valid. An invalid step must not advance.

Whether the action is in flight. Commits are usually asynchronous.

Label for the action that moves the flow on. On the final step this commits, so name the outcome — "Create export", never "Finish".

Called when the action is pressed. Defaults to advancing to the next step; on the final step there is nothing to advance to, so supply the commit here.

Related