react progress steps component

Build a Progress Steps Component in ReactJS from Scratch Using CSS

Demo App

This is what we are going to build in this tutorial. You can play by clicking Next button.

1
Address
2
Shipping
3
Payment
4
Summary


Almost every e-commerce app or website includes a progress steps component to indicate the flow or current step in a process. Many developers want to build their own custom version without using styled-components or UI libraries like Material UI or Chakra.

In this article, we’ll build a reusable, fully functional progress steps component using ReactJS and vanilla CSS, step by step.

If you're looking for the Tailwind CSS version, copy the code from this Tailwind CSS stepper component.

Let’s Get Started

It needs to be coded in a way that it becomes reusable in any web app. The approach I use while building web apps is to design a UI first and then implement a logic.

Design the Basic Step UI

Step 01: Designing four steps

The step is just a circle. We can add a number or checkmark based on the state of the app, step will have a label too.

import './progress-steps.css'
const ProgressSteps = () => {
return (
<div className="progress-steps__item">
<div className="progress-steps__circle">
<span className="progress-steps__count">1</span>
</div>
<div className="progress-steps__label-container">
<span className="progress-steps__label">Address</span>
</div>
</div>
)
}
export default ProgressSteps

CSS Style

.progress-steps__item {
position: relative;
z-index: 1;
}
.progress-steps__circle {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #ffffff;
border: 3px solid #f3e7f3;
transition: 0.4s ease;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
}
.progress-steps__count {
font-size: 19px;
color: #f3e7f3;
}
.progress-steps__label-container {
position: absolute;
top: 66px;
left: 50%;
transform: translate(-50%, -50%);
}
.progress-steps__label {
font-size: 19px;
color: #4a154b;
}
Single Step

First Trick

You might have noticed we have made the position of step label absolute. It is to make it independent of the step circle (you will find the reason in step 2)

As we have four steps in our web app, so will map over them to build remaining steps.

import './progress-steps.css'
const ProgressSteps = () => {
return (
<div className="progress-steps">
<div className="progress-steps__container">
{[1, 2, 3, 4].map(() => (
<div className="progress-steps__item">
<div className="progress-steps__circle">
<span className="progress-steps__count">1</span>
</div>
<div className="progress-steps__label-container">
<span className="progress-steps__label">Address</span>
</div>
</div>
))}
</div>
</div>
)
}
export default ProgressSteps

CSS Style

.progress-steps {
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 0 16px;
}
.progress-steps__container {
display: flex;
justify-content: space-between;
margin-top: 70px;
position: relative;
}
.progress-steps__item {
position: relative;
z-index: 1;
}
.progress-steps__circle {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #ffffff;
border: 3px solid #f3e7f3;
transition: 0.4s ease;
display: flex;
justify-content: center;
align-items: center;
}
.progress-steps__count {
font-size: 19px;
color: #f3e7f3;
}
.progress-steps__label-container {
position: absolute;
top: 66px;
left: 50%;
transform: translate(-50%, -50%);
}
.progress-steps__label {
font-size: 19px;
color: #4a154b;
}

Meanwhile, we have also created two more div for Step Container for making it display: flex and Main Container to contain the whole component.

After doing it, we will have results something like this in our browser.

Multiple static steps

Making it dynamic

Is it the right approach? NO…

It is not the proper approach to build steps because what if a web app requires five or three steps? So, to make it completely dynamic and scalable, we will add a JavaScript array of objects.

import './progress-steps.css'
const steps = [
{
label: 'Address',
step: 1,
},
{
label: 'Shipping',
step: 2,
},
{
label: 'Payment',
step: 3,
},
{
label: 'Summary',
step: 4,
},
]
const ProgressSteps = () => {
return (
<div className="progress-steps">
<div className="progress-steps__container">
{steps.map(({ step, label }) => (
<div className="progress-steps__item" key={step}>
<div className="progress-steps__circle">
<span className="progress-steps__count">{step}</span>
</div>
<div className="progress-steps__label-container">
<span className="progress-steps__label">{label}</span>
</div>
</div>
))}
</div>
</div>
)
}
export default ProgressSteps
Multiple dynamic steps

