Creating a Gauge in React

Let's embark on a journey.

At the end of this journey, we'll have created a gauge component in React.js.

A gauge is a simple diagram that visualizes a simple number. The shape and design is based on physical gauges, used in control panels to show the measurement of a specific metric.

I like to use them in digital interfaces to show context around a number -- a user can quickly see whether a value is lower or higher than expected. However, while our goal is to create a gauge React.js component, we'll learn concepts and tricks along the way that can help to create many other things. As our good friend Ursula Le Guin puts it,

It is good to have an end to journey toward; but it is the journey that matters, in the end.
Ursula K. Le Guin, The Left Hand of Darkness

On this journey, we'll defeat dragons, monsters, and the most powerful foe of all: drawing arcs in the browser.

2
Time spent reading
seconds
Gauge.jsx
1.
import React from "react"
2.
3.
const Gauge = ({
4.
value=50,
5.
min=0,
6.
max=100,
7.
label,
8.
units,
9.
}) => {
10.
return (
11.
<div>
12.
</div>
13.
)
14.
}
15.
16.
export default Gauge
value
50
min
0
max
100
label
units

The beginning of our journey finds us in a Gauge.jsx file, with a simple functional React component.

Gauge.jsx
1.
import React from "react"
2.
3.
const Gauge = ({
4.
value=50,
5.
min=0,
6.
max=100,
7.
label,
8.
units,
9.
}) => {
10.
return (
11.
<div>
12.
</div>
13.
)
14.
}
15.
16.
export default Gauge
value
50
min
0
max
100

Whenever we create a new <Gauge>, we'll be able to customize it with five props:

  • 1.
    value is the current value. This defaults to 50,
  • 2.
    min is the minimum value for our metric. This defaults to 0,
  • 3.
    max is the maximum value for our metric. This defaults to 100,
  • 4.
    label,
  • 5.
    units,

Underneath our code, we'll show the output of our component.

Let's start by adding an <svg> component, with a nice pink border so we can see its dimensions.

Gauge.jsx
1.
import React from "react"
2.
3.
const Gauge = ({
4.
value=50,
5.
min=0,
6.
max=100,
7.
label,
8.
units,
9.
}) => {
10.
return (
11.
<div>
12.
<svg style={{
13.
border: "1px solid pink"
14.
}}>
15.
</svg>
16.
</div>
17.
)
18.
}
19.
20.
export default Gauge
value
50
min
0
max
100

As we can see, the default size of a <svg> component is 300 pixels wide by 150 pixels tall.

We want our gauge to be 9em wide. This will let our gauge scale with our text, so it will never look disproportionate with the labels that we'll add later.

Gauge.jsx
1.
import React from "react"
2.
3.
const Gauge = ({
4.
value=50,
5.
min=0,
6.
max=100,
7.
label,
8.
units,
9.
}) => {
10.
return (
11.
<div>
12.
<svg
13.
width="9em"
14.
style={{
15.
border: "1px solid pink"
16.
}}>
17.
</svg>
18.
</div>
19.
)
20.
}
21.
22.
export default Gauge
value
50
min
0
max
100

We can think about the <svg> element as a telescope into another world. Our telescope defaults to a normal zoom level: one unit is the same as one pixel.

But we can zoom our telescope in or out. To set the zoom on our telescope, we'll use the viewBox property.

viewBox takes four arguments:

  • x and y set the position of the top, left corner of our view box. Changing these values will pan our view.
  • width and height set the number of "units" that are visible inside of our view box. Changing these values will zoom our view.

Let's see what different telescope settings would look like for a simple svg with a purple circle at [50, 50], with a radius of 40.

0
0
100
100
viewBox: "0 0 100 100"
[0, 0]
[100, 0]
[0, 100]
[100, 100]
[50, 50]

For our gauge, let's simplify our math and focus our telescope on a simple 2 by 1 grid:

[-1, -1]
[1, -1]
[-1, 0]
[1, 0]
[0, -1]
[0, 0]
Gauge.jsx
1.
import React from "react"
2.
3.
const Gauge = ({
4.
value=50,
5.
min=0,
6.
max=100,
7.
label,
8.
units,
9.
}) => {
10.
return (
11.
<div>
12.
<svg
13.
width="9em"
14.
viewBox={[
15.
-1, -1,
16.
2, 1,
17.
].join(" ")}
18.
style={{
19.
border: "1px solid pink"
20.
}}>
21.
</svg>
22.
</div>
23.
)
24.
}
25.
26.
export default Gauge
value
50
min
0
max
100

If this doesn't make much sense yet, stay tuned and you'll start seeing the benefits very soon.

Alright, let's begin the next phase of our journey:

Drawing!

Drawing arcs in SVG is a complex feat, but we can use our trusty weapon d3.arc(), from the d3-shape library.

d3.arc() will create an arc generator. We can configure our arc generator by calling different methods on it.

