Creating a fancy stepper component in React

This is an Stepper made in React:

React Stepper in action gif
Stepper in action gif

Steppers let you display content in sequencial steps, they are good for:

  • Split big forms in a dynamic way, so the user doesn’t need to fill 20000 inputs at once
  • Present data in a logical way, for example, in order to understand the content in the step 2 you need to see the step 1 first

In this tutorial we are going to create a Stepper component in React, taking care of the usability and accessibility, and we are going to create a cool and fancy one.

If you don’t want to go through the tutorial, the code is in github already, here

Create the project

First things first, let’s create our project, we are going to create one with ‘create-react-app’ and Typescript

npx create-react-app fancy-stepper --template typescript

Disclaimer: I don’t recommend to use create-react-app to generate your project, it adds a lot of dependencies that you probably don’t need at first, but for learning purposes is fast and easy to use.

Once our project is generated:

cd fancy-stepper

And start the app

yarn start

Your app should be running on localhost:3000

Preparing the project

Next step is to create our Stepper component. Let’s go to our src directory and let’s create a file called Stepper.tsx, and we are going to create our component like this:

import React from 'react';

interface StepperProps {
	// Empty right now, we will fill this later
}

export const Stepper: React.FC<StepperProps> = () => {
    return <>Nothing yet</>
}

Now, go to your App.tsx file,and remove everything, then add your Stepper component.

import React from 'react';
import { Stepper } from './Stepper';
import './App.css';

function App() {
  return (
    <div>
      <Stepper />
    </div>
  );
}

export default App;

Creating our Stepper functionalities

If we make a breakdown of what an Stepper can do, we can summarize it like this:

  • Display N steps
  • Go to next step
  • Go to previous step
  • Update the Stepper progress

The Steps

We are going to pass steps to the stepper component using the render props pattern , let’s start creating an steps prop in our component. That prop will accept an array of objects, and each object will configure each step, let’s write our types first:

import React from "react";

interface StepperProps {
    steps: Step[];
}

interface Step {
    // Title of the step
    title: string;
    // Element to render in the step, can contain
    // a form, an image, whatever
    element: (stepProps:StepProps) => JSX.Element;
}

export interface StepProps {
  // Here we tell the stepper to go to the next or previous step from
  // the element we are rendering
  goNextStep: () => void;
  goPreviousStep: () => void;
  // Tells you the active step right now
  currentStep: number;
  // And this is useful to know where you are
  isLast: boolean;
  isFirst: boolean;
  // Tells you the step in which you are right now, starting
  // from 1
  step: number;
}

export const Stepper: React.FC<StepperProps> = ({steps}) => {
  return <>Nothing yet</>;
};

You will notice, now in our App.tsx file, we have an error because the Stepper component is missing the steps prop, let’s add it:

import React from "react";
import { Stepper } from "./Stepper";
import "./App.css";

function App() {
  return (
    <div>
      <Stepper
        steps={[
          {
            title: "I'm the step 1",
            // Render whatever you want here, we will improve this later
            element: ({ goNextStep, goPreviousStep }) => <>Step 1</>,
          },
          {
            title: "I'm the step 2",
            element: ({ goNextStep, goPreviousStep }) => <>Step 2</>,
          },
        ]}
      />
    </div>
  );
}

export default App;

Nice!, now our steps and our Stepper are ready.

Rendering our Steps

We need to display the steps sequentially, since we don’t want the steps to appear and disappear from the DOM, because that’s not good for accessibility, we are going to render them linearly with an overflow:hidden wrapper, like this:

Overflow hidden representation
Overflow hidden representation

The red border represents the visible area, and each grey box represents each step, we only see the step which is currently inside the red area.

let’s start by rendering the steps in our Stepper component:

export const Stepper: React.FC<StepperProps> = ({ steps }) => {
  const goNextStep = () => {};
  const goPreviousStep = () => {};

  return (
    <div className="stepper stepper-wrapper">
      {/* This div represents the red bordered box */ }
      <div className="stepper-selector">
        {steps.map(step => (
          <div>
            <step.element
              // NOOP right now, we will update this later
              goNextStep={goNextStep}
              goPreviousStep={goPreviousStep}
              // Fill this with fake values, we will go
              // over this later
              currentStep={0}
              isFirst={false}
              isLast={false}
            />
          </div>
        ))}
      </div>
    </div>
  );
};

