26 CSS Circular Menus12 / 26
Pie Chart Navigation Menu
A pie chart that is the menu: four budget categories drawn as proportional slices with a single conic-gradient, plus an invisible hit layer of real buttons over each slice. Click a slice and it 'lifts' toward you while the detail panel updates. Data visualization and navigation in one element — a genuinely different take on the category picker.
Published Updated
The code
<section class="ccm-12" aria-label="Pie chart navigation demo">
<div class="ccm-12__stage">
<div class="ccm-12__duo">
<div class="ccm-12__pie" role="group" aria-label="Budget categories">
<div class="ccm-12__disc" aria-hidden="true"></div>
<div class="ccm-12__hits"></div>
</div>
<aside class="ccm-12__panel" aria-live="polite">
<em class="ccm-12__cat">Housing</em>
<strong class="ccm-12__amt">$1,824<span>/mo</span></strong>
<p class="ccm-12__share">38% of monthly budget</p>
<div class="ccm-12__bar"><i style="scale:0.38 1"></i></div>
</aside>
</div>
</div>
</section><section class="ccm-12" aria-label="Pie chart navigation demo">
<div class="ccm-12__stage">
<div class="ccm-12__duo">
<div class="ccm-12__pie" role="group" aria-label="Budget categories">
<div class="ccm-12__disc" aria-hidden="true"></div>
<div class="ccm-12__hits"></div>
</div>
<aside class="ccm-12__panel" aria-live="polite">
<em class="ccm-12__cat">Housing</em>
<strong class="ccm-12__amt">$1,824<span>/mo</span></strong>
<p class="ccm-12__share">38% of monthly budget</p>
<div class="ccm-12__bar"><i style="scale:0.38 1"></i></div>
</aside>
</div>
</div>
</section>.ccm-12,
.ccm-12 *,
.ccm-12 *::before,
.ccm-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ccm-12 {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: oklch(0.97 0.006 265);
--c1: oklch(0.62 0.17 265);
--c2: oklch(0.72 0.15 195);
--c3: oklch(0.78 0.14 85);
--c4: oklch(0.68 0.17 350);
--lift: 10px;
font-family: 'Segoe UI',system-ui,sans-serif;
color: oklch(0.25 0.02 265);
}
.ccm-12__stage {
width: 100%;
height: 100vh;
display: grid;
place-items: center;
border-radius: 20px;
background: oklch(0.97 0.006 265);
border: 1px solid oklch(0.9 0.01 265);
}
.ccm-12__duo {
display: flex;
align-items: center;
gap: 44px;
flex-wrap: wrap;
justify-content: center;
padding: 30px;
}
.ccm-12__pie {
position: relative;
width: 270px;
height: 270px;
}
.ccm-12__disc {
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow: 0 24px 50px -24px oklch(0.4 0.08 265 / .5);
}
.ccm-12__hits {
position: absolute;
inset: 0;
}
.ccm-12__hits button {
position: absolute;
inset: 0;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
transition: translate .3s cubic-bezier(.34,1.3,.4,1),filter .3s;
}
.ccm-12__hits button:focus-visible {
outline: 3px solid oklch(0.5 0.1 265);
outline-offset: 4px;
}
.ccm-12__hits button.is-active {
filter: drop-shadow(0 8px 16px oklch(0.4 0.08 265 / .45));
}
.ccm-12__panel {
display: grid;
gap: 6px;
min-width: 220px;
padding: 24px 26px;
border-radius: 18px;
background: #fff;
border: 1px solid oklch(0.9 0.01 265);
box-shadow: 0 18px 44px -26px oklch(0.4 0.08 265 / .5);
}
.ccm-12__cat {
font-style: normal;
font-size: 12px;
letter-spacing: .14em;
text-transform: uppercase;
color: oklch(0.55 0.04 265);
}
.ccm-12__amt {
font-size: 34px;
letter-spacing: -.02em;
}
.ccm-12__amt span {
font-size: 15px;
font-weight: 500;
color: oklch(0.55 0.04 265);
}
.ccm-12__share {
font-size: 13.5px;
color: oklch(0.45 0.03 265);
}
.ccm-12__bar {
height: 8px;
border-radius: 4px;
background: oklch(0.93 0.01 265);
overflow: hidden;
margin-top: 6px;
}
.ccm-12__bar i {
display: block;
height: 100%;
border-radius: 4px;
background: var(--seg,var(--c1));
transform-origin: left;
transition: scale .5s cubic-bezier(.3,1,.35,1),background .3s;
}
@media (prefers-reduced-motion: reduce) {
.ccm-12 * {
transition-duration: .01s !important;
}
}.ccm-12,
.ccm-12 *,
.ccm-12 *::before,
.ccm-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ccm-12 {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: oklch(0.97 0.006 265);
--c1: oklch(0.62 0.17 265);
--c2: oklch(0.72 0.15 195);
--c3: oklch(0.78 0.14 85);
--c4: oklch(0.68 0.17 350);
--lift: 10px;
font-family: 'Segoe UI',system-ui,sans-serif;
color: oklch(0.25 0.02 265);
}
.ccm-12__stage {
width: 100%;
height: 100vh;
display: grid;
place-items: center;
border-radius: 20px;
background: oklch(0.97 0.006 265);
border: 1px solid oklch(0.9 0.01 265);
}
.ccm-12__duo {
display: flex;
align-items: center;
gap: 44px;
flex-wrap: wrap;
justify-content: center;
padding: 30px;
}
.ccm-12__pie {
position: relative;
width: 270px;
height: 270px;
}
.ccm-12__disc {
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow: 0 24px 50px -24px oklch(0.4 0.08 265 / .5);
}
.ccm-12__hits {
position: absolute;
inset: 0;
}
.ccm-12__hits button {
position: absolute;
inset: 0;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
transition: translate .3s cubic-bezier(.34,1.3,.4,1),filter .3s;
}
.ccm-12__hits button:focus-visible {
outline: 3px solid oklch(0.5 0.1 265);
outline-offset: 4px;
}
.ccm-12__hits button.is-active {
filter: drop-shadow(0 8px 16px oklch(0.4 0.08 265 / .45));
}
.ccm-12__panel {
display: grid;
gap: 6px;
min-width: 220px;
padding: 24px 26px;
border-radius: 18px;
background: #fff;
border: 1px solid oklch(0.9 0.01 265);
box-shadow: 0 18px 44px -26px oklch(0.4 0.08 265 / .5);
}
.ccm-12__cat {
font-style: normal;
font-size: 12px;
letter-spacing: .14em;
text-transform: uppercase;
color: oklch(0.55 0.04 265);
}
.ccm-12__amt {
font-size: 34px;
letter-spacing: -.02em;
}
.ccm-12__amt span {
font-size: 15px;
font-weight: 500;
color: oklch(0.55 0.04 265);
}
.ccm-12__share {
font-size: 13.5px;
color: oklch(0.45 0.03 265);
}
.ccm-12__bar {
height: 8px;
border-radius: 4px;
background: oklch(0.93 0.01 265);
overflow: hidden;
margin-top: 6px;
}
.ccm-12__bar i {
display: block;
height: 100%;
border-radius: 4px;
background: var(--seg,var(--c1));
transform-origin: left;
transition: scale .5s cubic-bezier(.3,1,.35,1),background .3s;
}
@media (prefers-reduced-motion: reduce) {
.ccm-12 * {
transition-duration: .01s !important;
}
}(() => {
const data = [
{ label: 'Housing', amt: '$1,824', pct: 38, color: 'var(--c1)' },
{ label: 'Food & dining', amt: '$1,296', pct: 27, color: 'var(--c2)' },
{ label: 'Transport', amt: '$1,056', pct: 22, color: 'var(--c3)' },
{ label: 'Savings', amt: '$624', pct: 13, color: 'var(--c4)' },
];
const root = document.querySelector('.ccm-12');
const disc = root.querySelector('.ccm-12__disc');
const hits = root.querySelector('.ccm-12__hits');
const panel = {
cat: root.querySelector('.ccm-12__cat'), amt: root.querySelector('.ccm-12__amt'),
share: root.querySelector('.ccm-12__share'), bar: root.querySelector('.ccm-12__bar i'),
};
// 1) paint the pie from the data
let acc = 0;
const stops = data.map((d) => {
const s = d.color + ' ' + acc + '% ' + (acc + d.pct) + '%'; acc += d.pct; return s;
});
disc.style.background = 'conic-gradient(' + stops.join(',') + ')';
// 2) build one clipped hit button per slice from the same numbers
const P = 40; // polygon resolution per slice
acc = 0;
data.forEach((d, idx) => {
const a0 = acc / 100 * 360 - 90, a1 = (acc + d.pct) / 100 * 360 - 90; acc += d.pct;
const pts = ['50% 50%'];
for (let k = 0; k <= P; k++) {
const a = (a0 + (a1 - a0) * k / P) * Math.PI / 180;
pts.push((50 + 50 * Math.cos(a)).toFixed(2) + '% ' + (50 + 50 * Math.sin(a)).toFixed(2) + '%');
}
const b = document.createElement('button');
b.type = 'button';
b.setAttribute('aria-label', d.label + ', ' + d.pct + ' percent of budget');
b.style.clipPath = 'polygon(' + pts.join(',') + ')';
const mid = ((a0 + a1) / 2) * Math.PI / 180;
b.addEventListener('click', () => {
hits.querySelectorAll('button').forEach((x) => { x.classList.remove('is-active'); x.style.translate = '0 0'; });
b.classList.add('is-active');
b.style.translate = (Math.cos(mid) * 10) + 'px ' + (Math.sin(mid) * 10) + 'px';
panel.cat.textContent = d.label;
panel.amt.innerHTML = d.amt + '<span>/mo</span>';
panel.share.textContent = d.pct + '% of monthly budget';
panel.bar.style.scale = (d.pct / 100) + ' 1';
panel.bar.style.background = d.color;
});
hits.append(b);
});
})();(() => {
const data = [
{ label: 'Housing', amt: '$1,824', pct: 38, color: 'var(--c1)' },
{ label: 'Food & dining', amt: '$1,296', pct: 27, color: 'var(--c2)' },
{ label: 'Transport', amt: '$1,056', pct: 22, color: 'var(--c3)' },
{ label: 'Savings', amt: '$624', pct: 13, color: 'var(--c4)' },
];
const root = document.querySelector('.ccm-12');
const disc = root.querySelector('.ccm-12__disc');
const hits = root.querySelector('.ccm-12__hits');
const panel = {
cat: root.querySelector('.ccm-12__cat'), amt: root.querySelector('.ccm-12__amt'),
share: root.querySelector('.ccm-12__share'), bar: root.querySelector('.ccm-12__bar i'),
};
// 1) paint the pie from the data
let acc = 0;
const stops = data.map((d) => {
const s = d.color + ' ' + acc + '% ' + (acc + d.pct) + '%'; acc += d.pct; return s;
});
disc.style.background = 'conic-gradient(' + stops.join(',') + ')';
// 2) build one clipped hit button per slice from the same numbers
const P = 40; // polygon resolution per slice
acc = 0;
data.forEach((d, idx) => {
const a0 = acc / 100 * 360 - 90, a1 = (acc + d.pct) / 100 * 360 - 90; acc += d.pct;
const pts = ['50% 50%'];
for (let k = 0; k <= P; k++) {
const a = (a0 + (a1 - a0) * k / P) * Math.PI / 180;
pts.push((50 + 50 * Math.cos(a)).toFixed(2) + '% ' + (50 + 50 * Math.sin(a)).toFixed(2) + '%');
}
const b = document.createElement('button');
b.type = 'button';
b.setAttribute('aria-label', d.label + ', ' + d.pct + ' percent of budget');
b.style.clipPath = 'polygon(' + pts.join(',') + ')';
const mid = ((a0 + a1) / 2) * Math.PI / 180;
b.addEventListener('click', () => {
hits.querySelectorAll('button').forEach((x) => { x.classList.remove('is-active'); x.style.translate = '0 0'; });
b.classList.add('is-active');
b.style.translate = (Math.cos(mid) * 10) + 'px ' + (Math.sin(mid) * 10) + 'px';
panel.cat.textContent = d.label;
panel.amt.innerHTML = d.amt + '<span>/mo</span>';
panel.share.textContent = d.pct + '% of monthly budget';
panel.bar.style.scale = (d.pct / 100) + ' 1';
panel.bar.style.background = d.color;
});
hits.append(b);
});
})();Here's a working CSS Circular Menu from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Pie Chart Navigation Menu
Source: https://codefronts.com/navigation/css-circular-menus/pie-slice-selector/
A pie chart that is the menu: four budget categories drawn as proportional slices with a single conic-gradient, plus an invisible hit layer of real buttons over each slice. Click a slice and it 'lifts' toward you while the detail panel updates. Data visualization and navigation in one element — a genuinely different take on the category picker.
## HTML
```html
<section class="ccm-12" aria-label="Pie chart navigation demo">
<div class="ccm-12__stage">
<div class="ccm-12__duo">
<div class="ccm-12__pie" role="group" aria-label="Budget categories">
<div class="ccm-12__disc" aria-hidden="true"></div>
<div class="ccm-12__hits"></div>
</div>
<aside class="ccm-12__panel" aria-live="polite">
<em class="ccm-12__cat">Housing</em>
<strong class="ccm-12__amt">$1,824<span>/mo</span></strong>
<p class="ccm-12__share">38% of monthly budget</p>
<div class="ccm-12__bar"><i style="scale:0.38 1"></i></div>
</aside>
</div>
</div>
</section>
```
## CSS
```css
.ccm-12,
.ccm-12 *,
.ccm-12 *::before,
.ccm-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ccm-12 {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: oklch(0.97 0.006 265);
--c1: oklch(0.62 0.17 265);
--c2: oklch(0.72 0.15 195);
--c3: oklch(0.78 0.14 85);
--c4: oklch(0.68 0.17 350);
--lift: 10px;
font-family: 'Segoe UI',system-ui,sans-serif;
color: oklch(0.25 0.02 265);
}
.ccm-12__stage {
width: 100%;
height: 100vh;
display: grid;
place-items: center;
border-radius: 20px;
background: oklch(0.97 0.006 265);
border: 1px solid oklch(0.9 0.01 265);
}
.ccm-12__duo {
display: flex;
align-items: center;
gap: 44px;
flex-wrap: wrap;
justify-content: center;
padding: 30px;
}
.ccm-12__pie {
position: relative;
width: 270px;
height: 270px;
}
.ccm-12__disc {
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow: 0 24px 50px -24px oklch(0.4 0.08 265 / .5);
}
.ccm-12__hits {
position: absolute;
inset: 0;
}
.ccm-12__hits button {
position: absolute;
inset: 0;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
transition: translate .3s cubic-bezier(.34,1.3,.4,1),filter .3s;
}
.ccm-12__hits button:focus-visible {
outline: 3px solid oklch(0.5 0.1 265);
outline-offset: 4px;
}
.ccm-12__hits button.is-active {
filter: drop-shadow(0 8px 16px oklch(0.4 0.08 265 / .45));
}
.ccm-12__panel {
display: grid;
gap: 6px;
min-width: 220px;
padding: 24px 26px;
border-radius: 18px;
background: #fff;
border: 1px solid oklch(0.9 0.01 265);
box-shadow: 0 18px 44px -26px oklch(0.4 0.08 265 / .5);
}
.ccm-12__cat {
font-style: normal;
font-size: 12px;
letter-spacing: .14em;
text-transform: uppercase;
color: oklch(0.55 0.04 265);
}
.ccm-12__amt {
font-size: 34px;
letter-spacing: -.02em;
}
.ccm-12__amt span {
font-size: 15px;
font-weight: 500;
color: oklch(0.55 0.04 265);
}
.ccm-12__share {
font-size: 13.5px;
color: oklch(0.45 0.03 265);
}
.ccm-12__bar {
height: 8px;
border-radius: 4px;
background: oklch(0.93 0.01 265);
overflow: hidden;
margin-top: 6px;
}
.ccm-12__bar i {
display: block;
height: 100%;
border-radius: 4px;
background: var(--seg,var(--c1));
transform-origin: left;
transition: scale .5s cubic-bezier(.3,1,.35,1),background .3s;
}
@media (prefers-reduced-motion: reduce) {
.ccm-12 * {
transition-duration: .01s !important;
}
}
```
## JavaScript
```js
(() => {
const data = [
{ label: 'Housing', amt: '$1,824', pct: 38, color: 'var(--c1)' },
{ label: 'Food & dining', amt: '$1,296', pct: 27, color: 'var(--c2)' },
{ label: 'Transport', amt: '$1,056', pct: 22, color: 'var(--c3)' },
{ label: 'Savings', amt: '$624', pct: 13, color: 'var(--c4)' },
];
const root = document.querySelector('.ccm-12');
const disc = root.querySelector('.ccm-12__disc');
const hits = root.querySelector('.ccm-12__hits');
const panel = {
cat: root.querySelector('.ccm-12__cat'), amt: root.querySelector('.ccm-12__amt'),
share: root.querySelector('.ccm-12__share'), bar: root.querySelector('.ccm-12__bar i'),
};
// 1) paint the pie from the data
let acc = 0;
const stops = data.map((d) => {
const s = d.color + ' ' + acc + '% ' + (acc + d.pct) + '%'; acc += d.pct; return s;
});
disc.style.background = 'conic-gradient(' + stops.join(',') + ')';
// 2) build one clipped hit button per slice from the same numbers
const P = 40; // polygon resolution per slice
acc = 0;
data.forEach((d, idx) => {
const a0 = acc / 100 * 360 - 90, a1 = (acc + d.pct) / 100 * 360 - 90; acc += d.pct;
const pts = ['50% 50%'];
for (let k = 0; k <= P; k++) {
const a = (a0 + (a1 - a0) * k / P) * Math.PI / 180;
pts.push((50 + 50 * Math.cos(a)).toFixed(2) + '% ' + (50 + 50 * Math.sin(a)).toFixed(2) + '%');
}
const b = document.createElement('button');
b.type = 'button';
b.setAttribute('aria-label', d.label + ', ' + d.pct + ' percent of budget');
b.style.clipPath = 'polygon(' + pts.join(',') + ')';
const mid = ((a0 + a1) / 2) * Math.PI / 180;
b.addEventListener('click', () => {
hits.querySelectorAll('button').forEach((x) => { x.classList.remove('is-active'); x.style.translate = '0 0'; });
b.classList.add('is-active');
b.style.translate = (Math.cos(mid) * 10) + 'px ' + (Math.sin(mid) * 10) + 'px';
panel.cat.textContent = d.label;
panel.amt.innerHTML = d.amt + '<span>/mo</span>';
panel.share.textContent = d.pct + '% of monthly budget';
panel.bar.style.scale = (d.pct / 100) + ' 1';
panel.bar.style.background = d.color;
});
hits.append(b);
});
})();
```Here's a working CSS Circular Menu from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Pie Chart Navigation Menu
Source: https://codefronts.com/navigation/css-circular-menus/pie-slice-selector/
A pie chart that is the menu: four budget categories drawn as proportional slices with a single conic-gradient, plus an invisible hit layer of real buttons over each slice. Click a slice and it 'lifts' toward you while the detail panel updates. Data visualization and navigation in one element — a genuinely different take on the category picker.
## HTML
```html
<section class="ccm-12" aria-label="Pie chart navigation demo">
<div class="ccm-12__stage">
<div class="ccm-12__duo">
<div class="ccm-12__pie" role="group" aria-label="Budget categories">
<div class="ccm-12__disc" aria-hidden="true"></div>
<div class="ccm-12__hits"></div>
</div>
<aside class="ccm-12__panel" aria-live="polite">
<em class="ccm-12__cat">Housing</em>
<strong class="ccm-12__amt">$1,824<span>/mo</span></strong>
<p class="ccm-12__share">38% of monthly budget</p>
<div class="ccm-12__bar"><i style="scale:0.38 1"></i></div>
</aside>
</div>
</div>
</section>
```
## CSS
```css
.ccm-12,
.ccm-12 *,
.ccm-12 *::before,
.ccm-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ccm-12 {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: oklch(0.97 0.006 265);
--c1: oklch(0.62 0.17 265);
--c2: oklch(0.72 0.15 195);
--c3: oklch(0.78 0.14 85);
--c4: oklch(0.68 0.17 350);
--lift: 10px;
font-family: 'Segoe UI',system-ui,sans-serif;
color: oklch(0.25 0.02 265);
}
.ccm-12__stage {
width: 100%;
height: 100vh;
display: grid;
place-items: center;
border-radius: 20px;
background: oklch(0.97 0.006 265);
border: 1px solid oklch(0.9 0.01 265);
}
.ccm-12__duo {
display: flex;
align-items: center;
gap: 44px;
flex-wrap: wrap;
justify-content: center;
padding: 30px;
}
.ccm-12__pie {
position: relative;
width: 270px;
height: 270px;
}
.ccm-12__disc {
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow: 0 24px 50px -24px oklch(0.4 0.08 265 / .5);
}
.ccm-12__hits {
position: absolute;
inset: 0;
}
.ccm-12__hits button {
position: absolute;
inset: 0;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
transition: translate .3s cubic-bezier(.34,1.3,.4,1),filter .3s;
}
.ccm-12__hits button:focus-visible {
outline: 3px solid oklch(0.5 0.1 265);
outline-offset: 4px;
}
.ccm-12__hits button.is-active {
filter: drop-shadow(0 8px 16px oklch(0.4 0.08 265 / .45));
}
.ccm-12__panel {
display: grid;
gap: 6px;
min-width: 220px;
padding: 24px 26px;
border-radius: 18px;
background: #fff;
border: 1px solid oklch(0.9 0.01 265);
box-shadow: 0 18px 44px -26px oklch(0.4 0.08 265 / .5);
}
.ccm-12__cat {
font-style: normal;
font-size: 12px;
letter-spacing: .14em;
text-transform: uppercase;
color: oklch(0.55 0.04 265);
}
.ccm-12__amt {
font-size: 34px;
letter-spacing: -.02em;
}
.ccm-12__amt span {
font-size: 15px;
font-weight: 500;
color: oklch(0.55 0.04 265);
}
.ccm-12__share {
font-size: 13.5px;
color: oklch(0.45 0.03 265);
}
.ccm-12__bar {
height: 8px;
border-radius: 4px;
background: oklch(0.93 0.01 265);
overflow: hidden;
margin-top: 6px;
}
.ccm-12__bar i {
display: block;
height: 100%;
border-radius: 4px;
background: var(--seg,var(--c1));
transform-origin: left;
transition: scale .5s cubic-bezier(.3,1,.35,1),background .3s;
}
@media (prefers-reduced-motion: reduce) {
.ccm-12 * {
transition-duration: .01s !important;
}
}
```
## JavaScript
```js
(() => {
const data = [
{ label: 'Housing', amt: '$1,824', pct: 38, color: 'var(--c1)' },
{ label: 'Food & dining', amt: '$1,296', pct: 27, color: 'var(--c2)' },
{ label: 'Transport', amt: '$1,056', pct: 22, color: 'var(--c3)' },
{ label: 'Savings', amt: '$624', pct: 13, color: 'var(--c4)' },
];
const root = document.querySelector('.ccm-12');
const disc = root.querySelector('.ccm-12__disc');
const hits = root.querySelector('.ccm-12__hits');
const panel = {
cat: root.querySelector('.ccm-12__cat'), amt: root.querySelector('.ccm-12__amt'),
share: root.querySelector('.ccm-12__share'), bar: root.querySelector('.ccm-12__bar i'),
};
// 1) paint the pie from the data
let acc = 0;
const stops = data.map((d) => {
const s = d.color + ' ' + acc + '% ' + (acc + d.pct) + '%'; acc += d.pct; return s;
});
disc.style.background = 'conic-gradient(' + stops.join(',') + ')';
// 2) build one clipped hit button per slice from the same numbers
const P = 40; // polygon resolution per slice
acc = 0;
data.forEach((d, idx) => {
const a0 = acc / 100 * 360 - 90, a1 = (acc + d.pct) / 100 * 360 - 90; acc += d.pct;
const pts = ['50% 50%'];
for (let k = 0; k <= P; k++) {
const a = (a0 + (a1 - a0) * k / P) * Math.PI / 180;
pts.push((50 + 50 * Math.cos(a)).toFixed(2) + '% ' + (50 + 50 * Math.sin(a)).toFixed(2) + '%');
}
const b = document.createElement('button');
b.type = 'button';
b.setAttribute('aria-label', d.label + ', ' + d.pct + ' percent of budget');
b.style.clipPath = 'polygon(' + pts.join(',') + ')';
const mid = ((a0 + a1) / 2) * Math.PI / 180;
b.addEventListener('click', () => {
hits.querySelectorAll('button').forEach((x) => { x.classList.remove('is-active'); x.style.translate = '0 0'; });
b.classList.add('is-active');
b.style.translate = (Math.cos(mid) * 10) + 'px ' + (Math.sin(mid) * 10) + 'px';
panel.cat.textContent = d.label;
panel.amt.innerHTML = d.amt + '<span>/mo</span>';
panel.share.textContent = d.pct + '% of monthly budget';
panel.bar.style.scale = (d.pct / 100) + ' 1';
panel.bar.style.background = d.color;
});
hits.append(b);
});
})();
```How this works
The chart is one background on one element:
background: conic-gradient(
var(--c1) 0 38%, var(--c2) 38% 65%,
var(--c3) 65% 87%, var(--c4) 87% 100%);Interaction is the clever part: you can't attach events to gradient stops, so a transparent button sits over each slice, clipped to the same wedge with clip-path: polygon() vertices computed from the slice's start/end angles. The lift effect translates the active button's wedge along its bisector and drops a highlight ring behind it.
Because slices are real <button>s in DOM order, tab order follows data order and each carries its own accessible name ('Housing, 38 percent of budget').
Make it yours
- Slice data: one array in the JS — percentages, labels, colors; gradient and hit zones regenerate.
- Donut variant: punch a hole with a
mask: radial-gradient(). - Hover-lift distance: the
--lifttoken. - Feed it live data by regenerating the array from your API and calling
paint().
Gotchas — read before shipping
- Conic-gradient percentages and clip-path hit zones must be generated from the same numbers — hand-syncing two lists always drifts.
- Thin slices (<8%) make terrible touch targets; enforce a minimum visual angle or group small items into 'Other'.
- Slices need accessible names with the value in them — color alone is invisible to screen readers and colorblind users.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 15.4+ | 113+ | 111+ |
Floor set by oklch() as this demo is written. The core technique itself goes back further — Chrome any.
conic-gradient + clip-path polygon — both are years past baseline in all engines. The JS generates polygons, so no trig CSS is required at all.