Vistaprint

Accordion Step Flow

How to use an Accordion to show the user progressing step-by-step through a series of tasks.

The Accordion Step Flow is a user experience where the user progresses step-by-step through a series of tasks, with that series represented by an Accordion. It is commonly leveraged on extra-small screens instead of a Step Indicator.

We recommend this flow on Extra-Small screens over the Step Indicator because its vertical orientation makes it a better fit for a phone's narrow screen.

In this flow:

  • only the current step is expanded; the user may collapse it if they wish

  • the user may not click on future steps to jump ahead in the flow; future steps are "disabled"

  • the user may click on past steps to jump back in the flow

Let's start out with the complete example, and then we'll break it down piece by piece.

Components

Here are the components and props that we've used:

Accordion

The root of everything is the Accordion.

jsx
<Accordion skin="steps" purpose="list">
...
</Accordion>
  • skin- the "steps" skin gives us the right look and feel for each Collapsible inside the Accordion.

  • purpose- applies the proper semantics (so that the Accordion will use <ol> and <li> tags rather than <div> tags)

Collapsible

One layer deeper, we have Collapsibles. In most cases, we can use the StepCollapsible convenience component:

jsx
<StepCollapsible
collapsibleId={stepNumber.toString()}
disabled={stepNumber > currentStep}
aria-current={stepNumber === currentStep ? 'step' : undefined}
stepNumber={`Step ${stepNumber}`}
stepTitle={stepTitle}
stepValue={stepValue}
>
...
</StepCollapsible>

If you've got a non-standard use-case, you can use the lower-level components themselves:

jsx
<Collapsible
collapsibleId={stepNumber.toString()}
disabled={stepNumber > currentStep}
aria-current={stepNumber === currentStep ? 'step' : undefined}
>
<CollapsibleSummary>
<CollapsibleSummaryButton>
<CollapsibleSummaryButtonStepNumber>
{`Step ${stepNumber}`}
</CollapsibleSummaryButtonStepNumber>
<CollapsibleSummaryButtonStepTitle>
{stepTitle}
</CollapsibleSummaryButtonStepTitle>
<CollapsibleSummaryButtonStepValue>
{stepValue}
</CollapsibleSummaryButtonStepValue>
</CollapsibleSummaryButton>
</CollapsibleSummary>
<CollapsibleContent>...</CollapsibleContent>
</Collapsible>

  • collapsibleId- identifies the Collapsible to its parent Accordion

  • disabled- allows us to apply a disabled look and functionality to the Collapsible. We do this for steps that are in the future, since we don't want to allow users to jump ahead in the flow

  • aria-current helps assistive technologies understand the user's current progress through the step flow

  • stepNumber- pretty straightforward. It's just the text that appears on the far left of each collapsible

  • stepTitle- the text that appears next

  • stepValue- the value associated with the step, which is displayed on the far right

  • error- not shown, but if the step has an error, you can pass true in order to call attention to the error

SelectionSet and TextButton

Inside of a Collapsible, you can put whatever you want inside of your Collapsible; just make sure that the user has some way of continuing to the next step. This example lets the user pick between Selection Set choices, clicking a "Continue" button to proceed:

jsx
<>
<SelectionSet>
<SelectionSetInput value="Standard">
<SelectionSetLabel>Standard</SelectionSetLabel>
</SelectionSetInput>
<SelectionSetInput value="Square">
<SelectionSetLabel>Square</SelectionSetLabel>
</SelectionSetInput>
<SelectionSetInput value="Rounded">
<SelectionSetLabel>Rounded</SelectionSetLabel>
</SelectionSetInput>
</SelectionSet>
<TextButton>Continue</TextButton>
</>

Logic

With our UI all squared away, we can start adding our logic. A good way to approach this is to think UI in terms of state and events.

State

We need to track 4 pieces of information:

  1. The currentStep

  2. Which Collapsible s are expanded vs collapsed

  3. The value for each step

  4. Whether or not there's an error on each step

Events

  1. Someone can click a Collapsible

  • If the step is in the past, we want close our current step and expand the clicked step

  • If the step is the current step, we want to just expand/collapse the current step

  • If the step is in the future, we want to ignore the click, because the user isn't allowed to jump ahead

  1. Someone can select an option from our one of our SelectionSet s

We'll just update the selected option for the specified step number.

  1. Someone can click the "Continue" button

We'll close our current step and expand the next step.

Reducer

When you see a scenario where multiple pieces of state change in response to a single event (e.g. we need to update currentStep and the list of expandedCollapsibles at the same time), that's a time reach for React.useReducer rather than React.useState.

Let's write a reducer to describe the behavior of this UI.

