Radial Gauge in React

Interactive Radial Gauge (in React)

So you'd like to build an interactive radial gauge in React, huh? Pick any chart library and you are done for the static part 😉. But if you'd like to dive a little bit deeper into this problem stay with me. The process of making it can show you an interesting perspective on how things like that are done and what to consider when making it. And of course, we will make it interactive! Chart libs would most likely make it read-only.

Below is what we are going to build. Imagine it's a component that lets you adjust light brightness in your room. Give it a try:

50%
Brightness

Did you use the setter ball to adjust the value?

First, there was a light track!

Actually, first we need to decide how we would like to render the gauge. Ideally, it should be responsive and configurable. We could think of some circular <div> with a border that would work as a track but that seems to be pretty limited. We could use a <canvas> element which gives us freedom in what we render but it's not straightforward to get into interaction with canvas. We also have to have in mind that the track is rounded and as you can see, it's not a perfect full circle. It's a part of it. It seems that the first important decision has to happen at the beginning. So...

Let's use SVG!

Here we have a simple SVG on a 300x300px view box with a circle and border around (stroke):

We place the center of the circle in the center of our box (150x150px), give it a radius (130px), and style the stroke.

import React from 'react';
export default function RadialGauge() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<circle
fill="none"
cx="150"
cy="150"
r="130"
stroke="white"
strokeWidth="20"
/>
</svg>
);
}

Make it responsive

There's a reason why "mobile first" was a popular saying a few years ago. Betting on responsiveness from the beginning sets our minds in the proper mode. In our case, we will think in terms of equations, not some magic numbers. We actually have one already! 130 pixels for the radius is the result of finding the center of the 300x300 box and making sure that stroke (20px) won't be cut.

Let's change our code and set the viewBox size to 100. Almost everything else will be in relation to that number.

import React from 'react';
const TRACK_WIDTH_PX = 7;
const VIEW_BOX_SIZE_PX = 100;
export default function RadialGauge() {
const viewBox = `0 0 ${VIEW_BOX_SIZE_PX} ${VIEW_BOX_SIZE_PX}`;
const radius = VIEW_BOX_SIZE_PX / 2 - TRACK_WIDTH_PX / 2;
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox={viewBox}>
<circle
fill="none"
cx="50%"
cy="50%"
r={radius}
stroke="white"
strokeWidth={TRACK_WIDTH_PX}
/>
</svg>
);
}

Maybe I should have highlighted what didn't change... but anyway. Everything now is recalculated based on the size of the container. Play with the resizer if you want:

Track is not a full circle

Here's the first problem that can't be solved with regular DOM elements I think. We should rather think about our track as <path> than a <circle>. I'm not an SVG expert but I do like tricks so I'm going to show you one of the tricks. Instead of drawing a path, we will use strokeDasharray and strokeDashoffset attributes of the circle.

Those attributes are responsible for stroke dash. Here's an example code:

<svg viewBox="0 0 30 4" xmlns="http://www.w3.org/2000/svg">
<!-- No dashes nor gaps -->
<line x1="0" y1="1" x2="30" y2="1" stroke="#2a2a2a" />
<!-- Dashes and gaps of the same size -->
<line x1="0" y1="3" x2="30" y2="3" stroke="#2a2a2a" stroke-dasharray="4" />
</svg>

As a result, we should expect a solid line and a line with gaps:

How is that going to help us? Well, we will draw just one dash with a gap:

To do it we need to know the circumference of the track - this is our dash. And we will put a gap for X percent of the circumference. At this point, we have to decide how much space the track should occupy. I'm not sure how that would be solved with <path> but with a circle, we can think in terms of angles/degrees. That's a good mental model to have I think. Here we use 270°.

Code changes:

import React from 'react';
const TRACK_SIZE_DEGREES = 270;
const TRACK_WIDTH_PX = 7;
const VIEW_BOX_SIZE_PX = 100;
export default function RadialGauge() {
const viewBox = `0 0 ${VIEW_BOX_SIZE_PX} ${VIEW_BOX_SIZE_PX}`;
const radius = VIEW_BOX_SIZE_PX / 2 - TRACK_WIDTH_PX / 2;
const circumference = 2 * Math.PI * radius;
const dasharray = circumference;
const trackFillPercentage = TRACK_SIZE_DEGREES / 360;
const trackDashoffset = circumference * (1 - trackFillPercentage);
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox={viewBox}>
<circle
fill="none"
cx="50%"
cy="50%"
r={radius}
stroke="white"
strokeDasharray={dasharray}
strokeDashoffset={trackDashoffset}
strokeWidth={TRACK_WIDTH_PX}
/>
</svg>
);
}

It doesn't look like it's properly positioned yet, right? And our reference doesn't have hard stops at the ends. Fortunately, SVG has a stroke-linecap: round for the ends problem and transform to rotate the gauge. We need to remember that things are relative and configurable - through constants now, but still configurable. Here's the result:

There's a bit of math involved unfortunately:

const cxy = VIEW_BOX_SIZE_PX * 0.5;
const trackTransform = `rotate(${
-(TRACK_SIZE_DEGREES / 2) - 90
}, ${cxy}, ${cxy})`;

So far we were getting away from calculating the center of the circle by using 50% for cx and cy but now we have to calculate that. When you rotate anything in space you have to specify a point around which the thing is rotated. Think about rotating the Earth. Rotating it around its center will result in Earth spinning (nights and days). Rotating it around the center of our solar system (sun) will cause a very different move (and result in seasons).

When doing the rotation we somehow have to place the center of the gap at the bottom center of the "canvas". We can do it with the equation presented above.

You can play with the track size [in degrees] below to see how well it positions itself and how the dash and gap play together:

Very satisfying indeed!

Another track with value

Now it's time to handle the value. We will draw another track on top of the existing one so it will look filled. You might not believe it but the only thing that we need to change is stroke-dashoffset and calculating it is not that difficult. Let's introduce a prop value with some default and calculate the missing variable:

// [...]
const INITIAL_VALUE_PERCENTAGE = 0;
const TRACK_SIZE_DEGREES = 270;
// [...]
export default function RadialGauge({ value }) {
// [...]
const valuePercentage = (value / 100) * trackFillPercentage;
const valueDashoffset = circumference * (1 - valuePercentage);
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox={viewBox}>
<circle
fill="none"
cx="50%"
cy="50%"
r={radius}
stroke="white"
strokeDasharray={dasharray}
strokeDashoffset={trackDashoffset}
strokeWidth={TRACK_WIDTH_PX}
/>
<circle
fill="none"
strokeLinecap="round"
cx="50%"
cy="50%"
r={radius}
stroke="gold"
strokeDasharray={dasharray}
strokeDashoffset={valueDashoffset}
strokeWidth={TRACK_WIDTH_PX}
transform={trackTransform}
/>
</svg>
);
}

Tada 🎉 We have the value visualized with a gold track. It's 50% currently.

To make it a little bit nicer I'll put some text on the gauge that is going to show the value in a written form and add some colors, like a little gradient to the gold track. But that's purely aesthetics so we won't focus too much on it.

Please play with the range slider below:

50%
Brightness

Things are getting real, aren't they?

Make it interactive

Draw the handler

Controlling the value from the outside is cool, but even cooler is to make the gauge interactive and put some kind of handle on it so a user can easily move the handle and set the new value.

Let's put a ball-like handler/setter in the neutral position (50%). With the center point being 50%, 50% as for other elements, we need to translate it to the top - translate(0, -46). So we just moved it 46 pixels up.

50%
Brightness

Of course, that's a magic number ✨ and other elements are not aware of the extra space we need for the setter to avoid it being cut on the edges.

Here are the necessary adjustments. I'm going to bind the setter's radius in relation to the stroke of tracks. It's going to be 80% of the track's width + a border of 33%. Then we adjust the radius to take that into account. This time, magic numbers are allowed 😅 I'm picking proportions that just look good on the screen to me and have the potential to scale nicely.

