Reusable Multistep widget

Last updated: 2023-09-xx (a month ago)
One of the most commonly found piece of UI is a multistep process. It is useful for sign-up or check-out processes, and particularly useful to clearly guide the user each step of the way, without overwhelming them.
In this article we will learn how to put together a neat multistep widget, which can be repurposed for any number of use cases! Let's get straight into it, below is the final product, a simple 3-step sign-up process to get access to our brand new gaming platform Mist:
Welcome to Mist, the new gaming platform!

Please follow the steps to create your account

Step 1 of 3
Account details
Pick your games
Complete
Tell us about yourself

Username

Full name

About your gaming experience
Contact information
Here is the breakdown of the features packed into this widget:
  • a view of the whole progress is shown in the top-right corner, with the current step circled in blue;
  • each step is made up of one or more sub-steps. Each of those need to be validated before the user is allowed to move on to the next one;
  • it is possible to move back to previous steps or sub-steps, but not forward. This is necessary to make sure input validation can be properly enforced;
  • the last step is mocking an asynchronous API call that would normally create the account, to illustrate that some steps can include async jobs easily.
Let's review the different components ony by one.

Sub-step

To kick things off, here is the type we are going to apply to our sub-steps.
export type ProgressSubStep = { id: number, name: string, current: boolean, completed: boolean, icon: ReactElement }
It is pretty straightforward, with props purely UI-oriented like the name or icon, and some that will be used dynamically like whether the sub-step is complete, or if it is currently opened.
Then we have the body of the sub-step component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 type Props = { subSteps: ProgressSubStep[], setSubSteps: Dispatch<SetStateAction<ProgressSubStep[]>>, children: ReactElement, } export const ProgressSubSteps = ({ subSteps, setSubSteps, children }: Props) => { const currentSubStep = subSteps.find(step => step.current) || subSteps[0]; {/* Allowing a user to jump back to a step only if has been completed already */} const onSubStepClick = (subStep: ProgressSubStep) => { return (subStep.id === 1 || subStep.completed) ? handleJumpToSubStep(setSubSteps, subSteps, subStep.id) : null } return ( <> {subSteps.map((subStep, index) => { return ( <div key={index} className='bg-white rounded-lg p-6 shadow-lg border-[1px] border-asteroid-300/30'> <div onClick={() => onSubStepClick(subStep)} className='flex justify-between cursor-pointer'> <div className='font-bold text-base flex items-center gap-2'> {subStep.icon} {subStep.name} </div> <div> <CheckCircleIcon className={classNames(subStep.completed ? 'text-green-500' : 'text-gray-300', 'h-7 w-7')} /> </div> </div> {/* CSS transitions don't work with h-auto, so this trick with using grid 0fr/1fr is necessary. (until new CSS "interpolate-size: 'allow-keywords'", which solves this exact scenario, is more widely supported) */} <div className={classNames('grid transition-[grid-template-rows]', subStep.id === currentSubStep.id ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}> <div className='overflow-hidden'> <div className='mt-4 h-auto'>{subStep.id === currentSubStep.id ? children : null}</div> </div> </div> </div> ) })} </> ) }
In this component we find:
  • the props, which include an array of sub-steps to render, as well as a state setter that can be called to update them. It also includes the children to render within the sub-step, typically some kind of form;
  • line 10 is a method handling the click on a sub-step, it is checking that the sub-step was completed, and if so proceeds to update the state to reflect the change. See the short utility functions used to update the state
  • export const handleJumpToSubStep = (setSubSteps: Dispatch<SetStateAction<ProgressSubStep[]>>, subSteps: ProgressSubStep[], newSubStepId: number) => { let clonedSteps = cloneDeep(subSteps); clonedSteps = clonedSteps.map(step => ({ ...step, current: step.id === newSubStepId })) setSubSteps(clonedSteps); } export const handleCompleteSubStep = (setSubSteps: Dispatch<SetStateAction<ProgressSubStep[]>>, subSteps: ProgressSubStep[], subStepId: number) => { let clonedSteps = cloneDeep(subSteps); clonedSteps = clonedSteps.map(step => ({ ...step, completed: step.id === subStepId ? true : step.completed })) handleJumpToSubStep(setSubSteps, clonedSteps, subStepId + 1) }
  • the rest is simply CSS, we are rendering the content of only the current step (line 33), and adding a green icon if it is completed.
Next up we will see the code for step component:

Step

The state holding the sign-up information is called using the type SignUpInfo, and is broken down in the following way:
export type AccountInfo = { username: string, fullName: string } export type SupportedPlatform = "" | "PC" | "Playstation" | "Nintendo"; export type PlatformInfo = { platform: SupportedPlatform, favouriteGames?: string[] } export type ContactInfo = { email: string } export type SignUpInfo = AccountInfo & PlatformInfo & ContactInfo;
We will only share the code of the first step, because steps 2 and 3 become trivial once step 1 is understood.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 type Props = { handleGoToNextStep: (signUpInfo: SignUpInfo) => void, initialValues: SignUpInfo } export const StepOneAccountSetup = ({ handleGoToNextStep, initialValues }: Props) => { const [signUpInfo, setSignUpInfo] = useState<SignUpInfo>(initialValues); const initialSubSteps: ProgressSubStep[] = [{ id: 1, name: 'Tell us about yourself', current: true, completed: Boolean(signUpInfo.username) && Boolean(signUpInfo.fullName), icon: <UserIcon className='h-8 w-8 text-indigo-500' /> }, { id: 2, name: 'About your gaming experience', current: false, completed: Boolean(signUpInfo.platform), icon: <TvIcon className='h-8 w-8 text-indigo-500' /> }, { id: 3, name: 'Contact information', current: false, completed: Boolean(signUpInfo.email), icon: <AtSymbolIcon className='h-8 w-8 text-indigo-500' /> }]; const [subSteps, setSubSteps] = useState<ProgressSubStep[]>(initialSubSteps); const currentSubStep = subSteps.find(step => step.current) || subSteps[0]; const getSubStepContent = () => { switch (currentSubStep.id) { case 1: return ( <StepOneSubStepOne initialValues={{ username: signUpInfo.username, fullName: signUpInfo.fullName }} handleSubmit={(accountInfo: AccountInfo) => { handleCompleteSubStep(setSubSteps, subSteps, 1); setSignUpInfo({ ...signUpInfo, ...accountInfo }) }} /> ); case 2: return ( <StepOneSubStepTwo initialValues={{ platform: signUpInfo.platform }} handleSubmit={(platformInfo: PlatformInfo) => { handleCompleteSubStep(setSubSteps, subSteps, 2); setSignUpInfo({ ...signUpInfo, ...platformInfo }) }} /> ) case 3: return ( <StepOneSubStepThree initialValues={{ email: signUpInfo.email }} handleSubmit={(contactInfo: ContactInfo) => { const updatedSignUpInfo = { ...signUpInfo, ...contactInfo }; handleGoToNextStep(updatedSignUpInfo); }} /> ) default: return <></> } } return ( <ProgressSubSteps allowOverflowForSteps={[2]} setSubSteps={setSubSteps} subSteps={subSteps} > {getSubStepContent()} </ProgressSubSteps> ) }
This component renders the 3 sub-steps of the first step:
  • as props, it is receiving a method that we can call to move to the next main step, once all sub-steps are completed, alongside the initial values that we expect the user to fill out;
  • lines 6 and 8 we are initialising the state variables. The first sub-step is set to be the current one by default, and we also define if any the sub-steps is already completed based on the initial values. This is useful if the user jumps back to previous steps after filling out some parts of the form;
  • line 31 is the switch statement that will provide the content of the current sub-step. As we can see, each is fed initial values and an event handler that can be called when the user needs to move on to the next sub-step;
Let's dig a little further and see the component for the first sub-step
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 type Props = { handleSubmit: ({ username, fullName }: AccountInfo) => void, initialValues: AccountInfo } export const StepOneSubStepOne = ({ handleSubmit, initialValues }: Props) => { /* This object is defining how we want to restrict each field, and the associated error message if a rule is not respected */ const validationSchemaYup = Yup.object().shape({ username: Yup.string() .required('Username is mandatory), fullName: Yup.string() .required('Full name is mandatory), }); return ( <Formik initialValues={initialValues} validationSchema={validationSchemaYup} onSubmit={handleSubmit}> {({ errors, touched }) => ( <Form> <div> <p>Username</p> <Field className="pl-2 block w-full rounded-md border-0 py-1.5 pr-10 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6" id="username" name="username" /> {errors.username && touched.username ? ( <div className='mt-2 lg:text-base text-sm text-red-600'>{errors.username}</div> ) : null} <p>Full name</p> <Field className="pl-2 block w-full rounded-md border-0 py-1.5 pr-10 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6" id="fullName" name="fullName" /> {errors.fullName && touched.fullName ? ( <div className='mt-2 lg:text-base text-sm text-red-600'>{errors.fullName}</div> ) : null} </div> <div className='flex justify-end'> <button disabled={Object.keys(errors).length !== 0} type="submit" className="h-10 flex justify-center my-4 w-40 self-center rounded-md bg-slate-700 px-3 text-sm font-semibold text-white shadow-sm hover:bg-slate-600 disabled:cursor-not-allowed items-center" > <div className="font-semibold lg:text-lg text-base !text-indigo-50">Continue</div> </button> </div> </Form> )} </Formik> ); }
As eluded to earlier, one of the typical piece of UI found in multi-step processes is a form. Here we are using Formik, a library that simplifies form-handling, and which you can learn more about in this article
  • the way we organised our code is making sub-steps pretty simple. Formik can maintain its own internal state so we just inject the initial values and it takes care of the rest;
  • error handling is done through Yup, which can enforce the rule we have defined, like having a properly formed email address. Each error message shows up underneath the appropriate field in case an error is detected;
  • the submit button will trigger the "onSubmit" method only after Formik has validated that each field is following the validation schema we provided. After that the values filled out by the user are sent back up to the parent component.
The rest of the sub-steps is following the exact same pattern, so use the code above as a starting point for creating your own custom multi-step process!

Tying it all up

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 export const ProgressShell = () => { const defaultSteps: ProgressStep[] = [{ id: 1, name: 'Account details', current: true, completed: true }, { id: 2, name: 'Pick your games', current: false, completed: true }, { id: 3, name: 'Complete', current: false, completed: true }] const [steps, setSteps] = useState<ProgressStep[]>(defaultSteps); const [signUpInfo, setSignUpInfo] = useState<SignUpInfo>({ fullName: '', username: '', platform: '', email: '', favouriteGames: [] }); const currentStep = steps.find(step => step.current) || steps[0]; const stepCount = steps.length; const generateJumpToStep = (stepsToUpdate: ProgressStep[], newStepId: number): ProgressStep[] => { const clonedSteps = cloneDeep(stepsToUpdate); const newSteps = clonedSteps.map(step => ({ ...step, current: step.id === newStepId })); return newSteps; } const handleChangeStep = (stepsToUpdate: ProgressStep[], newStepId: number) => { // Jumping back to a previous step is allowed setSteps(generateJumpToStep(stepsToUpdate, newStepId)); } const manuallyChangeStep = (stepsToUpdate: ProgressStep[], newStepId: number) => { if (currentStep.id > newStepId) { handleChangeStep(stepsToUpdate, newStepId); } } const getStepContent = () => { switch (currentStep.id) { case 1: return ( <StepOneAccountSetup initialValues={signUpInfo} handleGoToNextStep={(signUpInfo) => { setSignUpInfo(signUpInfo); handleChangeStep(steps, 2); }} /> ) case 2: return ( <StepTwoGameSelection initialValues={signUpInfo.favouriteGames || []} platform={signUpInfo.platform} handleGoToNextStep={(gameSelection) => { let clonedInfo = cloneDeep(signUpInfo); clonedInfo.favouriteGames = gameSelection; setSignUpInfo(clonedInfo); handleChangeStep(steps, 3); }} /> ) case 3: return ( <StepThreeTwoRecap initialValues={signUpInfo} /> ) default: return <></> } } return ( <div className='my-10'> <div className='flex justify-between flex-col lg:flex-row items-center'> <div className='mb-6 lg:md-0'> <h6 className={'text-xl md:text-2xl text-gray-600'}>Welcome to Mist, the new gaming platform!</h6> <p className={'text-lg text-gray-500'}>Please follow the steps to create your account</p> </div> {/* Progress bar */} <div className='relative flex items-center'> <div className='font-semibold mr-4 opacity-60'> Step {currentStep.id} of {stepCount} </div> <div className='flex items-start justify-between mr-4 md:mr-8'> {steps.map((step, index) => { return ( <div key={index} onClick={() => manuallyChangeStep(steps, step.id)} className='relative flex items-center cursor-pointer'> {/* Pale blue circle set as background, used to display the light blue outline of the current step */} <div className='z-10 absolute h-8 w-8 bg-blue-300 rounded-full'> {/* Aligning the label with the step's dot */} <div className={classNames(!step.current && 'opacity-50', 'font-semibold text-xs text-center w-24 flex items-center justify-center mt-10 left-0 -translate-x-7')}> {step.name} </div> </div> <div className={classNames(step.current ? 'm-1 h-6 w-6' : 'h-8 w-8', 'z-20 bg-gray-200 rounded-full flex items-center justify-center transition-all')}> <div className={classNames(currentStep.id <= step.id ? 'h-0 w-0' : 'h-6 w-6', 'bg-blue-500 rounded-full transition-all')}></div> </div> {stepCount !== index + 1 ? <div className='h-1 w-20 bg-gray-200'></div> : null} </div> ) })} </div> </div> </div> <div className='flex flex-col space-y-4'> {getStepContent()} </div> </div> )}
This last piece is the main componant, and holds the topmost state.
  • line 2 is the initialising of the state variable used to track the steps status. It is similar to the one we used for the sub-steps;
  • line 20, we have the piece of state that is holding the global information filled out by the user, across all steps. Each step is updating that variable upon completion, to track it all in one easily accessible place;
  • then we see 3 funtions that are handling the logic to change the current step. It is either used as a callback by a step component upon completion, or when the user clicks on a previous step in the top-right corner progress bar, in order to go back;
  • line 51 is the same type of switch statement we used when rendering sub-steps. Here it is rendering steps, the first one of which the code we alraedy examined. The other two have similar implementations;
  • finally there is the render, which ties all the other components together. Some CSS is applied to the current step in order to make it visually easy for the user to know how much progress they've made;
This is all for this article, you should now have a strong base on which to develop your own flavour of a multistep widget! See you next time :)