Uzochukwu Eddie Odozi Web and mobile app developer. TypeScript and JavaScript enthusiast. Lover of Pro Evolution Soccer (PES).

How to build an SVG circular progress component using React and React Hooks

8 min read 2507

ow to build an SVG circular progress component using React and React hooks

Progress bars are used to indicate activities like file uploads and downloads, page loading, user counts, and more on desktop and mobile devices. This visual representation can go a long way toward enhancing the user experience of your app.

In this tutorial, we’ll demonstrate how to create a simple, customizable, easy-to-use circular progress bar component from Scalable Vector Graphics (SVGs) using React. We’ll do so using no external dependencies.

Here’s what the circular progress component will look like:

Circular Progress Component

You can reference the full source code for this tutorial in the GitHub repo.

Let’s dive in!

Getting started

Before we start, we must first create a React application. We’ll use create-react-app with npx to create our app. I’ll assume you have Node.js installed on your computer.

Open a terminal or command prompt, navigate to the directory where you want to add your project, and type the following command.

npx create-react-app react-progress-bar

You can open the project with any IDE of your choice.

We made a custom demo for .
No really. Click here to check it out.

create-react-app creates a src directory. This is the directory that contains the entry component (App.js) of our application and where other components will be created. Delete the contents of the index.css file and add:

body {
  margin: 0;
}

In the App.css file, delete all CSS styles except for the classes App and App-header. You can change both class names to be lowercase. Inside the App.js component file, delete the contents of the header element and change it to a div.

<div className="app">
    <div className="app-header">
    </div>
</div>

create-react-app creates the component inside App.js as a functional component. You can use the default definition of the function or change it to an arrow function.

Progress component setup

To create a progress component, create a folder called progress and add two files ProgressBar.js and ProgressBar.css. Inside the ProgressBar.js file, create an arrow function ProgressBar and export function as default. Set the parent element to Fragment (import from React) or empty tags.

Basic SVG

Scalable Vector Graphics (SCGs) are used to define vector-based graphics for the web, according to W3 Schools.

The first element to add to the progress bar component is the <svg> element tag, which defines a container for a coordinate system and viewport.

import React from 'react';
import './ProgressBar.css';
const ProgressBar = () => {
    return (
        <>
            <svg>

            </svg>
        </>
    );
}
export default ProgressBar;

The svg element can accept numerous attributes; we’ll add width and height. The width and height of the SVG container will be dynamic, so we’ll add both as props.

return (
    <>
        <svg className="svg" width={} height={}>

        </svg>
    </>
);

Inside the added <svg> element, place a <circle> tag to create a circle. In the <circle> element, declare the radius r of the circle and the x-coordinate (cx) and y-coordinate (cy) of its center.

Additionally, we’ll define the stroke (color) and stroke width of the circle. I’ll define two separate <circle> elements:

<svg className="svg" width={} height={}>
    <circle
        className="svg-circle-bg"
        stroke={}
        cx={}
        cy={}
        r={}
        strokeWidth={}
    />
    <circle
        className="svg-circle"
        stroke={}
        cx={}
        cy={}
        r={}
        strokeWidth={}
    />
</svg>

The first circle element displays the inner circle while the second is placed on top of the first element to display the progress color based on the percentage calculated.

Next, add a <text></text> element, which draws a graphics element consisting of text. We’ll also add the attributes x and y, which represent the x and y starting points of the text.

<svg className="svg" width={} height={}>
    ...
    ...
    <text className="svg-circle-text" x={}  y={}>
        ...
    </text>
</svg>

Add the below CSS styles into the ProgressBar.css file and import it into the component.

.svg {
    display: block;
    margin: 20px auto;
    max-width: 100%;
}

.svg-circle-bg {
    fill: none;
}

.svg-circle {
    fill: none;
}
.svg-circle-text {
   font-size: 2rem;
    text-anchor: middle;
    fill: #fff;
    font-weight: bold;
}

As you can see, we don’t have much in the way of CSS styles. The progress bar elements will contain properties that will add some styles to the elements. Let’s take a closer look.

Progress component props