javascript
function accordionStepFlowReducer(state, action) {
switch (action.type) {
case 'step-clicked':
if (action.payload.stepNumber === state.currentStep) {
// if it was the current-step, we'll toggle the expanded-state
return {
...state,
expandedCollapsibles: {
[action.payload.stepNumber]: action.payload.expanded,
},
}
} else if (action.payload.stepNumber > state.currentStep) {
// ignore clicks on future steps
return state
}
// otherwise swap to the clicked step
return {
...state,
currentStep: action.payload.stepNumber,
expandedCollapsibles: { [action.payload.stepNumber]: true },
}
case 'next-button-clicked':
return {
...state,
currentStep: state.currentStep + 1,
expandedCollapsibles: { [state.currentStep + 1]: true },
}
case 'option-selected':
return {
...state,
selectedOptions: {
...state.selectedOptions,
[action.payload.stepNumber]: action.payload.value,
},
}
default:
throw new Error('Unrecognized action!')
}
}

We've defined our 3 events:

typescript
type StepClickPayload = {
type: 'step-clicked'
payload: { stepNumber: number; expanded: boolean }
}
typescript
type nextButtonClickedPayload = {
type: 'next-button-clicked'
}
typescript
type OptionSelectedPayload = {
type: 'option-selected'
payload: { stepNumber: number; value: string }
}

All that's left to do is to dispatch those events from our UI.

Putting it all together

Here's the code all together (except for the reducer that we've just written):

jsx
function AccordionStepFlow() {
const [stepsState, dispatch] = React.useReducer(accordionStepFlowReducer, {
currentStep: 1,
expandedCollapsibles: { 1: true },
selectedOptions: {},
})
const { currentStep, expandedCollapsibles, selectedOptions } = stepsState
return (
<Accordion
expandedCollapsibles={expandedCollapsibles}
onRequestExpandedChange={(collapsibleId, expanded) => {
const stepNumber = parseInt(collapsibleId, 10)
dispatch({ type: 'step-clicked', payload: { stepNumber, expanded } })
}}
skin="steps"
purpose="list"
>
<StepCollapsible
collapsibleId="1"
disabled={1 > currentStep}
stepNumber="Step 1"
stepTitle="Shape"
stepValue={selectedOptions['1']}
>
<SelectionSet
variant="single-select"
skin="buttons"
selectedValue={selectedOptions['1'] || null}
onSelectedValueChange={value => {
dispatch({
type: 'option-selected',
payload: { stepNumber: 1, value },
})
}}
>
<SelectionSetInput value="Standard">
<SelectionSetLabel>Standard</SelectionSetLabel>
</SelectionSetInput>
<SelectionSetInput value="Square">
<SelectionSetLabel>Square</SelectionSetLabel>
</SelectionSetInput>
<SelectionSetInput value="Rounded">
<SelectionSetLabel>Rounded</SelectionSetLabel>
</SelectionSetInput>
</SelectionSet>
<TextButton onClick={() => dispatch({ type: 'next-button-clicked' })}>
Continue
</TextButton>
</StepCollapsible>
<StepCollapsible
collapsibleId="2"
disabled={2 > currentStep}
stepNumber="Step 2"
stepTitle="Paper Weight"
stepValue={selectedOptions['2']}
>
<SelectionSet
variant="single-select"
skin="buttons"
selectedValue={selectedOptions['2'] || null}
onSelectedValueChange={value => {
dispatch({
type: 'option-selected',
payload: { stepNumber: 2, value },
})
}}
>
<SelectionSetInput value="Standard">
<SelectionSetLabel>Standard</SelectionSetLabel>
</SelectionSetInput>
<SelectionSetInput value="Premium">
<SelectionSetLabel>Premium</SelectionSetLabel>
</SelectionSetInput>
</SelectionSet>
<TextButton onClick={() => dispatch({ type: 'next-button-clicked' })}>
Continue
</TextButton>
</StepCollapsible>
<StepCollapsible
collapsibleId="3"
disabled={3 > currentStep}
stepNumber="Step 3"
stepTitle="Finish"
stepValue={selectedOptions['3']}
>
<SelectionSet
variant="single-select"
skin="buttons"
selectedValue={selectedOptions['3'] || null}
onSelectedValueChange={value => {
dispatch({
type: 'option-selected',
payload: { stepNumber: 3, value },
})
}}
>
<SelectionSetInput value="Matte">
<SelectionSetLabel>Matte</SelectionSetLabel>
</SelectionSetInput>
<SelectionSetInput value="Glossy">
<SelectionSetLabel>Glossy</SelectionSetLabel>
</SelectionSetInput>
<SelectionSetInput value="Linen">
<SelectionSetLabel>Linen</SelectionSetLabel>
</SelectionSetInput>
</SelectionSet>
<TextButton>Go to cart</TextButton>
</StepCollapsible>
</Accordion>
)
}