innerRadius
25
startAngle
0
outerRadius
40
endAngle
5.5
cornerRadius
20
padAngle
0
Show me the code
tap me for more details
1.
const arcGenerator = d3.arc()
2.
.innerRadius(25)
3.
.outerRadius(40)
4.
.startAngle(0)
5.
.endAngle(5.5)
6.
.padAngle(0)
7.
.cornerRadius(20)
8.
9.
const arcPath = arcPathGenerator()
10.
11.
** HTML **
12.
13.
<svg width="100" height="100">
14.
<path
15.
fill="cornflowerblue"
16.
d={arcPath}
17.
style="transform: translate(50%, 50%)"
18.
/>
19.
</svg>
20.

Let's look at the parameters we want to use for our arc:

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
4.
const Gauge = ({
5.
value=50,
6.
min=0,
7.
max=100,
8.
label,
9.
units,
10.
}) => {
11.
const backgroundArc = arc()
12.
.innerRadius(0.65)
13.
.outerRadius(1)
14.
.startAngle(-Math.PI / 2)
15.
.endAngle(Math.PI / 2)
16.
.cornerRadius(1)
17.
18.
return (
19.
<div>
20.
<svg
21.
width="9em"
22.
viewBox={[
23.
-1, -1,
24.
2, 1,
25.
].join(" ")}
26.
style={{
27.
border: "1px solid pink"
28.
}}>
29.
</svg>
30.
</div>
31.
)
32.
}
33.
34.
export default Gauge
value
50
min
0
max
100
  • 1.
    our arc's radius will start at 0.65 and end at 1, ending up 0.35 units wide
  • 2.
    our arc will start at -Math.PI / 2 (one quarter turn counterclockwise), and extend to Math.PI / 2 (one quarter turn clockwise)
  • 3.
    let's make our arc a bit friendlier, with a cornerRadius of 1,

To use our arc generator, we simply need to call it.

Let's see what that looks like for our gauge. To start, we'll create a grey background arc, to show the full range of our gauge.

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
4.
const Gauge = ({
5.
value=50,
6.
min=0,
7.
max=100,
8.
label,
9.
units,
10.
}) => {
11.
const backgroundArc = arc()
12.
.innerRadius(0.65)
13.
.outerRadius(1)
14.
.startAngle(-Math.PI / 2)
15.
.endAngle(Math.PI / 2)
16.
.cornerRadius(1)
17.
()
18.
19.
return (
20.
<div>
21.
<svg
22.
width="9em"
23.
viewBox={[
24.
-1, -1,
25.
2, 1,
26.
].join(" ")}
27.
style={{
28.
border: "1px solid pink"
29.
}}>
30.
</svg>
31.
</div>
32.
)
33.
}
34.
35.
export default Gauge
value
50
min
0
max
100

Great! Calling an arc generator, with these settings spits out a string:

"M-0.8062257748298549,-8.326672684688674e-17A0.175,0.175,0,0,1,-0.9772433634301272,-0.2121212121212122A1,1,0,0,1,0.9772433634301272,-0.21212121212121218A0.175,0.175,0,0,1,0.8062257748298549,-2.7755575615628914e-17L0.8062257748298549,-2.7755575615628914e-17A0.175,0.175,0,0,1,0.6352081862295826,-0.13787878787878788A0.65,0.65,0,0,0,-0.6352081862295826,-0.13787878787878793A0.175,0.175,0,0,1,-0.8062257748298549,-8.326672684688674e-17Z"

This incantation is a set of instructions that can be passed to an SVG <path> element.

In our render method, let's create a new <path> element and pass these magic words to the d attribute.

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
4.
const Gauge = ({
5.
value=50,
6.
min=0,
7.
max=100,
8.
label,
9.
units,
10.
}) => {
11.
const backgroundArc = arc()
12.
.innerRadius(0.65)
13.
.outerRadius(1)
14.
.startAngle(-Math.PI / 2)
15.
.endAngle(Math.PI / 2)
16.
.cornerRadius(1)
17.
()
18.
19.
return (
20.
<div>
21.
<svg style={{overflow: "visible"}}
22.
width="9em"
23.
viewBox={[
24.
-1, -1,
25.
2, 1,
26.
].join(" ")}>
27.
<path
28.
d={backgroundArc}
29.
fill="#dbdbe7"
30.
/>
31.
</svg>
32.
</div>
33.
)
34.
}
35.
value
50
min
0
max
100

Perfect!

Note how our arc is perfectly centered in our <svg>. This is because d3.arc(), by default, centers the arc around [0, 0].

[-1, -1]
[1, -1]
[-1, 0]
[1, 0]
[0, -1]
[0, 0]

If we revisit our grid figure, we can see that the [0, 0] point is at the bottom, center of the viewBox. This perfectly frames the top half of our arc, which is the only part that we're interested in.