Step 02: Building a Progress Line

Next, we need a line over the steps to make it look connected.

It is pretty simple; we will use CSS pseudo-class property for that. So, we don’t add another element to our DOM.

.progress-steps__container {
display: flex;
justify-content: space-between;
margin-top: 70px;
position: relative;
}
.progress-steps__container:before {
content: '';
position: absolute;
background: #f3e7f3;
height: 4px;
width: 100%;
top: 50%;
transform: translateY(-50%);
left: 0;
}

This step has nothing fancy. Here we have only used simple CSS to get this result.

We have positioned the line in the middle of our container using the top, transform, and left properties and make it 100% of the container.

Progress line

The last tricky step is here!

Right now, our progress line is dull and empty. We want to fill the portion of the line with a darker color whenever a step is done.

So, the trick here for having a color-filled progress line is adding another line over the previous one.

For this we have created another element in DOM with class progress-steps__filled-line and made it position absolute.

import './progress-steps.css'
const steps = [
{
label: 'Address',
step: 1,
},
{
label: 'Shipping',
step: 2,
},
{
label: 'Payment',
step: 3,
},
{
label: 'Summary',
step: 4,
},
]
const ProgressSteps = () => {
return (
<div className="progress-steps">
<div className="progress-steps__container">
{steps.map(({ step, label }) => (
<div className="progress-steps__item" key={step}>
<div className="progress-steps__circle">
<span className="progress-steps__count">{step}</span>
</div>
<div className="progress-steps__label-container">
<span className="progress-steps__label">{label}</span>
</div>
</div>
))}
<div className="progress-steps__filled-line" style={{ width: '33.3%' }}></div>
</div>
</div>
)
}
export default ProgressSteps

Filled Line Style

.progress-steps__filled-line {
content: '';
position: absolute;
background: #4a154b;
height: 4px;
width: 100%;
top: 50%;
transition: 0.4s ease;
transform: translateY(-50%);
left: 0;
}

Is it hard to understand? No, we are only playing with CSS properties.

The width is set to static 33.3% width to see the result, but it will become dynamic after logic implementation.

Progress line filled

Step 03: Navigation Buttons

Now we are left with Next and Previous buttons. This part is quite straightforward

import './progress-steps.css'
const steps = [
{
label: 'Address',
step: 1,
},
{
label: 'Shipping',
step: 2,
},
{
label: 'Payment',
step: 3,
},
{
label: 'Summary',
step: 4,
},
]
const ProgressSteps = () => {
return (
<div className="progress-steps">
<div className="progress-steps__container">
{steps.map(({ step, label }) => (
<div className="progress-steps__item" key={step}>
<div className="progress-steps__circle">
<span className="progress-steps__count">{step}</span>
</div>
<div className="progress-steps__label-container">
<span className="progress-steps__label">{label}</span>
</div>
</div>
))}
<div className="progress-steps__filled-line" style={{ width: '33.3%' }}></div>
</div>
<div className="progress-steps__buttons">
<button className="progress-steps__button">Previous</button>
<button className="progress-steps__button">Next</button>
</div>
</div>
)
}
export default ProgressSteps
.progress-steps__buttons {
display: flex;
justify-content: space-between;
margin: 0 -15px;
margin-top: 100px;
}
.progress-steps__button {
border-radius: 4px;
border: 0;
background: #4a154b;
color: #ffffff;
cursor: pointer;
padding: 8px;
width: 90px;
}
.progress-steps__button:active {
transform: scale(0.98);
}
.progress-steps__button:disabled {
background: #f3e7f3;
color: #000000;
cursor: not-allowed;
}

Our button has active and disabled classes just like buttons have.

The first milestone is achieved, and we are done with our structural and CSS parts. It’s time to write some logic.

Steps with buttons

Second, let’s Make it Functional