Now, state

Our stepper needs to store the value of the active step, we are going to use React state for this, how we are going to update this is using the goNextStep and goPreviousStep functions, those functions are being passed to the steps we are rendering.

export const Stepper: React.FC<StepperProps> = ({ steps }) => {
  const [currentStep, setCurrentStep] = useState<number>(1);
  const goNextStep = () => {
    const nextStep = currentStep + 1;
    if (nextStep <= steps.length) {
      setCurrentStep(nextStep);
    }
  };

  const goPreviousStep = () => {
    const previousStep = currentStep - 1;
    if (previousStep >= 1) {
      setCurrentStep(previousStep);
    }
  };

  return (
    <div className="stepper stepper-wrapper">
      <div className="stepper-selector">
        {steps.map((step, i) => (
          <div className="step-wrapper">
            <step.element
              step={i + 1}
              goNextStep={goNextStep}
              goPreviousStep={goPreviousStep}
              // From our state
              currentStep={currentStep}
              // Check if this step is the first one
              isFirst={i === 0}
              // Check if its the last one
              isLast={i === steps.length - 1}
            />
          </div>
        ))}
      </div>
    </div>
  );
};

Making it fancy

Now let’s improve what we render in each step, so we can play with it a bit, we are going to add transitions too.

function App() {
  return (
    <div className="wrapper">
      <Stepper
        steps={[
          {
            title: "I'm the step 1",
            // Render whatever you want here, we will improve this later
            element: stepProps => <Step {...stepProps} />,
          },
          {
            title: "I'm the step 2",
            element: stepProps => <Step {...stepProps} />,
          },
        ]}
      />
    </div>
  );
}

export default App;

const Step: React.FC<StepProps> = ({
  goNextStep,
  goPreviousStep,
  isFirst,
  isLast,
  currentStep,
  step,
}) => {
  return (
    <div className="step">
      <div className="step-body">IM THE STEP {step}</div>
      <div className="step-actions">
        {/* If we are in the Step 1, we cannot go back, so we disable this */}
        <button
          className="step-button"
          disabled={isFirst}
          onClick={goPreviousStep}
        >
          GO PREVIOUS
        </button>
        {/* Same but with the last step */}
        <button className="step-button" disabled={isLast} onClick={goNextStep}>
          GO NEXT
        </button>
      </div>
    </div>
  );
};

If you go to your browser, you will see an ugly HTML layout, so we are going to add some styles to improve that:

/* App.css */
.step {
  height: 100%;
  width: 100%;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  background: #fff;
}

.step-body {
  flex: 1;
  justify-content: center;
  align-items: center;
  display: flex;
}

.step-actions {
  display: inline-flex;
  justify-content: space-between;
  margin: 0 2rem 1rem;
}

.step-button {
  padding: 0.5rem 1rem;
  border: none;
}

/* Stepper.css */ 

.stepper {
  width: 600px;
  height: 600px;
  position: relative;
  overflow: hidden;
  display: inline-block;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 3px 1px -2px,
    rgba(0, 0, 0, 0.14) 0px 2px 2px 0px, rgba(0, 0, 0, 0.12) 0px 1px 5px 0px;
}

.step-wrapper {
  width: 600px;
  height: 100%;
}

.stepper-selector {
  position: absolute;
  height: 100%;
  display: inline-flex;
	top:0;
}

And now, let’s add our functionality to switch between steps, we are going to use a ref for this.