The progress bar component takes in five props:

  1. size — the full width and height of the SVG
  2. progress — the circular progress value
  3. strokeWidth — the width (thickness) of the circles
  4. circleOneStroke — the stroke color of first circle
  5. circleTwoStroke — the stroke color of the second circle

These properties will be passed in as props into the circular progress component when it is used. Other properties, such as radius and circumference, are calculated from the provided props.

Pass a props property into the arrow function and destructure the five properties.

const ProgressBar = (props) => {
    const { 
        size, 
        progress, 
        strokeWidth, 
        circleOneStroke, 
        circleTwoStroke,
    } = props;
    ...
}

Next, calculate the radius and circumference of the circles. Add a new variable called center and set its value to half of the size passed in as props. This value will be used in the cx and cy coordinates of the center of circle.

const center = size / 2;

The radius of the path is defined to be in the middle, so for the path to fit perfectly inside the viewBox, we need to subtract half the strokeWidth from half the size (diameter). The circumference of the circle is 2 * π * r.

const radius = size / 2 - strokeWidth / 2;
const circumference = 2 * Math.PI * radius;

Add the props and radius to the SVG and circles.

<svg className="svg" width={size} height={size}>
    <circle
        className="svg-circle-bg"
        stroke={circleOneStroke}
        cx={center}
        cy={center}
        r={radius}
        strokeWidth={strokeWidth}
    />
    <circle
        className="svg-circle"
        stroke={circleTwoStroke}
        cx={center}
        cy={center}
        r={radius}
        strokeWidth={strokeWidth}
    />
    <text className="svg-circle-text" x={center}  y={center}>
        {progress}%
    </text>
</svg>

Go to the App.js file and import the ProgressBar component. Add the component inside the div element with class name app-header.

const App = () => {
    return (
        <div className="app">
            <div className="app-header">
                <ProgressBar />
            </div>
        </div>
    );
}

Back to the ProgressBar.js file. The ProgressBar component needs to have the props defined inside its component.

<ProgressBar 
    progress={50}
    size={500}
    strokeWidth={15}
    circleOneStroke='#7ea9e1'
    circleTwoStroke='#7ea9e1'
/>

The user can specify values for the properties. Later, the progress value will be updated from a button click and an input. The circleTwoStroke value will be randomly selected from an array of colors.

Circular Progress Component Strokes

When using SVG, there are ways to control how strokes are rendered. Let’s take a look at stroke-dasharray and stroke-dashoffset.

stroke-dasharray enables you to control the length of the dash and the spacing between each dash. Basically, it defines the pattern of dashes and gaps that is used to paint the outline of the shape — in this case, the circles.

Rather than creating multiple dashes, we can create one big dash to go around the entire circle. We’ll do this using the circumference we calculated earlier. The stroke-dashoffset will determine the position from where the rendering starts.

The second circle will display the progress value between 0 and 100. Add the property below to the second circle

strokeDasharray={circumference}

Notice that we are using strokeDasharray and not stroke-dasharray. In react, css properties that are separated by - are usually written in camelCase when used inside the component.

...
<circle
    className="svg-circle"
    stroke={circleTwoStroke}
    cx={center}
    cy={center}
    r={radius}
    strokeWidth={strokeWidth}
    strokeDasharray={circumference}
/>
...

We’ll use three different React hooks: useState, useEffect, and useRef. useState updates the stroke-dashoffset based on the progress value passed as a prop and inside the useEffect hook. The useRef hook will get a reference to the second circle and then add a CSS transition property to the circle.

Import the useState, useEffect, and useRef hooks from React.

import React, { useEffect, useState, useRef } from 'react';

Create a new useState property inside the arrow function and set its default value to zero.

const [offset, setOffset] = useState(0);

On the second circle, add a ref property and then create a new variable after the useState property.

...
<circle
    ...
    ref={circleRef}
    ...
/>
...
const circleRef = useRef(null);

The circleRef property will produce a reference to the second circle, and then we can update its style on the DOM.

Next, add a useEffect method.

useEffect(() => {

}, []);

Inside the useEffect hook, calculate the position of the progress by using this formula:

((100 - progress) / 100) * circumference;

Recall that the circumference is already calculated and the progress is a prop value set by the user.

useEffect(() => {
    const progressOffset = ((100 - progress) / 100) * circumference;
    setOffset(progressOffset);
}, [setOffset, circumference, progress, offset]);

The properties inside the array are dependencies and so must be added to the useEffect array. After calculating the progressOffset, the setOffset method is used to update the offset.

Add to the second circle:

...
<circle
    ...
    strokeDashoffset={offset}
    ...
/>
...

It should look like the screenshots below.

70 percent progress:

Circular Progress Component Showing 70 Percent Progress

30 percent progress:

Circular Progress Component Showing 30 Percent Progress

To add some transition to the stroke-dashoffset, we’ll use useRef, which has been defined. The useRef hook gives us access to the current property of the element on the DOM, which enables us to access the style property. We’ll place this transition inside the useEffect hook so that it will be rendered as soon as the progress value changes.

Below the setOffset method and inside the useEffect hook, add:

circleRef.current.style = 'transition: stroke-dashoffset 850ms ease-in-out;';

circleRef is the variable defined for useRef, and we have access to its current and style properties. To see the change, reload your browser and observe how the transition occurs.

We now have our progress bar component. Let’s add some prop-types to the component.

import PropTypes from 'prop-types';

This places the prop-types definition right before the export default.

ProgressBar.propTypes = {
    size: PropTypes.number.isRequired,
    progress: PropTypes.number.isRequired,
    strokeWidth: PropTypes.number.isRequired,
    circleOneStroke: PropTypes.string.isRequired,
    circleTwoStroke: PropTypes.string.isRequired
}

These properties are defined as required properties. You can add more properties to the component if you wish.

Your ProgressBar functional component should look like this:

import React, { useEffect, useState, useRef } from 'react';

import PropTypes from 'prop-types';
import './ProgressBar.css';

const ProgressBar = props => {
    const [offset, setOffset] = useState(0);
    const circleRef = useRef(null);
    const { 
        size, 
        progress, 
        strokeWidth, 
        circleOneStroke, 
        circleTwoStroke,
    } = props;

    const center = size / 2;
    const radius = size / 2 - strokeWidth / 2;
    const circumference = 2 * Math.PI * radius;

    useEffect(() => {
        const progressOffset = ((100 - progress) / 100) * circumference;
        setOffset(progressOffset);
        circleRef.current.style = 'transition: stroke-dashoffset 850ms ease-in-out;';
    }, [setOffset, circumference, progress, offset]);

    return (
        <>
            <svg
                className="svg"
                width={size}
                height={size}
            >
                <circle
                    className="svg-circle-bg"
                    stroke={circleOneStroke}
                    cx={center}
                    cy={center}
                    r={radius}
                    strokeWidth={strokeWidth}
                />
                <circle
                    className="svg-circle"
                    ref={circleRef}
                    stroke={circleTwoStroke}
                    cx={center}
                    cy={center}
                    r={radius}
                    strokeWidth={strokeWidth}
                    strokeDasharray={circumference}
                    strokeDashoffset={offset}
                />
                <text 
                    x={`${center}`} 
                    y={`${center}`} 
                    className="svg-circle-text">
                        {progress}%
                </text>
            </svg>
        </>
    )
}

ProgressBar.propTypes = {
    size: PropTypes.number.isRequired,
    progress: PropTypes.number.isRequired,
    strokeWidth: PropTypes.number.isRequired,
    circleOneStroke: PropTypes.string.isRequired,
    circleTwoStroke: PropTypes.string.isRequired
}

export default ProgressBar;

Generate random progress values

To see the transition applied to the progress, we’ll create an input field to enable the user to change progress values and a button to add random progress values.

Start by adding the below CSS styles to the App.css file.

button {
  background: #428BCA;
  color: #fff;
  font-size: 20px;
  height: 60px;
  width: 150px;
  line-height: 60px;
  margin: 25px 25px;
  text-align: center;
  outline: none;
}