Now that we've drawn the background arc which represents the range of possible values, we'll want to draw the filled arc that shows the current value.

  • 1.
    First, we need to know what percent of the possible values lie below our current value. For example, if our range was from 0 - 100, a value of 50 would be 50% through our possible range.

    We'll create a percentScale which transforms values into a percent (between 0 and 1).Our percentScale will use a scaleLinear from d3-scale.
  • 2.
    Next, we need to know what angle our filled arc needs to fill until. Let's create an angleScale that maps a percent into our possible angles (remember these values from our backgroundArc's innerRadius and outerRadius?)
  • 3.
    Wonderful! Now that we know the end angle, we can create the incantation for our filled arc's d attribute.
  • 4.
    And lastly, let's summit that hill and draw our filled arc!
Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
import { scaleLinear } from "d3-scale"
4.
5.
const Gauge = ({
6.
value=50,
7.
min=0,
8.
max=100,
9.
label,
10.
units,
11.
}) => {
12.
const backgroundArc = arc()
13.
.innerRadius(0.65)
14.
.outerRadius(1)
15.
.startAngle(-Math.PI / 2)
16.
.endAngle(Math.PI / 2)
17.
.cornerRadius(1)
18.
()
19.
20.
const percentScale = scaleLinear()
21.
.domain([min, max])
22.
.range([0, 1])
23.
const percent = percentScale(value)
24.
25.
const angleScale = scaleLinear()
26.
.domain([0, 1])
27.
.range([-Math.PI / 2, Math.PI / 2])
28.
.clamp(true)
29.
30.
const angle = angleScale(percent)
31.
32.
const filledArc = arc()
33.
.innerRadius(0.65)
34.
.outerRadius(1)
35.
.startAngle(-Math.PI / 2)
36.
.endAngle(angle)
37.
.cornerRadius(1)
38.
()
39.
40.
return (
41.
<div>
42.
<svg style={{overflow: "visible"}}
43.
width="9em"
44.
viewBox={[
45.
-1, -1,
46.
2, 1,
47.
].join(" ")}>
48.
<path
49.
d={backgroundArc}
50.
fill="#dbdbe7"
51.
/>
52.
<path
53.
d={filledArc}
54.
fill="#9980FA"
55.
/>
56.
</svg>
57.
</div>
58.
)
59.
}
60.
value
50
min
0
max
100

Perfect! Play around with the slider to see how our filled arc looks with different values.

Adding a gradient

One thing you might notice when playing with our gauge's value is that most states feel kind of... the same.

Let's reinforce how far the current value is to the left of right by filling our filled arc with a light-to-dark gradient.

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
import { scaleLinear } from "d3-scale"
4.
5.
const Gauge = ({
6.
value=50,
7.
min=0,
8.
max=100,
9.
label,
10.
units,
11.
}) => {
12.
const backgroundArc = arc()
13.
.innerRadius(0.65)
14.
.outerRadius(1)
15.
.startAngle(-Math.PI / 2)
16.
.endAngle(Math.PI / 2)
17.
.cornerRadius(1)
18.
()
19.
20.
const percentScale = scaleLinear()
21.
.domain([min, max])
22.
.range([0, 1])
23.
const percent = percentScale(value)
24.
25.
const angleScale = scaleLinear()
26.
.domain([0, 1])
27.
.range([-Math.PI / 2, Math.PI / 2])
28.
.clamp(true)
29.
30.
const angle = angleScale(percent)
31.
32.
const filledArc = arc()
33.
.innerRadius(0.65)
34.
.outerRadius(1)
35.
.startAngle(-Math.PI / 2)
36.
.endAngle(angle)
37.
.cornerRadius(1)
38.
()
39.
40.
const colorScale = scaleLinear()
41.
.domain([0, 1])
42.
.range(["#dbdbe7", "#4834d4"])
43.
44.
const gradientSteps = colorScale.ticks(10)
45.
.map(value => colorScale(value))
46.
47.
return (
48.
<div>
49.
<svg style={{overflow: "visible"}}
50.
width="9em"
51.
viewBox={[
52.
-1, -1,
53.
2, 1,
54.
fill="#9980FA"
55.
].join(" ")}>
56.
<path
57.
d={backgroundArc}
58.
fill="#dbdbe7"
59.
/>
60.
<path
61.
d={filledArc}
62.
/>
63.
</svg>
64.
</div>
65.
)
66.
}
67.
value
50
min
0
max
100
  • 1.
    First, we'll need a color scale from our lightest to our darkest color.
  • 2.
    Next, we need an array of color values that we want our gradient to interpolate through. To create this, we can use our colorScale's .ticks() method to output an array of equally-spaced values across the output range.

    Let's see a toy example for a simple scale that maps 0 - 1 to the range 0 - 100:
    .ticks() example
    1.
    const scale = scaleLinear()
    2.
    .domain([0, 1])
    3.
    .range([0, 100])
    4.
    5.
    const ticks = scale.ticks()
    6.
    alert(ticks)

Now that we have our gradient's colors, we can apply them!

