Introduction
The Progress Circle is a powerful visualization tool frequently used for goal tracking, featured in apps related to learning, finance, and fitness. In this article, we'll explore how to build a responsive Progress Circle component using React with Typescript, along with React Spring for animation and SVG for rendering circle elements. By leveraging scalable vector graphics, we can create high-quality, responsive visuals. This approach allows for easy customization, so you won’t need to rely on external libraries with large bundle sizes.
Final Result Preview
Here is a showcase of what are we gonna be building (use case from measuring student’s progress on Atheros Learning platform)
Setting up a project
For convenience let’s utilize the Create React App tool to build a project for us. We will be building with Typescript to ensure type safety, run this command if you are using npm:
npx create-react-app progress-circles --template typescript
or with yarn:
yarn create react-app progress-circles --template typescript
then navigate to create directory:
cd progress-circles
We also need to install React Spring library:
npm i @react-spring/webbashyarn add react-spring
and run the app with command:
npm startbashyarn start
Breakdown of Progress Circle Elements
Progress circle consists of 4 main parts:
- inner circle - A smaller SVG circle element to function as a wrapper for information elements
- progress circle - SVG circle, where we will animate its stroke to visualize progress
- background circle - SVG circle with reduced opacity to serve as a background element behind the progress circle
- information - Icon, Title and animated progress text
Progress Circle Properties
One of the main properties in our progress circle will be size. The progress circle will have square dimensions, so let’s use size to encompass both width and height (vis. first figure below). To draw a circle using SVG, we need to calculate the radius. For the outer circle, the radius will be equal to size - strokeWidth(vis. second figure). It is also possible to utilize this attribute to determine the radius of an inner circle. One way is to define an additional value, innerRadiusOffset, which determines the distance between the outer circle and circle. The radius of the inner circle is equal to the outer radius - innerRadiusOffset(vis. third figure).
Progress Circle Interface
Add a folder called components to the src directory where we will place progress circle files. Create ProgressCircle.tsx for the React component and ProgressCircle.css where we will place stylesheets. Here is our Typescript interface, component structure, and computed constants, as we discussed earlier (other props will be discussed later in the article):
import React from 'react';import { useSpring, animated } from '@react-spring/web';import './ProgressCircle.css';export interface ProgressCircleI {animationIndex?: number;title: string;icon?: string;progressType: ProgressType;maxValue?: number;value: number;size: number;strokeWidth: number;innerRadiusOffset?: number;gradientStart?: string;gradientEnd?: string;innerCircleColor?: string;}const ProgressCircle: React.FunctionComponent<ProgressCircleI> = ({animationIndex = 0,title,icon,progressType,maxValue = 100,value,size,innerCircleColor = "#E5F1FF",innerRadiusOffset = 20,strokeWidth,gradientStart = "#AD63F6",gradientEnd = "#5DA8FF",}: ProgressCircleI) => {const centerXY = size / 2;const outerRadius = centerXY - strokeWidth;const innerRadius = outerRadius - innerRadiusOffset;return ();};export default ProgressCircle;
Percentage and Fraction Progress Type Enumerator
There are two ways to measure progress in our case. One method is by a fraction, and the second is by percentage progress. To distinguish these methods, create an enumerator type called ProgressType in ProgressCircle.tsx, and export it to be accessed from other files:
export enum ProgressType {PERCENTAGE,FRACTION,}
App Component
In the generated App.tsx component, we want to insert mock data to test the component we will be building. Following mockData creates different types of Progress Circle with different values and progress types, you can play around with them later after we finish the Progress Circle component.
import './App.css';import ProgressCircle, { ProgressCircleI, ProgressType } from './components/ProgressCircle';const mockData: readonly Omit<ProgressCircleI, 'animationIndex'>[] = [{progressType: ProgressType.FRACTION,title: 'Quizzes',icon: 'quizzes',value: 24,maxValue: 31,size: 250,strokeWidth: 10,},{progressType: ProgressType.FRACTION,title: 'Questions',icon: 'questions',value: 122,maxValue: 131,size: 250,strokeWidth: 10,},{progressType: ProgressType.PERCENTAGE,title: 'Success Rate',icon: 'success-rate',value: 94,size: 250,strokeWidth: 10,},];function App() {return (<div className='progress-circles-wrapper'>{mockData.map((data, index) => (<ProgressCirclekey={`progress-circle-${index}`}animationIndex={index}{...data}/>))}</div>);}export default App;
This Typescript setting will mark the mock data as readonly and defines its interface from ProgressCircle.tsx, we omit animationIndex which will be passed from the .map() function.
readonly Omit<ProgressCircleI, 'animationIndex'>[]
In the App.css file, .progress-circles-wrapper stylesheet renders Progress Circles from mockData in a responsive row grid with 32px gaps between.
.progress-circles-wrapper {display: flex;justify-content: center;text-align: center;flex-wrap: wrap;padding: 32px;gap: 32px;}
Wrapper
In ProgressCircle.tsx, insert a div element as a wrapper for all other elements. Set width and height using inline style to the size prop and classname .progress-circle with flex column layout, that places all child elements to center.
return (<divclassName="progress-circle"style={{ width: `${size}px`, height: `${size}px` }}>...</div>);
ProgressCircle.css
We will be including another wrapper for information elements on top of this layout so add property position: relative to .progress-circle class for the later defined absolute position to work properly.
.progress-circle {display: flex;flex-direction: column;justify-content: center;align-items: center;position: relative;}
SVG Tag
To render the SVG, we need to integrate SVG element into the layout and place it inside the previously created wrapper. Let's also use the size props for width, height, and viewBox property which defines the coordinate system within the SVG element.
<svgwidth={size}height={size}viewBox={`0 0 ${size} ${size}`}className="progress-circle__svg">...</svg>
SVG Circles start drawing from the 3 o’clock position, rotate the position of SVG by -90 degrees using the transform property.
ProgressCircle.css
.progress-circle__svg {transform: rotate(-90deg);}
Animation Constants
We need to create three more helper constants. First, the ratio represents how much of the maxValue is occupied by the value prop, providing a measure of progress from 0 to 1.
const ratio = value / maxValue;
The second is the circumference of a progress circle. The mathematical formula for the circumference of a circle is:
Circumference=2*π*radius=π*diameter
We can use the previously created constant outerRadius for circumference calculation.
const circumference = 2 * Math.PI * outerRadius;
strokeDashoffset is an SVG attribute used to control the starting point of the stroke of a shape, in our case a circle. If strokeDashoffset = 0 the whole stroke will be drawn. If strokeDashoffset = circumference, the stroke will not be drawn at all. We can use our previously initialized ratio to subtract it from the circumference value and calculate our desired offset.
const maxStrokeDashoffset = circumference - (circumference * ratio);
or maybe this formula is more intuitive for you:
const maxStrokeDashoffset = circumference * (1 - ratio);
Linear Gradient
We can now put our formulas to the test. First, insert a linear gradient element to add color to Progress Circles. Give id to the linearGradient element so it can be accessed later. These settings will create horizontal gradients with gradientStart color at the right and gradientEnd at the left according to the direction of the Progress Circle.
<linearGradientid="primary-gradient"x1="0%"y1="0%"x2="0%"y2="100%"><stop offset="0%" stopColor={gradientStart} /><stop offset="100%" stopColor={gradientEnd} /></linearGradient>
Background Circle
The following code will insert backgroundCircle, place it below the linearGradient element.
<circlestroke="url(#primary-gradient)"opacity="0.3"strokeWidth={strokeWidth}fill="none"cx={centerXY}cy={centerXY}r={outerRadius}/>
- stroke - add a gradient to the stroke using the id we defined earlier in the linear gradient element
- opacity - lower the opacity to create a transparent effect
- strokeWidth - assign stroke width using components prop
- fill - be sure to add fill as none to create a hollow inside
- cx, cy - the center of a circle, using initialized constant centerXY, previously initialized as size / 2
- r - radius props, add our previously calculated constant outerRadius
Animation Hook
Let’s import Spring hook from React Spring library. We can introduce the animation spring hook now to incorporate values that will drive our animation, strokeDashoffset will be animated from circumference value to maxStrokeDashoffset as we discussed earlier. animatedTextValue will be used for animating text inside the inner circle (from 0 to value prop). The config object describes how the values change over time.
We can apply animationIndex to drive the animation of each element differently. In this case, we apply friction to each following element in the array. This will result in animation being progressively delayed for the second and third elements in our example.
import { useSpring, animated } from '@react-spring/web';...const { strokeDashoffset, animatedTextValue } = useSpring({from: {strokeDashoffset: circumference,animatedTextValue: 0},to: {strokeDashoffset: maxStrokeDashoffset,animatedTextValue: value},config: {mass: 2,friction: 40 + (animationIndex * 10),tension: 140,},});...
Physics based vs Duration Based Animation
React Spring library provides a so-called physics-based approach to animation. It is a method of creating animations that simulate real-world physics principles such as tension, friction, mass, and velocity. It allows more natural movement other than classic approaches. Here is an example from react-spring docs, where you can test different values. However, you can still describe animation using standard configurations like duration and easing curves:
import { easings } from '@react-spring/web';...config: {duration: 1000 + (animationIndex * 100),easing: easings.easeLinear,},...
Animated Progress Circle
We have all necessary properties, let’s finally add our animated Progress Circle. Type animated.circle as element name to signalize React Spring that we will animate element property. It has almost same properties as background circle, except for:
- strokeDasharray: is equal to circumference of a circle, which will create one continuous stroke
- strokeDashoffset: specifies how much strokeDasharray will be offset, we assign our animated strokeDashoffset value which will create animated Progress Circle effect, animated from circumference to maxStrokeDashoffset value we calculated earlier.
- strokeLinecap: as “round” will add radius to start and end of a stroke, default value butt will ad square caps
<animated.circlestroke="url(#primary-gradient)"strokeWidth={strokeWidth}fill="none"cx={centerXY}cy={centerXY}r={outerRadius}strokeDasharray={circumference}strokeDashoffset={strokeDashoffset}strokeLinecap="round"/>
In this video, you can see how strokeDashoffset is calculated in real time, along with other constants:
Information Layout
Wrapper
The last part to implement is the information part in the middle of the inner circle with icon, title, and progress labels. Set width and height using inline style, from innerRadius constant and multiply by 2, so the wrapper has dimensions of the inner circle.
<divclassName="progress-circle__info-wrapper"style={{width: `${innerRadius * 2}px`,height: `${innerRadius * 2}px`,}}>...</div>
Style the wrapper as a column layout with centering elements horizontally and vertically. Set absolute position to place wrapper on top of previously created elements.
.progress-circle__info-wrapper {display: flex;flex-direction: column;justify-content: center;align-items: center;position: absolute;}
Icon
The first item in the information layout is the icon. Because the icon property is optional. Write a conditional statement that will control whether the icon will be displayed. Set width and height to 10% of the overall circle size. All image files are placed in a public folder at the root of the project directory.
...{icon && (<imgstyle={{ width: `${size * 0.1}px`, height: `${size * 0.1}px` }}src={`${icon}.svg`}alt={title}/>)}...
Title
Next insert the title element, which will have a 6% font size from the component dimension.
<spanclassName="progress-circle__title"style={{ fontSize: `${size * 0.06}px` }}>{title}</span>
Progress Label
Lastly, we need to design the text progress part. The paragraph element will serve as a text wrapper with a 9% size from the component dimension. The first span displays the animated text value, so we need to signalize it to React Spring with the animated.span element name. The animatedTextValue.to() function is used to animate the transition of the value. Math.floor() will round numbers with decimal places to the whole integer. The second span will render a percent sign or fraction with maxValue based on progressType of the current Progress Circle.
...<spanclassName="progress-circle__percentage"style={{ fontSize: `${size * 0.09}px` }}><animated.span>{animatedTextValue.to((val: number) => Math.floor(val))}</animated.span><span>{progressType == ProgressType.PERCENTAGE ? " %" : `/ ${maxValue}`}</span></span>...
In the CSS file, we import Manrope Google Font and create a variable for storing the color value of the title and percentage text.
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@600..700&display=swap');:root {--neutral-600: #4a516d;}....progress-circle__title {font-family: 'Manrope', sans-serif;color: var(--neutral-600);font-weight: 600;margin-top: 4px;margin-bottom: 16px;}.progress-circle__percentage {font-family: 'Manrope', sans-serif;color: var(--neutral-600);font-weight: 700;}
Conclusion
I hope you found this article helpful. Now that you know the principles of responsive components and animation principles, you're free to experiment with your own designs and customizations, making this component your own, or learn more about React Spring library.
Did you like this post? Feel free to send any questions about the topic to karel@atheros.ai.