myproductivetools

Another Stab at the Perfect CSS Pie Chart… Sans JavaScript!

How to Build a Pure CSS Pie Chart Without JavaScript

Pie charts have always been a tricky challenge for front-end developers. Getting them to look right, be accessible, and remain easy to customize usually meant reaching for a JavaScript library. But what if you could build one entirely in CSS?

That question has been explored in depth by developers pushing the boundaries of what CSS can do — and the results are impressive. In this post, we break down a compelling CSS-only pie chart approach that eliminates JavaScript entirely, while keeping the markup semantic, accessible, and easy to customize.

Why Build a CSS Pie Chart Without JavaScript?

JavaScript is a powerful tool, but it comes with overhead — load time, execution cost, and added complexity. More philosophically, there’s a clean separation of concerns argument to be made:

  • JavaScript is for handling logic and state.
  • CSS is for styling and presentation.

If CSS is powerful enough to render a pie chart without any scripting, then that’s not just a fun trick — it’s architecturally sound. It also means better performance and fewer dependencies.

The Three Core Goals

Any well-designed CSS pie chart should meet three key criteria:

  1. Semantic markup — Screen readers must be able to understand the data presented.
  2. HTML-customizable — Once the CSS is written, only the HTML should need changing to update the chart.
  3. JavaScript-free — No scripts required, not even minimal ones.

The original challenge was that pie chart slices need to know their starting position, which depends on the cumulative value of all previous slices. Previously, a small JavaScript loop handled this calculation. The goal was to remove that loop entirely.

The Original Approach and Its JavaScript Dependency

The earlier CSS pie chart implementation used a small JavaScript snippet to loop through each list item and set an --accum CSS custom property, representing the cumulative percentage of all previous slices:

const pieChartItems = document.querySelectorAll(".pie-chart li");
let accum = 0;
pieChartItems.forEach((item) => {
  item.style.setProperty("--accum", accum);
  accum += parseFloat(item.getAttribute("data-percentage"));
});

Without this, CSS had no way for a child element to know the state of its siblings. That’s a fundamental limitation of how CSS inheritance works — children can’t query their sibling elements directly.

The Key Insight: Move Data to the Parent

The solution is elegant: instead of placing percentage values on individual list items, move them all to the parent element and index them.

Before (original markup):

<ul class="pie-chart">
  <li data-percentage-1="10">Apple</li>
  <li data-percentage-2="30">Banana</li>
  <li data-percentage-3="20">Orange</li>
  <li data-percentage-4="40">Strawberry</li>
</ul>

After (JavaScript-free markup):

<ul class="pie-chart" data-percentage-1="10" data-percentage-2="30" data-percentage-3="20" data-percentage-4="40">
  <li>Apple</li>
  <li>Banana</li>
  <li>Orange</li>
  <li>Strawberry</li>
</ul>

By moving all the data attributes to the parent <ul>, the parent can now act as the “surrounding entity” that CSS needs to compute and distribute values to each child slice.

You can optionally add data-label attributes to visually pair labels and values:

<ul class="pie-chart" data-percentage-1="10" data-percentage-2="30" data-percentage-3="20" data-percentage-4="40">
  <li data-label-1>Apple</li>
  <li data-label-2>Banana</li>
  <li data-label-3>Orange</li>
  <li data-label-4>Strawberry</li>
</ul>

How the CSS Works

The CSS implementation requires two sets of rules working in tandem.

Step 1: Pass Percentages Down to Each Slice

Using the upgraded attr() function with type hints and :nth-child() selectors, each percentage is read from the parent and assigned to its corresponding child as a local CSS variable:

.pie-chart {
  --p-100-1: attr(data-percentage-1 type(<number>)); :nth-child(1) { --p-100: var(--p-100-1) }
  --p-100-2: attr(data-percentage-2 type(<number>)); :nth-child(2) { --p-100: var(--p-100-2) }
  --p-100-3: attr(data-percentage-3 type(<number>)); :nth-child(3) { --p-100: var(--p-100-3) }
  --p-100-4: attr(data-percentage-4 type(<number>)); :nth-child(4) { --p-100: var(--p-100-4) }
}