On the web, we might be used to using a simple background CSS gradient, but those won't work in SVG land. Let's see what we need to do instead.

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
import { scaleLinear } from "d3-scale"
4.
5.
const Gauge = ({
6.
value=50,
7.
min=0,
8.
max=100,
9.
label,
10.
units,
11.
}) => {
12.
const backgroundArc = arc()
13.
.innerRadius(0.65)
14.
.outerRadius(1)
15.
.startAngle(-Math.PI / 2)
16.
.endAngle(Math.PI / 2)
17.
.cornerRadius(1)
18.
()
19.
20.
const percentScale = scaleLinear()
21.
.domain([min, max])
22.
.range([0, 1])
23.
const percent = percentScale(value)
24.
25.
const angleScale = scaleLinear()
26.
.domain([0, 1])
27.
.range([-Math.PI / 2, Math.PI / 2])
28.
.clamp(true)
29.
30.
const angle = angleScale(percent)
31.
32.
const filledArc = arc()
33.
.innerRadius(0.65)
34.
.outerRadius(1)
35.
.startAngle(-Math.PI / 2)
36.
.endAngle(angle)
37.
.cornerRadius(1)
38.
()
39.
40.
const colorScale = scaleLinear()
41.
.domain([0, 1])
42.
.range(["#dbdbe7", "#4834d4"])
43.
44.
const gradientSteps = colorScale.ticks(10)
45.
.map(value => colorScale(value))
46.
47.
return (
48.
<div>
49.
<svg style={{overflow: "visible"}}
50.
width="9em"
51.
viewBox={[
52.
-1, -1,
53.
2, 1,
54.
].join(" ")}>
55.
<defs>
56.
<linearGradient
57.
id="Gauge__gradient"
58.
gradientUnits="userSpaceOnUse"
59.
x1="-1"
60.
x2="1"
61.
y2="0">
62.
</linearGradient>
63.
</defs>
64.
<path
65.
d={backgroundArc}
66.
fill="#dbdbe7"
67.
/>
68.
<path
69.
d={filledArc}
70.
fill="url(#Gauge__gradient)"
71.
/>
72.
</svg>
73.
</div>
74.
)
75.
}
76.
value
50
min
0
max
100
  • 1.
    First, we'll want to create a <defs> (ie. definitions) element. Everything inside of a <defs> element will not be rendered, but we can reference them elsewhere. This is great for elements that need to be defined before being used.
  • 2.
    Next, we'll create a <linearGradient> element.
  • 3.
    Our <linearGradient> won't render anything by itself, but we'll give it an id attribute to use as a handle to reference later.
  • 4.
    To correctly position our gradient, we want to set its gradientUnits attribute. This attribute has two options:
    • userSpaceOnUse (the default), which uses the parent <svg> element's coordinate system (set in its viewBox)
    • objectBoundingBox, which uses the coordinate system from the element the gradient is applied to
    We'll set gradientUnits to userSpaceOnUse, which is the default, but it's nice to be explicit.
  • 5.
    Now that we know that we're working in our usual coordinate space, let's look at our grid diagram:
    [-1, -1]
    [1, -1]
    [-1, 0]
    [1, 0]
    [0, -1]
    [0, 0]
    We want our gradient to stretch all the way across our arc, so we'll start at [-1, 0] and end at [1, 0].

Next, we'll need to create gradient stops, which tell the gradient where each color should fall. The gradient itself will do the hard work of blending between each of these colors.

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
import { scaleLinear } from "d3-scale"
4.
5.
const Gauge = ({
6.
value=50,
7.
min=0,
8.
max=100,
9.
label,
10.
units,
11.
}) => {
12.
const backgroundArc = arc()
13.
.innerRadius(0.65)
14.
.outerRadius(1)
15.
.startAngle(-Math.PI / 2)
16.
.endAngle(Math.PI / 2)
17.
.cornerRadius(1)
18.
()
19.
20.
const percentScale = scaleLinear()
21.
.domain([min, max])
22.
.range([0, 1])
23.
const percent = percentScale(value)
24.
25.
const angleScale = scaleLinear()
26.
.domain([0, 1])
27.
.range([-Math.PI / 2, Math.PI / 2])
28.
.clamp(true)
29.
30.
const angle = angleScale(percent)
31.
32.
const filledArc = arc()
33.
.innerRadius(0.65)
34.
.outerRadius(1)
35.
.startAngle(-Math.PI / 2)
36.
.endAngle(angle)
37.
.cornerRadius(1)
38.
()
39.
40.
const colorScale = scaleLinear()
41.
.domain([0, 1])
42.
.range(["#dbdbe7", "#4834d4"])
43.
44.
const gradientSteps = colorScale.ticks(10)
45.
.map(value => colorScale(value))
46.
47.
return (
48.
<div>
49.
<svg style={{overflow: "visible"}}
50.
width="9em"
51.
viewBox={[
52.
-1, -1,
53.
2, 1,
54.
].join(" ")}>
55.
<defs>
56.
<linearGradient
57.
id="Gauge__gradient"
58.
gradientUnits="userSpaceOnUse"
59.
x1="-1"
60.
x2="1"
61.
y2="0">
62.
{gradientSteps.map((color, index) => (
63.
<stop
64.
key={color}
65.
stopColor={color}
66.
offset={`${
67.
index
68.
/ (gradientSteps.length - 1)
69.
}`}
70.
/>
71.
))}
72.
</linearGradient>
73.
</defs>
74.
<path
75.
d={backgroundArc}
76.
fill="#dbdbe7"
77.
/>
78.
<path
79.
d={filledArc}
80.
fill="url(#Gauge__gradient)"
81.
/>
82.
</svg>
83.
</div>
84.
)
85.
}
86.
value
50
min
0
max
100
  • 1.
    To do this, we'll map over our gradientSteps array, grabbing each color and its index in the array
  • 2.
    For each color, we'll create a <stop> element
  • 3.
    React requires a key for the outmost elements in a loop, to distinguish between them and help with re-render decisions
  • 4.
    We'll set each stopColor attribute as our color, and
  • 5.
    set each offset attribute as the percent (0 - 100} through the gradient. We could create a scale to convert the index into a percent, but it's easier here to just divide the index by the maximum index (or, one less than the number of gradientSteps).
  • 6.
    And at last, we can use our handle and set the fill attribute of our filled arc.