To make the progress components fully functional, we have to implement two logics, the first to keep track of our actions and the second to calculate the width of the progress bar.

Let us start with the logic of keeping track of our actions.

We need only one state to keep track of our action steps. It requires two functions, one for the next and another for moving to the previous step.

import { useState } from 'react'
import './progress-steps.css'
const steps = [
{
label: 'Address',
step: 1,
},
{
label: 'Shipping',
step: 2,
},
{
label: 'Payment',
step: 3,
},
{
label: 'Summary',
step: 4,
},
]
const ProgressSteps = () => {
const [activeStep, setActiveStep] = useState(1)
const nextStep = () => {
setActiveStep(activeStep + 1)
}
const prevStep = () => {
setActiveStep(activeStep - 1)
}
const totalSteps = steps.length
return (
<div className="progress-steps">
<div className="progress-steps__container">
{steps.map(({ step, label }) => (
<div className="progress-steps__item" key={step}>
<div className="progress-steps__circle">
<span className="progress-steps__count">{step}</span>
</div>
<div className="progress-steps__label-container">
<span className="progress-steps__label">{label}</span>
</div>
</div>
))}
<div className="progress-steps__filled-line" style={{ width: '33.3%' }}></div>
</div>
<div className="progress-steps__buttons">
<button className="progress-steps__button" onClick={prevStep} disabled={activeStep === 1}>
Previous
</button>
<button
className="progress-steps__button"
onClick={nextStep}
disabled={activeStep === totalSteps}>
Next
</button>
</div>
</div>
)
}
export default ProgressSteps

We have added disabling button functionality with the logic: If activeState is 1 previous button will be disabled, and on the last step, the Next button will be disabled.

We can change this logic while implementing it in a real-world app.

Now the last trick of this article is calculating the width

Our last trick or logic of this article is to calculate the width of the progress line.

Here is the formula:

const width = `${(100 / (totalSteps - 1)) * (activeStep - 1)}%`

If we increase or decrease steps, our progress line will not overflow or shorten than steps container because of this formula.

I learned this width calculation formula from Brad Traversy’s 50 Projects In 50 Days - HTML, CSS & JavaScript.  Here you can find this course.

Dynamic width with inline styling

import React, { useState } from 'react'
import './progress-steps.css' // Import your CSS file
const steps = [
{
label: 'Address',
step: 1,
},
{
label: 'Shipping',
step: 2,
},
{
label: 'Payment',
step: 3,
},
{
label: 'Summary',
step: 4,
},
]
const ProgressSteps = () => {
const [activeStep, setActiveStep] = useState(1)
const nextStep = () => {
setActiveStep(activeStep + 1)
}
const prevStep = () => {
setActiveStep(activeStep - 1)
}
const totalSteps = steps.length
const width = `${(100 / (totalSteps - 1)) * (activeStep - 1)}%`
return (
<div className="progress-steps">
<div className="progress-steps__container">
{steps.map(({ step, label }) => (
<div className="progress-steps__item" key={step}>
<div
className={`progress-steps__circle ${
activeStep >= step ? 'progress-steps__circle--completed' : ''
}`}>
{activeStep > step ? (
<div className="progress-steps__checkmark">L</div>
) : (
<span className="progress-steps__count">{step}</span>
)}
</div>
<div className="progress-steps__label-container">
<span className="progress-steps__label" key={step}>
{label}
</span>
</div>
</div>
))}
<div className="progress-steps__filled-line" style={{ width: width }}></div>
</div>
{/* navigation buttons */}
</div>
)
}
export default ProgressSteps

We did three things in above code:

  1. We are passing width props as inline style, so it acts accordingly.
  2. Darken the step border if the step is active or done. So, in Step Style, we will set the logic as if acitveStep is equal to or greater than the step, it will pass completed styling, otherwise incomplete (just regular styling).
  3. Insert a checkmark style, if the step is done, it will show the check mark inside the step; otherwise simple digit.

Finally, we are done with building reusable progress step component.

React Progress Step Component Complete Code