This gives each slice access to its own percentage value via the --p-100 variable, while the parent retains indexed versions like --p-100-1, --p-100-2, etc.

Step 2: Compute the Cumulative Offset for Each Slice

Next, the CSS calculates the accumulated offset for each slice — that is, the sum of all previous slices’ values. This is what positions each slice correctly around the pie:

.pie-chart {
  --accum-1: 0;                                     :nth-child(1) { --accum: var(--accum-1) }
  --accum-2: calc(var(--accum-1) + var(--p-100-1)); :nth-child(2) { --accum: var(--accum-2) }
  --accum-3: calc(var(--accum-2) + var(--p-100-2)); :nth-child(3) { --accum: var(--accum-3) }
  --accum-4: calc(var(--accum-3) + var(--p-100-3)); :nth-child(4) { --accum: var(--accum-4) }
}

The first slice always starts at 0. Each subsequent slice accumulates the sum of all previous percentages. These values are then passed into each child via :nth-child().

The result? Every slice knows exactly where it starts — no JavaScript required.

Automatic Color Generation

One of the nicest enhancements in this approach is automatic color generation. If no data-color attribute is provided, colors are generated automatically using HSL values based on the slice’s position:

.pie-chart li {
  --color: attr(data-color type(<color>));
  --bg-color: var(--color, hsl(calc(360deg * sibling-index() / sibling-count()) 90% 40%));
}

This uses the upcoming sibling-index() and sibling-count() CSS functions, which are becoming Baseline soon. For browsers that don’t yet support them, a CSS-only polyfill using :has() and :nth-child() can replicate the behavior:

.pie-chart {
  :has(:nth-child(1)) { --sibling-count: 1 } :nth-child(1) { --sibling-index: 1; }
  :has(:nth-child(2)) { --sibling-count: 2 } :nth-child(2) { --sibling-index: 2; }
  :has(:nth-child(3)) { --sibling-count: 3 } :nth-child(3) { --sibling-index: 3; }
  :has(:nth-child(4)) { --sibling-count: 4 } :nth-child(4) { --sibling-index: 4; }
}

Colors can also be defined on either the parent or the individual children, giving the developer full flexibility.

Accessibility Considerations

The semantic structure is preserved throughout. Labels are plain HTML text inside list items, which screen readers can interpret naturally. Percentage values are surfaced using counter-reset and counter() for the content property — a technique that remains screen reader-friendly.

The approach also raises interesting questions about element choice. Could <label> and <meter> elements be used for labels and values? Could a <table> structure serve as the base markup, since chart data often originates from tabular data? These are worthwhile avenues to explore in future iterations.

Extending to Other Chart Types

The foundation built for the pie chart is flexible enough to support other chart types. As a proof of concept, the same CSS architecture has been extended to support a bar chart mode — all without adding any JavaScript. The shared variable system, the parent-level data attributes, and the :nth-child() distribution logic all carry over naturally.

This hints at a broader possibility: a unified, JavaScript-free charting system powered entirely by CSS and semantic HTML.

Could This Become a Web Component?

In many ways, this implementation already behaves like a web component — just one that uses light DOM and requires no JavaScript. A custom element like <pie-chart data-percentage-1="10" ...> is conceptually not far removed from <div class="pie-chart" data-percentage-1="10" ...>.

Adding JavaScript to enable features like live data fetching or automatic refresh would make a compelling progressive enhancement — but that’s a separate project for another day.

Why This Matters for Modern CSS Development

This approach showcases how rapidly CSS is evolving. Features like typed attr(), :has(), sibling-index(), and potentially @function are moving CSS toward a more expressive and logic-capable language.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top