Helpful bits & bobs

Another contextual clue we can add to our gauge is a sense of what is a normal value. When a value is close to the middle of our range, we might want to know whether or not it's above the mid-point.

One simple solution would be to draw a <line> element across the middle of our gauge, splitting it in two horizontally. If we make this line the same color as the background of our page (white), we can prevent it from stealing too much visual attention.

Note that we drew our line from [0, -1] to [0, -0.65] to prevent it from extending below our arc, whose inner radius starts at 0.65.

[-1, -1]
[1, -1]
[-1, 0]
[1, 0]
[0, -1]
[0, 0]
Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
import { scaleLinear } from "d3-scale"
4.
5.
const Gauge = ({
6.
value=50,
7.
min=0,
8.
max=100,
9.
label,
10.
units,
11.
}) => {
12.
const backgroundArc = arc()
13.
.innerRadius(0.65)
14.
.outerRadius(1)
15.
.startAngle(-Math.PI / 2)
16.
.endAngle(Math.PI / 2)
17.
.cornerRadius(1)
18.
()
19.
20.
const percentScale = scaleLinear()
21.
.domain([min, max])
22.
.range([0, 1])
23.
const percent = percentScale(value)
24.
25.
const angleScale = scaleLinear()
26.
.domain([0, 1])
27.
.range([-Math.PI / 2, Math.PI / 2])
28.
.clamp(true)
29.
30.
const angle = angleScale(percent)
31.
32.
const filledArc = arc()
33.
.innerRadius(0.65)
34.
.outerRadius(1)
35.
.startAngle(-Math.PI / 2)
36.
.endAngle(angle)
37.
.cornerRadius(1)
38.
()
39.
40.
const colorScale = scaleLinear()
41.
.domain([0, 1])
42.
.range(["#dbdbe7", "#4834d4"])
43.
44.
const gradientSteps = colorScale.ticks(10)
45.
.map(value => colorScale(value))
46.
47.
return (
48.
<div>
49.
<svg style={{overflow: "visible"}}
50.
width="9em"
51.
viewBox={[
52.
-1, -1,
53.
2, 1,
54.
].join(" ")}>
55.
<defs>
56.
<linearGradient
57.
id="Gauge__gradient"
58.
gradientUnits="userSpaceOnUse"
59.
x1="-1"
60.
x2="1"
61.
y2="0">
62.
{gradientSteps.map((color, index) => (
63.
<stop
64.
key={color}
65.
stopColor={color}
66.
offset={`${
67.
index
68.
/ (gradientSteps.length - 1)
69.
}`}
70.
/>
71.
))}
72.
</linearGradient>
73.
</defs>
74.
<path
75.
d={backgroundArc}
76.
fill="#dbdbe7"
77.
/>
78.
<path
79.
d={filledArc}
80.
fill="url(#Gauge__gradient)"
81.
/>
82.
<line
83.
y1="-1"
84.
y2="-0.65"
85.
stroke="white"
86.
strokeWidth="0.027"
87.
/>
88.
</svg>
89.
</div>
90.
)
91.
}
92.
value
50
min
0
max
100

We might also notice that the end of our filled arc can get a little lost, especially with the rounded corner. But this is supposed to be the main point of our diagram!