export const Stepper: React.FC<StepperProps> = ({ steps }) => {
  const [currentStep, setCurrentStep] = useState<number>(1);
  const stepperSelector = useRef<HTMLDivElement>(null);
  // Every time our currentStep is updated, we are going to trigger this
  useEffect(() => {
    moveStepper();
  }, [currentStep]);

  const goNextStep = () => {
    const nextStep = currentStep + 1;
    if (nextStep <= steps.length) {
      setCurrentStep(nextStep);
    }
  };

  const goPreviousStep = () => {
    const previousStep = currentStep - 1;
    if (previousStep >= 1) {
      setCurrentStep(previousStep);
    }
  };

  const moveStepper = () => {
    if (stepperSelector.current) {
      const stepper = stepperSelector.current;
      const stepWidth = stepper.offsetWidth / steps.length;
      stepper.style.transform = `translateX(-${
        stepWidth * (currentStep - 1)
      }px)`;
    }
  };

  return (
    <div className="stepper stepper-wrapper">
      {/* This will display our current step */}
      <div className="stepper-selector" ref={stepperSelector}>
        {steps.map((step, i) => (
          <div className="step-wrapper">
            <step.element
              step={i + 1}
              goNextStep={goNextStep}
              goPreviousStep={goPreviousStep}
              // From our state
              currentStep={currentStep}
              // Check if this step is the first one
              isFirst={i === 0}
              // Check if its the last one
              isLast={i === steps.length - 1}
            />
          </div>
        ))}
      </div>
    </div>
  );
};

Here we are getting a ref of the DOM element which contains the steps, we are going to move it every time we update the stepper.

More about refs here.

Adding a progress bar to the stepper

Time to add a progress bar, so we know where we are in the stepper.

Let’s create a new component in a file called StepperProgress.tsx, it should look like this:

import React from "react";
import "./Stepper.css";

interface StepperProgressProps {
  stepTitles: string[];
  currentStep: number;
}
export const StepperProgress: React.FC<StepperProgressProps> = ({
  stepTitles,
  currentStep,
}) => {
	// Calculate the progress for each step we fill
  const progressPerStep = 100 / (stepTitles.length - 1);
	// Calculate the progress based on the step we are in
  const progress = (currentStep - 1) * progressPerStep;
  return (
    <div className="stepper-progress">
      <div className="stepper-progress-wrapper">
        <div
          className="stepper-progress-bar"
          style={{ width: progress + "%" }}
        />
        {stepTitles.map((title, i) => (
          <div className="step-title">
            <div className="step-title-number">{i + 1}</div>
            {title}
          </div>
        ))}
      </div>
    </div>
  );
};

This component will display a progress bar, and will update the progress bar width every time we update the currentStep.

In our Stepper.tsx file let’s call the component:

// Rest of the Stepper.tsx file

return <div className="stepper stepper-wrapper">
      <StepperProgress
        stepTitles={steps.map(step => step.title)}
        currentStep={currentStep}
      />
      {/* This will display our current step */}
      <div className="stepper-selector" ref={stepperSelector}>
        {steps.map((step, i) => (
          <div className="step-wrapper">
            <step.element
              step={i + 1}
              goNextStep={goNextStep}
              goPreviousStep={goPreviousStep}
              // From our state
              currentStep={currentStep}
              // Check if this step is the first one
              isFirst={i === 0}
              // Check if its the last one
              isLast={i === steps.length - 1}
            />
          </div>
        ))}
      </div>
    </div>

And now let’s add some CSS for this:

// Stepper.css

// Rest of the CSS file
.stepper-progress {
  position: absolute;
  top: 15px;
  width: 100%;
  z-index: 9;
}

.stepper-progress-wrapper {
  width: 90%;
  position: relative;
  display: flex;
  margin: auto;
  justify-content: space-between;
}

.step-title {
  text-align: center;
  font-size: 0.7rem;
  align-items: center;
  background: #fff;
  padding: 0 1rem;
  height: 30px;
}

.step-title-number {
  font-size: 1rem;
  background: #ceeeff;
  height: 24px;
  width: 24px;
  margin: auto;
  line-height: 1.5;
  border: 3px solid #fff;
  border-radius: 100%;
}

.stepper-progress-bar {
  position: absolute;
  width: 100%;
  height: 3px;
  top: 13px;
  z-index: -1;
  background: #e91e63;
  transition: width 1s cubic-bezier(0.23, 1, 0.32, 1) 0s;
}

The result:

Hope you find this useful!

Pixo