input { 
  border: 1px solid #666; 
  background: #333; 
  color: #fff !important; 
  height: 30px;
  width: 200px;
  outline: none !important; 
  text-align: center;
  font-size: 16px;
  font-weight: bold;
}

input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

input[type=number] {
  -moz-appearance: textfield;
}

h1 { 
  margin: 0;
  text-transform: uppercase;
  text-shadow: 0 0 0.5em #fff;
  font-size: 46px;
  margin-bottom: 20px;
}

The styles are basic for button, input, and h1 elements. Next, add some elements to the div with class name app-header.

<h1>SVG Circle Progress</h1>
<ProgressBar 
    progress={50}
    size={500}
    strokeWidth={15}
    circleOneStroke='#7ea9e1'
    circleTwoStroke='#7ea9e1'
/>
<p>
    <input 
        type="number"
        name="percent" 
        placeholder="Add Progress Value"
        onChange={}
    />
</p>
<button>
    Random
</button>

This adds s header tag, p tag with input, and a button. Let’s add the onChange method to the input.

...
...
<p>
    <input 
        type="number"
        name="percent" 
        placeholder="Add Progress Value"
        onChange={onChange}
    />
</p>
...
const onChange = e => {

}

Inside the onChange method, the progress value and a random color will be selected and their properties updated. Import useState and create a useState property called progress.

const [progress, setProgress] = useState(0);

Create a useState color property.

const [color, setColor] = useState('');

Add an array of colors with hex codes. You can set whatever colors you wish.

const colorArray = ['#7ea9e1', "#ed004f", "#00fcf0", "#d2fc00", "#7bff00", "#fa6900"];

A random color will be selected from the array and displayed on the circular progress component.

Update the ProgressBar component with the progress and color props.

<ProgressBar 
    progress={progress}
    size={500}
    strokeWidth={15}
    circleOneStroke='#7ea9e1'
    circleTwoStroke={color}
/>

Add a method that gets a random color from the colorArray.

const randomColor = () => {
    return colorArray[Math.floor(Math.random() * colorArray.length)];
}

Set the maximum value for the progress component to 100 and the minimum value to 0. If the input value is less than zero, the progress is set to zero. If it’s greater than 100, the progress is set to 100.

if (e.target.value) {
    if (e.target.value > 100) {
        progress = 100;
    }
    if (e.target.value < 0) {
        progress = 0;
    }
    setProgress(progress);
}

The setProgress method will update the progress value. Add the randomColor method below the setProgress and update the color variable using setColor.

...
const randomProgressColor = randomColor();
setColor(randomProgressColor);

If you try this out, you’ll discover it works but if the input field is empty, it still retains some old value. This is not the behavior we want. To fix this, I’ll add an else statement inside the onChange and set the progress value to zero.

if (e.target.value) {
    ...
} else {
    setProgress(0);
}

This will set the progress value to zero whenever the input field is cleared or empty.

Circular Progress Component With Random Values

Random button functionality

Add an onClick method on the button and create a function to randomly set the progress value.

<button onClick={randomProgressValue}>
    Random
</button>

Create a method called randomProgressValue.

const randomProgressValue = () => {
}

First, use Math.random() to get a random value between 0 and 100 and set its value with the setProgress method. The randomColor method is called and the color value is updated.

const randomProgressValue = () => {
    const progressValue = Math.floor(Math.random() * 101);
    setProgress(progressValue);
    const randomProgressColor = randomColor();
    setColor(randomProgressColor);
}

Whenever the button is clicked, a random progress value is set and a random color is added using the setColor method.

Note that using a random colors array is optional. You can set any two colors you want for the circleOneStroke and circleTwoStroke props.

Circular Progress Component With Random Button Functionality

Conclusion

You should now have a good understanding of how to create a custom circular progress bar using React hooks such as useState, useEffect, and useRef.

See the full source code for this tutorial in the GitHub repo.

If you prefer to watch me as I code, you can check out my video on YouTube.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Uzochukwu Eddie Odozi Web and mobile app developer. TypeScript and JavaScript enthusiast. Lover of Pro Evolution Soccer (PES).

Leave a Reply