Let's add a bubble where our arc ends, to give it more visual importance.

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
import { scaleLinear } from "d3-scale"
4.
5.
const Gauge = ({
6.
value=50,
7.
min=0,
8.
max=100,
9.
label,
10.
units,
11.
}) => {
12.
const backgroundArc = arc()
13.
.innerRadius(0.65)
14.
.outerRadius(1)
15.
.startAngle(-Math.PI / 2)
16.
.endAngle(Math.PI / 2)
17.
.cornerRadius(1)
18.
()
19.
20.
const percentScale = scaleLinear()
21.
.domain([min, max])
22.
.range([0, 1])
23.
const percent = percentScale(value)
24.
25.
const angleScale = scaleLinear()
26.
.domain([0, 1])
27.
.range([-Math.PI / 2, Math.PI / 2])
28.
.clamp(true)
29.
30.
const angle = angleScale(percent)
31.
32.
const filledArc = arc()
33.
.innerRadius(0.65)
34.
.outerRadius(1)
35.
.startAngle(-Math.PI / 2)
36.
.endAngle(angle)
37.
.cornerRadius(1)
38.
()
39.
40.
const colorScale = scaleLinear()
41.
.domain([0, 1])
42.
.range(["#dbdbe7", "#4834d4"])
43.
44.
const gradientSteps = colorScale.ticks(10)
45.
.map(value => colorScale(value))
46.
47.
const markerLocation = getCoordsOnArc(
48.
angle,
49.
1 - ((1 - 0.65) / 2),
50.
)
51.
52.
return (
53.
<div>
54.
<svg style={{overflow: "visible"}}
55.
width="9em"
56.
viewBox={[
57.
-1, -1,
58.
2, 1,
59.
].join(" ")}>
60.
<defs>
61.
<linearGradient
62.
id="Gauge__gradient"
63.
gradientUnits="userSpaceOnUse"
64.
x1="-1"
65.
x2="1"
66.
y2="0">
67.
{gradientSteps.map((color, index) => (
68.
<stop
69.
key={color}
70.
stopColor={color}
71.
offset={`${
72.
index
73.
/ (gradientSteps.length - 1)
74.
}`}
75.
/>
76.
))}
77.
</linearGradient>
78.
</defs>
79.
<path
80.
d={backgroundArc}
81.
fill="#dbdbe7"
82.
/>
83.
<path
84.
d={filledArc}
85.
fill="url(#Gauge__gradient)"
86.
/>
87.
<line
88.
y1="-1"
89.
y2="-0.65"
90.
stroke="white"
91.
strokeWidth="0.027"
92.
/>
93.
<circle
94.
cx={markerLocation[0]}
95.
cy={markerLocation[1]}
96.
r="0.2"
97.
stroke="#2c3e50"
98.
strokeWidth="0.01"
99.
fill={colorScale(percent)}
100.
/>
101.
</svg>
102.
</div>
103.
)
104.
}
105.
106.
const getCoordsOnArc = (angle, offset=10) => [
107.
Math.cos(angle - (Math.PI / 2)) * offset,
108.
Math.sin(angle - (Math.PI / 2)) * offset,
109.
]
110.
value
50
min
0
max
100
  • 1.
    First, we need to figure out the position of our bubble. We'll use our custom getCoordsOnArc() function (defined in the next bullet point!). We can pass it our value's angle and the distance from the center of our arc. This will be half the distance the inner radius (0.65) is from the outer radius (1).
  • 2.
    At the bottom of our file, let's define our getCoordsOnArc() function. This is something I usually have lying around, because it comes in handy when doing radial layouts.
  • 3.
    At last, we can draw our bubble! We'll create a <circle> element, setting its center ([cx, cy]) at our markerLocation. A radius (r) of 0.2 feels right (a little larger than the arc itself), and we'll give it a slight border (stroke) to make it pop a bit.

    We'll also want to set the fill to match the color of the filled arc's gradient. Even though the gradient is partially covered by the bubble, this double encoding of the value helps the user focus on our main message: how far through the range is our value? Play around with the sliders to see how the changing color feels.

Our last bob is a little arrow that points to our bubble. This is our third redundancy for the current value - might as well throw up a neon sign, am I right?