import React, { useState } from 'react'
import './progress-steps.css' // Import your CSS file
const steps = [
{
label: 'Address',
step: 1,
},
{
label: 'Shipping',
step: 2,
},
{
label: 'Payment',
step: 3,
},
{
label: 'Summary',
step: 4,
},
]
const ProgressSteps = () => {
const [activeStep, setActiveStep] = useState(1)
const nextStep = () => {
setActiveStep(activeStep + 1)
}
const prevStep = () => {
setActiveStep(activeStep - 1)
}
const totalSteps = steps.length
const width = `${(100 / (totalSteps - 1)) * (activeStep - 1)}%`
return (
<div className="progress-steps">
<div className="progress-steps__container">
{steps.map(({ step, label }) => (
<div className="progress-steps__item" key={step}>
<div
className={`progress-steps__circle ${
activeStep >= step ? 'progress-steps__circle--completed' : ''
}`}>
{activeStep > step ? (
<div className="progress-steps__checkmark">L</div>
) : (
<span className="progress-steps__count">{step}</span>
)}
</div>
<div className="progress-steps__label-container">
<span className="progress-steps__label" key={step}>
{label}
</span>
</div>
</div>
))}
<div className="progress-steps__filled-line" style={{ width: width }}></div>
</div>
<div className="progress-steps__buttons">
<button className="progress-steps__button" onClick={prevStep} disabled={activeStep === 1}>
Previous
</button>
<button
className="progress-steps__button"
onClick={nextStep}
disabled={activeStep === totalSteps}>
Next
</button>
</div>
</div>
)
}
export default ProgressSteps

Progress Step Component Styling

.progress-steps {
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 0 16px;
}
.progress-steps__container {
display: flex;
justify-content: space-between;
margin-top: 70px;
position: relative;
}
.progress-steps__container:before {
content: '';
position: absolute;
background: #f3e7f3;
height: 4px;
width: 100%;
top: 50%;
transform: translateY(-50%);
left: 0;
}
.progress-steps__filled-line {
content: '';
position: absolute;
background: #4a154b;
height: 4px;
width: 100%;
top: 50%;
transition: 0.4s ease;
transform: translateY(-50%);
left: 0;
}
.progress-steps__item {
position: relative;
z-index: 1;
}
.progress-steps__circle {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #ffffff;
border: 3px solid #f3e7f3;
transition: 0.4s ease;
display: flex;
justify-content: center;
align-items: center;
}
.progress-steps__circle--completed {
border-color: #4a154b;
}
.progress-steps__count {
font-size: 19px;
color: #f3e7f3;
}
@media (max-width: 600px) {
.progress-steps__count {
font-size: 16px;
}
}
.progress-steps__label-container {
position: absolute;
top: 66px;
left: 50%;
transform: translate(-50%, -50%);
}
.progress-steps__label {
font-size: 19px;
color: #4a154b;
}
@media (max-width: 600px) {
.progress-steps__label {
font-size: 16px;
}
}
.progress-steps__buttons {
display: flex;
justify-content: space-between;
margin: 0 -15px;
margin-top: 100px;
}
.progress-steps__button {
border-radius: 4px;
border: 0;
background: #4a154b;
color: #ffffff;
cursor: pointer;
padding: 8px;
width: 90px;
}
.progress-steps__button:active {
transform: scale(0.98);
}
.progress-steps__button:disabled {
background: #f3e7f3;
color: #000000;
cursor: not-allowed;
}
.progress-steps__checkmark {
font-size: 26px;
font-weight: 600;
color: #4a154b;
-ms-transform: scaleX(-1) rotate(-46deg); /* IE 9 */
-webkit-transform: scaleX(-1) rotate(-46deg); /* Chrome, Safari, Opera */
transform: scaleX(-1) rotate(-46deg);
}

Codepen Demo | Progress Steps Component

Read Next

Codevertiser Magazine

Subscribe for a regular dose of coding challenges and insightful articles, delivered straight to your inbox