// [...]
const SETTER_RADIUS_RATIO = 0.8;
const SETTER_STROKE_RATIO = 1 / 3;
const VIEW_BOX_SIZE_PX = 100;
export default function RadialGauge({ value }) {
const viewBox = `0 0 ${VIEW_BOX_SIZE_PX} ${VIEW_BOX_SIZE_PX}`;
const setterRadius = TRACK_WIDTH_PX * SETTER_RADIUS_RATIO;
const setterStrokeWidth = TRACK_WIDTH_PX * SETTER_STROKE_RATIO;
const radius = VIEW_BOX_SIZE_PX / 2 - TRACK_WIDTH_PX / 2 - setterRadius;
// [...]
}

It's not cut anymore, but I again adjusted the top translate value manually to get the desired effect.

50%
Brightness

To position it properly and react to the value, we will again have to do a bit of math.

First of all, the setter moves on an arc. To properly calculate the position of the setter we use equations with sinus and cosines [angles are in radians]:

  • x = - radius * sin(angle)
  • y = radius * cos(angle)

Ha! You thought you don't need sinus and cosines anymore you nasty ≈ Tailwind-kid, huh?! 😉

Final equations are a bit more complicated because of the track size property that determines how many "angles" are 100% of the track (full circle = 360°). We need to take a proportion and adjust the angle with half of the part of the circle that "remains". Maybe it's easier to show the code than reading the plain text?

So we use some helper functions to calculate the x and y of the setter and apply it as transform:

export default function RadialGauge({ value }) {
// [...]
const setterPos = calculateSetterPosition(radius, TRACK_SIZE_DEGREES, value);
const setterTranslate = `translate(${setterPos.x}, ${setterPos.y})`;
// [...]
return (
// [...]
<circle
fill="#ffc400"
stroke="#fff"
cx="50%"
cy="50%"
r={setterRadius}
strokeWidth={setterStrokeWidth}
transform={setterTranslate}
/>
</svg>
);
}

The helper functions are (1) the hearth of the implementation -> angular offset and (2) conversion from degrees to radians that the first function uses internally:

// (1)
export function calculateSetterPosition(radius, size, value) {
return {
x: -radius * Math.sin(degToRad((value / 100) * size + (360 - size) / 2)),
y: radius * Math.cos(degToRad((value / 100) * size + (360 - size) / 2)),
};
}
// (2)
export function degToRad(deg) {
return deg * (Math.PI / 180);
}

Let's see the effect. Play with the value slider and see how nicely it fits the track:

50%
Brightness

Grab and move the handler

Now we have something to drag. I'm an old-school kid so we will use mousedown, mousemove, and mouseup events.

First, we will put it into a moving state by handling mousedown, when the user clicks on the setter. Then we have to "guess" where to put the setter on the track by calculating the trajectory - turning the mouse's x,y into x,y on the track. Here's a small visualization:

When you move around, the line that starts in the center crosses the track. There's also a rare case (for our 270° arc) where the line doesn't meet the arc - we will have to apply a limit there so a user can't move the setter outside of the track.

Once again, we need math. But instead of calculating the trajectory, we will try to kill two birds 🐦🐦 with one stone 🪨. We are not interested in pixel precision. We don't want to set 66.781%. No. All we want is to snap the values to decimal numbers - 66% or 67% in the example above.

So we will have to calculate the closest allowed value anyway for snapping to work. Maybe let's find out what is the closest point to the user's mouse position? As a mental exercise, let's put all values (0%, 1%, 2%, ..., 99%, 100%) on the track and show which one is the closest to the mouse.

As you move your mouse over you should see one circle being bigger than others - this is what we've just calculated as the closest point to the user's mouse position.

How is that done?

First, we need to use a method already known to us - calculateSetterPosition. But this time, we will iterate through all possible values between 0 and 100 to calculate all possible positions for the setter. Let's call it stepsCoords:

export function calculateStepsCoords(radius, size) {
const stepsCoords = [];
for (let i = 0; i <= 100; i++) {
const coords = calculateSetterPosition(radius, size, i);
stepsCoords.push({
value: i,
x: coords.x,
y: coords.y,
});
}
return stepsCoords;
}

Then when we move the mouse inside the container, we need to find the closest point in stepsCoords:

// [...]
const [currentStep, setCurrentStep] = useState(null);
// [...]
function onMove(event) {
const cxy = event.currentTarget.clientWidth / 2;
const x = event.pageX - event.currentTarget.offsetLeft - cxy;
const y = event.pageY - event.currentTarget.offsetTop - cxy;
const closestStep = findClosestStep(stepsCoords, x, y);
if (currentStep?.value !== closestStep.value) {
setCurrentStep(closestStep);
}
}
// [...]
export function findClosestStep(stepsCoords, x, y) {
let min = distanceBetweenPoints({ x, y }, stepsCoords[0]);
let minStep = stepsCoords[0];
stepsCoords.forEach((step) => {
const distance = distanceBetweenPoints({ x, y }, step);
if (min > distance) {
min = distance;
minStep = step;
}
});
return minStep;
}
// [...]
export function distanceBetweenPoints(A, B) {
return Math.hypot(B.x - A.x, B.y - A.y);
}

Despite it might look complicated there are just 3 steps here:

  • Handling "on move" and calculating mouse position relative to the center of the canvas
  • Iterating through all the points and calculating the distance to the mouse through Math.hypot()
  • Saving the point for which the distance is the shortest

In the explanation above we were saving the "current step" where in fact we just want to set the value. Following the good pattern that was coined by EmberJS -> Action up, Data down, we will just call a function prop and let the outside world control the gauge.

function onMove(event) {
const cxy = event.currentTarget.clientWidth / 2;
const x = event.pageX - event.currentTarget.offsetLeft - cxy;
const y = event.pageY - event.currentTarget.offsetTop - cxy;
const closestStep = findClosestStep(stepsCoords, x, y);
if (closestStep.value !== value && typeof onValueChange === 'function') {
onValueChange(closestStep.value);
}
}
// In JSX
const [value, setValue] = useState(50);
<RadialGauge value={value} onValueChange={setValue} />;

And that's basically all. We get the final result:

50%
Brightness

Bonus. Support touchscreen

That should be treated as something normal, not a bonus, right? 😅 Supporting a touchscreen for this type of interaction is pretty easy.

The onMove function has to check if touch is not involved and if it is, then we focus only on the first touch:

function onMove(event) {
if (isSetterMoving) {
const isTouch = event.touches && event.touches[0];
const cxy = event.currentTarget.clientWidth / 2;
const pageX = isTouch ? event.touches[0].pageX : event.pageX;
const pageY = isTouch ? event.touches[0].pageY : event.pageY;
const x = pageX - event.currentTarget.offsetLeft - cxy;
const y = pageY - event.currentTarget.offsetTop - cxy;
const closestStep = findClosestStep(stepsCoords, x, y);
if (closestStep.value !== value && typeof onValueChange === 'function') {
onValueChange(closestStep.value);
}
}
}

Of course, we also have to enrich our markup with proper event listeners:

<div
className={styles.gaugeContainer}
onMouseUp={() => setSetterMoving(false)}
onMouseMove={(ev) => onMove(ev)}
onTouchEnd={() => setSetterMoving(false)}
onTouchMove={(ev) => onMove(ev)}
>
// [...] // Setter
<circle
className={styles.gaugeSetter}
fill="#ffc400"
stroke="#fff"
cx="50%"
cy="50%"
r={setterRadius}
strokeWidth={setterStrokeWidth}
transform={setterTranslate}
onMouseDown={() => setSetterMoving(true)}
onTouchStart={() => setSetterMoving(true)}
/>
// [...]
</div>

The demo above, and at the beginning of this post already includes the touchscreen support.

Challenge me! Please 🙏

Did you see a concept of a non-trivial interface somewhere? Maybe I could try to implement it and describe the process here? Please DM me on Twitter or reply to this post's tweet.