Maybe it's overkill, but the arrow has another benefit: it further emphasizes the visual metaphor to an analog gauge, like one you would find in a pilot's dashboard. Let's implement it, and see how we like it.

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
import { scaleLinear } from "d3-scale"
4.
5.
const Gauge = ({
6.
value=50,
7.
min=0,
8.
max=100,
9.
label,
10.
units,
11.
}) => {
12.
const backgroundArc = arc()
13.
.innerRadius(0.65)
14.
.outerRadius(1)
15.
.startAngle(-Math.PI / 2)
16.
.endAngle(Math.PI / 2)
17.
.cornerRadius(1)
18.
()
19.
20.
const percentScale = scaleLinear()
21.
.domain([min, max])
22.
.range([0, 1])
23.
const percent = percentScale(value)
24.
25.
const angleScale = scaleLinear()
26.
.domain([0, 1])
27.
.range([-Math.PI / 2, Math.PI / 2])
28.
.clamp(true)
29.
30.
const angle = angleScale(percent)
31.
32.
const filledArc = arc()
33.
.innerRadius(0.65)
34.
.outerRadius(1)
35.
.startAngle(-Math.PI / 2)
36.
.endAngle(angle)
37.
.cornerRadius(1)
38.
()
39.
40.
const colorScale = scaleLinear()
41.
.domain([0, 1])
42.
.range(["#dbdbe7", "#4834d4"])
43.
44.
const gradientSteps = colorScale.ticks(10)
45.
.map(value => colorScale(value))
46.
47.
const markerLocation = getCoordsOnArc(
48.
angle,
49.
1 - ((1 - 0.65) / 2),
50.
)
51.
52.
return (
53.
<svg style={{overflow: "visible"}}
54.
<div>
55.
width="9em"
56.
viewBox={[
57.
-1, -1,
58.
2, 1,
59.
].join(" ")}>
60.
<defs>
61.
<linearGradient
62.
id="Gauge__gradient"
63.
gradientUnits="userSpaceOnUse"
64.
x1="-1"
65.
x2="1"
66.
y2="0">
67.
{gradientSteps.map((color, index) => (
68.
<stop
69.
key={color}
70.
stopColor={color}
71.
offset={`${
72.
index
73.
/ (gradientSteps.length - 1)
74.
}`}
75.
/>
76.
))}
77.
</linearGradient>
78.
</defs>
79.
<path
80.
d={backgroundArc}
81.
fill="#dbdbe7"
82.
/>
83.
<path
84.
d={filledArc}
85.
fill="url(#Gauge__gradient)"
86.
/>
87.
<line
88.
y1="-1"
89.
y2="-0.65"
90.
stroke="white"
91.
strokeWidth="0.027"
92.
/>
93.
<circle
94.
cx={markerLocation[0]}
95.
cy={markerLocation[1]}
96.
r="0.2"
97.
stroke="#2c3e50"
98.
strokeWidth="0.01"
99.
fill={colorScale(percent)}
100.
/>
101.
<path
102.
d="M0.136364 0.0290102C0.158279 -0.0096701 0.219156 -0.00967009 0.241071 0.0290102C0.297078 0.120023 0.375 0.263367 0.375 0.324801C0.375 0.422639 0.292208 0.5 0.1875 0.5C0.0852272 0.5 -1.8346e-08 0.422639 -9.79274e-09 0.324801C0.00243506 0.263367 0.0803571 0.120023 0.136364 0.0290102ZM0.1875 0.381684C0.221591 0.381684 0.248377 0.356655 0.248377 0.324801C0.248377 0.292947 0.221591 0.267918 0.1875 0.267918C0.153409 0.267918 0.126623 0.292947 0.126623 0.324801C0.126623 0.356655 0.155844 0.381684 0.1875 0.381684Z"
103.
transform={`rotate(${
104.
angle * (180 / Math.PI)
105.
}) translate(-0.2, -0.33)`}
106.
fill="#6a6a85"
107.
/>
108.
</svg>
109.
</div>
110.
)
111.
}
112.
113.
const getCoordsOnArc = (angle, offset=10) => [
114.
Math.cos(angle - (Math.PI / 2)) * offset,
115.
Math.sin(angle - (Math.PI / 2)) * offset,
116.
]
117.
value
50
min
0
max
100
  • 1.
    First, we'll create a new <path> element.
  • 2.
    A monster appears! What is this long, complicated string?

    One trick I like to use is to draw a vector graphic in a design tool such as Adobe Illustrator or Figma, then export that graphic as an svg. Then I can open that svg file in my text editor and grab the code I want.

    In this case, I created our arrow in Figma, then grabbed the <path>'s d attribute and pasted it in here.
  • 3.
    We can use the angle we computed before to rotate our arrow. We'll need to convert it from radians into degrees, since the default rotate unit is degrees.

    We also have a static transform to move our arrow left and up, to rotate around the middle dot, instead of our arrow's top, left corner.



The last details

Phew! That was quite a large hill to climb!

Now that we're happy with our gauge diagram, let's add in some context to surface the actual data to users. We'll add the current value, and two optional labels: the name of the metric, and the unit we're working in. I find it's good to have these prompts when creating a new Gauge.

This step might feel a little "Draw the rest of the owl". I won't walk through every single change, but feel free to go through all of the changes (highlighted), it's pretty straightforward!

Let's look at a few highlights, though.

