Donut Charts Without a Chart Library
Donut and pie charts are a common dashboard staple, but pulling in a full charting library for a simple visualization often feels like overkill. There is a lighter path: CSS conic-gradient() can draw the segments, and a bit of JavaScript math converts API data into the angles CSS needs. No extra packages, no heavy runtime.
The core trick is compact. A conic-gradient() renders colored stops at degree positions:
div {
background: conic-gradient(red 36deg, orange 36deg 170deg, yellow 170deg);
border-radius: 50%;
}
That single CSS property produces circular segments the same way a background image would. The challenge is that real API responses do not arrive pre-measured in degrees.
Converting Data Into Degrees
Consider a typical response array where each item has a name and a value:
const data = [
{
name: 'Cluster 1',
value: 210,
},
{
name: 'Cluster 2',
value: 30,
},
{
name: 'Cluster 3',
value: 180,
},
{
name: 'Cluster 4',
value: 260,
},
{
name: 'Cluster 5',
value: 60,
},
].sort((a, b) => a.value - b.value);
To feed that into conic-gradient(), each value must become an angle. The math is straightforward: sum all values, compute each value's percentage of the total, then map that percentage onto a 360-degree circle.
The total comes from a reduce() one-liner:
const total_value = data.reduce((a, b) => a + b.value, 0);
// => 740
Percentages follow:
const convertToPercent = (num) => Math.round((num / total_value) * 100);
// convertToPercent(210) => 28
And degrees follow from percentages:
const convertToDegrees = (num) => Math.round((num / 100) * 360);
// convertToDegrees(28) => 101
The tricky part is that conic-gradient() needs both a start and an end angle per segment. The individual computed degrees are only the segment spans; the actual start of a segment is the cumulative end of all previous segments. The first segment starts at 0.
A single function handles the accumulation. It walks the sorted data with reduce(), tracks the running sum, and builds up the gradient string:
const total_value = data.reduce((a, b) => a + b.value, 0);
const convertToPercent = (num) => Math.round((num / total_value) * 100);
const convertToDegrees = (num) => Math.round((num / 100) * 360);
const css_string = data
.reduce((items, item, index, array) => {
items.push(item);
item.count = item.count || 0;
item.count += array[index - 1]?.count || item.count;
item.start_value = array[index - 1]?.count ? array[index - 1].count : 0;
item.end_value = item.count += item.value;
item.start_percent = convertToPercent(item.start_value);
item.end_percent = convertToPercent(item.end_value);
item.start_degrees = convertToDegrees(item.start_percent);
item.end_degrees = convertToDegrees(item.end_percent);
return items;
}, [])
.map((chart) => {
const { color, start_degrees, end_degrees } = chart;
return ` ${color} ${start_degrees}deg ${end_degrees}deg`;
})
.join();
Verbose by design, this makes it easy to trace logic with console.log(). The chained map() appends deg to each value, and the final join() produces one CSS-ready string:
"#..." 0deg 14deg,
"#..." 14deg 43deg,
"#..." 43deg 130deg,
"#..." 130deg 234deg,
"#..." 234deg 360deg
Rendering With SVG foreignObject
SVG itself does not understand CSS conic-gradient(). The workaround is to wrap an HTML element inside an SVG foreignObject, where regular CSS backgrounds apply normally:
<svg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg' style={{ borderRadius: '100%' }}>
<foreignObject x='0' y='0' width='100' height='100'>
<div
xmlns='http://www.w3.org/1999/xhtml'
style={{
width: '100%',
height: '100%',
background: `conic-gradient(${css_string})`, // <- 🥳
}}
/>
</foreignObject>
</svg>
That produces a filled pie. To turn it into a donut, two approaches are possible. The cleaner method uses an SVG clipPath to punch out the center hole:
<svg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg' style={{ borderRadius: '100%' }}>
<clipPath id='hole'>
<path d='M 50 0 a 50 50 0 0 1 0 100 50 50 0 0 1 0 -100 v 18 a 2 2 0 0 0 0 64 2 2 0 0 0 0 -64' />
</clipPath>
<foreignObject x='0' y='0' width='100' height='100' clipPath='url(#hole)'>
<div
xmlns='http://www.w3.org/1999/xhtml'
style={{
width: '100%',
height: '100%',
background: `conic-gradient(${css_string})`
}}
/>
</foreignObject>
</svg>
Alternatively, place a plain <circle /> element at the pie's center. This works well only if the circle's fill matches the page background. On patterned backgrounds, the center will visibly block the pattern, unlike the clipPath approach, which leaves everything behind it visible. The clipPath is the better choice, though its path points are harder to adjust by hand without a design tool.
Colors From Tailwind and APIs
Colors introduce a separate wiring problem. The chart logic runs in JavaScript, but Tailwind colors live in CSS as variables. One workaround is exposing those CSS variables so JavaScript can reference them by name.
This can be done by adding a color key to each data item:
data={[
{
name: 'Cluster 1',
value: 210,
color: 'var(--color-fuchsia-400)',
},
{
name: 'Cluster 2',
value: 30,
color: 'var(--color-fuchsia-100)',
},
{
name: 'Cluster 3',
value: 180,
color: 'var(--color-fuchsia-300)',
},
{
name: 'Cluster 4',
value: 260,
color: 'var(--color-fuchsia-500)',
},
{
name: 'Cluster 5',
value: 60,
color: 'var(--color-fuchsia-200)',
},
].sort((a, b) => a.value - b.value)
The map() that builds the gradient string then picks up that color value and includes it in the final string:
.map((chart) => {
const { color, start_degrees, end_degrees } = chart;
return ` ${color} ${start_degrees}deg ${end_degrees}deg`;
})
.join();
Alternatively, build a color name dynamically from a hard-coded prefix and an array index:
.map((chart, index) => {
const { start_degrees, end_degrees } = chart;
return ` var(--color-pink-${(index + 1) * 100}) ${start_degrees}deg ${end_degrees}deg`;
})
.join();
Some APIs make this unnecessary. The GitHub GraphQL API, for example, returns language colors alongside language names. Feeding that data straight into one of the prepared donut components requires no color mapping at all.
A Lighter Alternative
A charting library is arguably more convenient when the data is deeply dynamic or the rendering requirements are complex. For a single donut, the library's download and maintenance cost are hard to justify. The conic-gradient() method keeps the page light, relies on built-in CSS behavior, and works with any frontend framework once the data is converted to angles.



