So you want to build a chart in your React app? If you're like me, the first thing you did was search for something like "react charting library".
There are lots of great lists out there debating the pros and cons of different options like recharts and victory. A lot of them are built on top of D3. These libraries can get you up and running really quickly with powerful features, and for many use cases will be the right choice.
But here I want to show you how quickly we can build our own charts without any of these libraries. Since we already have React to manage the DOM, we'll just be using SVG to render our charts.
Why are we doing this?
- Every new library you use requires learning how it works. But SVG is built into the browser - it's not going anywhere or being updated any time soon!
- You can build your chart to your exact design requirements - no need to worry about whether the library supports what you want to do or not.
- Save your precious kB! Sure, building your custom chart may require more lines of code in your app than using a library, but the total bundle size should be much smaller that way.
To go further with saving kB, the examples below all work with Preact too.
If you want to follow along, I'm starting with create-react-app using the TypeScript template:
npx create-react-app my-chart --template typescript
I recommend quickly adding Prettier for formatting too.
Chart Axes
In the diagram of the chart axes we're about to build below, I've added some variables to help visualise. We put y0
at the top since in SVG the y axis goes from top to bottom. You kind of have to look at everything upside down.
Open App.tsx
and replace it with the following (I'll explain the code in more detail later on):
import React from "react";
import "./App.css";
const SVG_WIDTH = 400;
const SVG_HEIGHT = 300;
function App() {
const x0 = 50;
const xAxisLength = SVG_WIDTH - x0 * 2;
const y0 = 50;
const yAxisLength = SVG_HEIGHT - y0 * 2;
const xAxisY = y0 + yAxisLength;
return (
<svg width={SVG_WIDTH} height={SVG_HEIGHT}>
{/* X axis */}
<line
x1={x0}
y1={xAxisY}
x2={x0 + xAxisLength}
y2={xAxisY}
stroke="grey"
/>
<text x={x0 + xAxisLength + 5} y={xAxisY + 4}>
x
</text>
{/* Y axis */}
<line x1={x0} y1={y0} x2={x0} y2={y0 + yAxisLength} stroke="grey" />
<text x={x0} y={y0 - 8} textAnchor="middle">
y
</text>
</svg>
);
}
export default App;
And with that, we have an x and y axis!
To start with, we must put everything in the svg
element:
<svg width={SVG_WIDTH} height={SVG_HEIGHT}>
...
</svg>
You'll notice all SVG elements work just the same as other DOM elements like div
and p
in React. For svg
make sure to pass in a width and height.
Here I'm hardcoding the width and height, but for a responsive view this could be a prop in your component. There's a good example of measuring the width of the parent element in the React Hooks FAQ.
Next is the line
element:
<line
x1={x0}
y1={xAxisY}
x2={x0 + xAxisLength}
y2={xAxisY}
stroke="grey"
/>
It's rather simple: draw a line from a point (x1, y1)
to (x2, y2)
. At a minimum, we also need a stroke
colour. If you read the docs you'll find far more ways to customise it, such as getting a dashed line or changing the thickness. But a thin solid line (the default) is good enough for now.
Lastly we have the text
element:
<text x={x0 + xAxisLength + 5} y={xAxisY + 4}>
x
</text>
As well as its content, it just needs an x
and y
coordinate. You'll notice for the y
text
I also included the textAnchor="middle"
attribute to center the text.
Bar chart
To produce this chart, replace your code with the following:
import React from "react";
import "./App.css";
const SVG_WIDTH = 400;
const SVG_HEIGHT = 300;
const data: [string, number][] = [
["Mon", 12],
["Tue", 14],
["Wed", 12],
["Thu", 4],
["Fri", 5],
["Sat", 18],
["Sun", 0],
];
function App() {
const x0 = 50;
const xAxisLength = SVG_WIDTH - x0 * 2;
const y0 = 50;
const yAxisLength = SVG_HEIGHT - y0 * 2;
const xAxisY = y0 + yAxisLength;
const dataYMax = data.reduce(
(currMax, [_, dataY]) => Math.max(currMax, dataY),
-Infinity
);
const dataYMin = data.reduce(
(currMin, [_, dataY]) => Math.min(currMin, dataY),
Infinity
);
const dataYRange = dataYMax - dataYMin;
const numYTicks = 5;
const barPlotWidth = xAxisLength / data.length;
return (
<svg width={SVG_WIDTH} height={SVG_HEIGHT}>
{/* X axis */}
<line
x1={x0}
y1={xAxisY}
x2={x0 + xAxisLength}
y2={xAxisY}
stroke="grey"
/>
<text x={x0 + xAxisLength + 5} y={xAxisY + 4}>
Day
</text>
{/* Y axis */}
<line x1={x0} y1={y0} x2={x0} y2={y0 + yAxisLength} stroke="grey" />
{Array.from({ length: numYTicks }).map((_, index) => {
const y = y0 + index * (yAxisLength / numYTicks);
const yValue = Math.round(dataYMax - index * (dataYRange / numYTicks));
return (
<g key={index}>
<line x1={x0} y1={y} x2={x0 - 5} y2={y} stroke="grey" />
<text x={x0 - 5} y={y + 5} textAnchor="end">
{yValue}
</text>
</g>
);
})}
<text x={x0} y={y0 - 8} textAnchor="middle">
$
</text>
{/* Bar plots */}
{data.map(([day, dataY], index) => {
const x = x0 + index * barPlotWidth;
const yRatio = (dataY - dataYMin) / dataYRange;
const y = y0 + (1 - yRatio) * yAxisLength;
const height = yRatio * yAxisLength;
const sidePadding = 10;
return (
<g key={index}>
<rect
x={x + sidePadding / 2}
y={y}
width={barPlotWidth - sidePadding}
height={height}
/>
<text x={x + barPlotWidth / 2} y={xAxisY + 16} textAnchor="middle">
{day}
</text>
</g>
);
})}
</svg>
);
}
export default App;
Let's break this down. At the top we have some mock data
, with a value (I'm going to say $) for each day of the week. Then we need to calculate some values based on the data for plotting:
const dataYMax = data.reduce(
(currMax, [_, dataY]) => Math.max(currMax, dataY),
-Infinity
);
const dataYMin = data.reduce(
(currMin, [_, dataY]) => Math.min(currMin, dataY),
Infinity
);
const dataYRange = dataYMax - dataYMin;
const numYTicks = 5;
const barPlotWidth = xAxisLength / data.length;
For dataYMax
and dataYMin
we need to iterate through the data to calculate the values. This would certainly be a good place to add useMemo
and extract into a utility function. Below that we calculate some more values we'll need for our chart.
For the y axis I've added some ticks along the axis:
{/* Y axis */}
<line x1={x0} y1={y0} x2={x0} y2={y0 + yAxisLength} stroke="grey" />
{Array.from({ length: numYTicks }).map((_, index) => {
const y = y0 + index * (yAxisLength / numYTicks);
const yValue = Math.round(dataYMax - index * (dataYRange / numYTicks));
return (
<g key={index}>
<line x1={x0} y1={y} x2={x0 - 5} y2={y} stroke="grey" />
<text x={x0 - 5} y={y + 5} textAnchor="end">
{yValue}
</text>
</g>
);
})}
<text x={x0} y={y0 - 8} textAnchor="middle">
$
</text>
Try to think through all of the coordinates in your head to understand how the values of y
and yValue
are determined, and see if the diagram below helps. You need to keep in mind that the y axis in our plot is bottom to top, but we are plotting the line
's y
points in a top to bottom system.
The only new element here is g
, which is used to group SVG elements. It's also handy for adding a key
.
Lastly we have the bar plots themselves:
{/* Bar plots */}
{data.map(([day, dataY], index) => {
const x = x0 + index * barPlotWidth;
const yRatio = (dataY - dataYMin) / dataYRange;
const y = y0 + (1 - yRatio) * yAxisLength;
const height = yRatio * yAxisLength;
const sidePadding = 10;
return (
<g key={index}>
<rect
x={x + sidePadding / 2}
y={y}
width={barPlotWidth - sidePadding}
height={height}
/>
<text x={x + barPlotWidth / 2} y={xAxisY + 16} textAnchor="middle">
{day}
</text>
</g>
);
})}
The maths to calculate the right values does get a bit tricky, try working your way through it. I've also added some manual padding between the bars.
Mike Bostock actually has a good post on why D3 is still useful for these kind of calculations. I don't disagree, though personally I would prefer to write the utility functions myself, rather than bringing in more dependencies. But it's really up to you.
We're using the rect
element here, which produces a rectangle, and whose properties are conveniently self explanatory. The only thing to keep in mind is the x
and y
coordinates refer to the top-left corner of the rectangle.
Styling
Open up App.css
, and replace the contents with the following to adjust the colours and text size:
text {
font-size: .7em;
fill: grey;
}
rect {
fill: blue;
}
Here I'm using element selectors, but you can add class names to SVG elements exactly the same way you would do to a div
. The biggest difference to note is we use fill
to change the colour.
What's next?
At this point you may be concerned that you have 100 lines of code to produce a, lets be honest, fairly ugly bar chart. But using just line
, text
and rect
we can already go a long way! Other useful elements to check out are circle
and path
. These building blocks are enough to produce the most vivid and captivating charts you can imagine.
SVG elements can be animated with CSS just like any other element. In React they also work great in that you can add onClick
or onPointerOver
attributes to make them interactive. They'll work exactly as you expect.
Tip: sometimes your hover won't work on an element if other elements are laid on top of it. In this case, using an invisible element as the last element can get around this.
Sure, if you just need to plot some data in a generic way then reach for a library. But if you have a specific design in mind (or someone else designed in a mockup), using SVG directly enables you to build exactly what you want, without any compromises.
Quick ref
Here's a quick reference covering the SVG elements you need:
Top comments (3)
This is the second "Make your own chart in React" demo I've seen, and both use bar charts. I need line graphs more often; it strikes me that building a line graph (particularly with bezier curves between the points) would be much more challenging to do using React/SVG; or am I mistaken? (I'm not familiar at all with using SVG.)
I very much like this approach and thanks from the very practical explanations! :)
This definitely takes longer time to develop than using a library for most use cases but if the need is very specific it may be easier to build from the scratch than trying to figure out how to the library handles it.
const yRatio = (dataY - dataYMin) / dataYRange;
this will always return 0 for the data with the minimum