Gauge.jsx
1.
import React from "react"
2.
import { arc } from "d3-shape"
3.
import { scaleLinear } from "d3-scale"
4.
import { format } from "d3-format"
5.
6.
const Gauge = ({
7.
value=50,
8.
min=0,
9.
max=100,
10.
label,
11.
units,
12.
}) => {
13.
const backgroundArc = arc()
14.
.innerRadius(0.65)
15.
.outerRadius(1)
16.
.startAngle(-Math.PI / 2)
17.
.endAngle(Math.PI / 2)
18.
.cornerRadius(1)
19.
()
20.
21.
const percentScale = scaleLinear()
22.
.domain([min, max])
23.
.range([0, 1])
24.
const percent = percentScale(value)
25.
26.
const angleScale = scaleLinear()
27.
.domain([0, 1])
28.
.range([-Math.PI / 2, Math.PI / 2])
29.
.clamp(true)
30.
31.
const angle = angleScale(percent)
32.
33.
const filledArc = arc()
34.
.innerRadius(0.65)
35.
.outerRadius(1)
36.
.startAngle(-Math.PI / 2)
37.
.endAngle(angle)
38.
.cornerRadius(1)
39.
()
40.
41.
const colorScale = scaleLinear()
42.
.domain([0, 1])
43.
.range(["#dbdbe7", "#4834d4"])
44.
45.
const gradientSteps = colorScale.ticks(10)
46.
.map(value => colorScale(value))
47.
48.
const markerLocation = getCoordsOnArc(
49.
angle,
50.
1 - ((1 - 0.65) / 2),
51.
)
52.
53.
return (
54.
<div
55.
style={{
56.
textAlign: "center",
57.
}}>
58.
<svg style={{overflow: "visible"}}
59.
width="9em"
60.
viewBox={[
61.
-1, -1,
62.
2, 1,
63.
].join(" ")}>
64.
<defs>
65.
<linearGradient
66.
id="Gauge__gradient"
67.
gradientUnits="userSpaceOnUse"
68.
x1="-1"
69.
x2="1"
70.
y2="0">
71.
{gradientSteps.map((color, index) => (
72.
<stop
73.
key={color}
74.
stopColor={color}
75.
offset={`${
76.
index
77.
/ (gradientSteps.length - 1)
78.
}`}
79.
/>
80.
))}
81.
</linearGradient>
82.
</defs>
83.
<path
84.
d={backgroundArc}
85.
fill="#dbdbe7"
86.
/>
87.
<path
88.
d={filledArc}
89.
fill="url(#Gauge__gradient)"
90.
/>
91.
<line
92.
y1="-1"
93.
y2="-0.65"
94.
stroke="white"
95.
strokeWidth="0.027"
96.
/>
97.
<circle
98.
cx={markerLocation[0]}
99.
cy={markerLocation[1]}
100.
r="0.2"
101.
stroke="#2c3e50"
102.
strokeWidth="0.01"
103.
fill={colorScale(percent)}
104.
/>
105.
<path
106.
d="M0.136364 0.0290102C0.158279 -0.0096701 0.219156 -0.00967009 0.241071 0.0290102C0.297078 0.120023 0.375 0.263367 0.375 0.324801C0.375 0.422639 0.292208 0.5 0.1875 0.5C0.0852272 0.5 -1.8346e-08 0.422639 -9.79274e-09 0.324801C0.00243506 0.263367 0.0803571 0.120023 0.136364 0.0290102ZM0.1875 0.381684C0.221591 0.381684 0.248377 0.356655 0.248377 0.324801C0.248377 0.292947 0.221591 0.267918 0.1875 0.267918C0.153409 0.267918 0.126623 0.292947 0.126623 0.324801C0.126623 0.356655 0.155844 0.381684 0.1875 0.381684Z"
107.
transform={`rotate(${
108.
angle * (180 / Math.PI)
109.
}) translate(-0.2, -0.33)`}
110.
fill="#6a6a85"
111.
/>
112.
</svg>
113.
114.
<div style={{
115.
marginTop: "0.4em",
116.
fontSize: "3em",
117.
lineHeight: "1em",
118.
fontWeight: "900",
119.
fontFeatureSettings: "'zero', 'tnum' 1",
120.
}}>
121.
{ format(",")(value) }
122.
</div>
123.
124.
{!!label && (
125.
<div style={{
126.
color: "#8b8ba7",
127.
marginTop: "0.6em",
128.
fontSize: "1.3em",
129.
lineHeight: "1.3em",
130.
fontWeight: "700",
131.
}}>
132.
{ label }
133.
</div>
134.
)}
135.
136.
{!!units && (
137.
<div style={{
138.
color: "#8b8ba7",
139.
lineHeight: "1.3em",
140.
fontWeight: "300",
141.
}}>
142.
{ units }
143.
</div>
144.
)}
145.
</div>
146.
)
147.
}
148.
149.
const getCoordsOnArc = (angle, offset=10) => [
150.
Math.cos(angle - (Math.PI / 2)) * offset,
151.
Math.sin(angle - (Math.PI / 2)) * offset,
152.
]
153.
50
label
units
value
50
min
0
max
100
label
units
  • 1.
    You might notice that we added special font-feature-settings to our displayed value. Turning on the "zero" flag gives any zero a slash through it, which is a nice distinction from a capital O.

    More importantly, we've turned on the "tnum" flag. This will ensure that each character takes up the same amount of space - wonderful for keeping our numbers from jumping around as the value changes.

    The "tnum" output is available as a feature to some fonts. We're using the wonderful Inter font, which has tons of fun fun features to play around with.
  • 2.
    We can use .format() from d3-format to add any necessary commas to our value. This makes the number a bit more human-friendly.
  • 3.
    Note that we can use a handy short-circuit boolean expression to optionally display our label or unit, if they are truthy.
  • 4.
    Since our value is the most important part of this gauge, let's use a lighter color for our secondary labels.



Happily ever after

We've reached the end of our long journey. And we should be proud of ourselves! We had some help along the way from our SVG element friends <circle>, <line>, and <path>, and overcame foes scary monsters of trigonometry, SVG gradients, and scaling data.

I hope you enjoyed our journey. You are a very fine person, reader, and I am very fond of you.

Home sweet home
40
Wind speed
meters per second
Update value
9
Visibility
kilometers
Update value
2,283
Atmospheric Pressure
hectopascals
Update value