A Semantic Approach to CSS Pie Charts

Pie charts have a bad reputation in some circles, but they remain a standard way to present part-to-whole relationships. The problem is that most CSS-only approaches to pie charts rely on conic-gradient(), which produces a decorative image rather than meaningful content. Screen readers see nothing but an empty element, and editing the chart means rewriting CSS rather than touching HTML.

The goal here is to build a pie chart that is semantic, customizable through HTML attributes, and requires as little JavaScript as possible. That means a screen reader should understand the data, and updating the chart should be as simple as changing a few data-* attributes.

Why conic-gradient() Falls Short

A single conic-gradient() can produce a perfectly divided circle with one line of CSS. This works beautifully for decorative purposes but fails on two counts. First, gradients are images, so they carry zero semantic meaning — that breaks accessibility goals. Second, the entire chart is drawn on a single element, meaning each slice’s percentage is trapped inside one long gradient string. There is no way to pull individual data-percentage values from the parent element and use them to style each slice separately.

The solution is not to abandon conic-gradient(), but to use it differently. Instead of one gradient for the whole chart, each list item draws its own slice with its own gradient.

Writing Semantic Markup

A good semantic structure wraps the chart in a <figure> with a <figcaption>, then lists each data point in an unordered list. Each <li> carries its percentage and color as data- attributes:

<figure>
  <figcaption>Candies sold last month</figcaption>
  <ul class="pie-chart">
    <li><strong>Chocolates</strong></li>
    <li><strong>Gummies</strong></li>
    <li><strong>Hard Candy</strong></li>
    <li><strong>Bubble Gum</strong></li>
  </ul>
</figure>

Screen readers will not announce the data-percentage attribute on its own, so a pseudo-element appends it visibly:

.pie-chart li::after {
  content: attr(data-percentage) "%";
}

Adding ARIA descriptions is unnecessary here. Since the legend must be visible anyway, the percentage would be read twice — once from the aria-description and again from the pseudo-element. The <ul> is valid inside <figure> because it counts as flow content, making this a self-contained, accessible diagram.

Drawing Each Slice

With the markup in place, each <li> needs dimensions based on a --radius variable rather than hardcoded lengths:

.pie-chart li {
  --radius: 20vmin;

  width: calc(var(--radius) * 2); /* radius twice = diameter */
  aspect-ratio: 1;
  border-radius: 50%;
}

The modern attr() syntax parses attributes as types other than strings, though browser support is currently limited to Chromium. Parsing data-percentage as a <number> yields a value like 15, which is then divided by 100 to get a decimal:

.pie-chart li {
  /* ... */
  --weighing: calc(attr(data-percentage type(<number>)) / 100);
}

That decimal gets converted to a percentage by multiplying by 1%:

.pie-chart li {
  /* ... */
  --percentage: calc(attr(data-percentage type(<number>)) * 1%);
}

The data-color attribute is parsed with the <color> type:

.pie-chart li {
  /* ... */
  --bg-color: attr(data-color type(<color>));
}

Each slice’s gradient goes from 0% to its own percentage, then becomes transparent:

.pie-chart li {
  /* ... */
   background: conic-gradient(
   var(--bg-color) 0% var(--percentage),
   transparent var(--percentage) 100%
  );
}

All slices start at the top and rotate clockwise, so they must each be rotated by the cumulative percentage of all preceding items. The CSS Cascade cannot share state between siblings or increment a running total, which leaves two options: hardcode an accumulator variable per item, or use JavaScript. Hardcoding breaks the HTML-customizability goal, so a small script calculates the --accum variable instead:

const pieChartItems = document.querySelectorAll(".pie-chart li");

let accum = 0;

pieChartItems.forEach((item) =>; {
  item.style.setProperty("--accum", accum);
  accum += parseFloat(item.getAttribute("data-percentage"));
});

Because conic-gradient() rotation uses the from syntax, which takes an angle rather than a percentage, the accumulator must be converted into an angle as --offset:

.pie-chart li {
  /* ... */
  --offset: calc(360deg * var(--accum) / 100);

  background: conic-gradient(
    from var(--offset),
    var(--bg-color) 0% var(--percentage),
    transparent var(--percentage) 100%
  );
}

Laying all items on top of each other is a job for CSS Grid. The container becomes a grid, and each slice is placed in the single center cell:

.pie-chart {
  display: grid;
  place-items: center;
}

.pie-chart li {
  /* ... */
  grid-row: 1;
  grid-column: 1;
}

With every slice properly rotated, the pie takes shape. The only remaining mess is the overlapping labels stacked at the container’s center.

Positioning Labels Around the Circle

The labels inside each <li> move to the container’s center using the same grid trick:

.pie-chart li {
  /* ... */
  display: grid;
  place-items: center;
}

.pie-chart li::after,
strong {
  grid-row: 1;
  grid-column: 1;
}

Positioning each label next to its slice requires the trigonometric functions cos() and sin(). Given an angle and a radius, these return X and Y coordinates on a circle. The angle, called --theta, is calculated from the slice data:

.pie-chart li {
  /* ... */
  --theta: calc((360deg * var(--weighing)) / 2 + var(--offset) - 90deg);
}

That formula finds the middle of each slice. It converts the percentage to an angle, halves it to reach the center point, adds the --offset for correct rotation, and subtracts 90 degrees to align with the way cos() and sin() measure angles versus where conic-gradient() starts.

The X and Y coordinates come from multiplying the radius by the cosine and sine of --theta:

.pie-chart li {
  /* ... */
  --pos-x: calc(cos(var(--theta)) * var(--radius));
  --pos-y: calc(sin(var(--theta)) * var(--radius));
}

A --gap variable keeps labels from overcrowding the chart edge:

.pie-chart li {
  /* ... */
  --gap: 4rem;
  --pos-x: calc(cos(var(--theta)) * (var(--radius) + var(--gap)));
  --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)));
}

Each label is translated by --pos-x and --pos-y to land alongside its slice:

.pie-chart li::after,
strong {
  /* ... */
  transform: translateX(var(--pos-x)) translateY(var(--pos-y));
}

The name and percentage within each label still stack on top of each other, so the percentage gets a slight extra translation on the Y-axis:

.pie-chart li::after {
  --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)) + 1lh);
}

What Remains

This implementation meets the core goals: markup is semantic, percentages live in HTML attributes, and JavaScript is limited to the one unavoidable calculation. Several enhancements could push it closer to ideal:

  • Accepting raw counts instead of precomputed percentages and letting the CSS calculate proportional weights.
  • Generating slice colors with color-mix() when a data-color attribute is absent.
  • Extending the same technique to bar charts or other chart types.
  • Adding hover effects, such as scaling a slice slightly to emphasize it.

Each of those deserves its own treatment, but the foundation here holds up well — a pie chart that reads like real data, edits like HTML, and draws itself without an entire charting library.