UI Components Easy
Pie / Donut Chart
An animated SVG pie and donut chart with legend, interactive slice highlighting on hover, and smooth arc transitions. Toggle between pie and donut mode with a button click.
Open in Lab
MCP
vanilla-js svg react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 24px;
}
.chart-page {
max-width: 700px;
margin: 0 auto;
}
.chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.chart-title {
font-size: 1.2rem;
font-weight: 700;
}
.chart-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 3px;
}
.chart-controls {
display: flex;
gap: 6px;
}
.ctrl-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 600;
padding: 5px 12px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: all .15s;
}
.ctrl-btn.active,
.ctrl-btn:hover {
border-color: #818cf8;
color: #818cf8;
}
.chart-body {
display: flex;
align-items: center;
gap: 32px;
flex-wrap: wrap;
}
.pie-wrap {
position: relative;
flex-shrink: 0;
}
.pie-svg {
display: block;
}
.pie-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.pie-center-val {
font-size: 1.4rem;
font-weight: 800;
}
.pie-center-lbl {
font-size: 0.72rem;
color: var(--text-muted);
}
.pie-slice {
cursor: pointer;
transition: transform .2s;
transform-origin: center;
transform-box: fill-box;
}
.pie-slice:hover {
transform: scale(1.04);
}
.pie-legend {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 160px;
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.82rem;
cursor: pointer;
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.legend-pct {
margin-left: auto;
font-weight: 700;
color: var(--text-muted);
font-size: 0.75rem;
}
.chart-tooltip {
position: fixed;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.78rem;
pointer-events: none;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}const DATA = [
{ label: "Organic Search", value: 4200, color: "#818cf8" },
{ label: "Direct", value: 2800, color: "#34d399" },
{ label: "Social Media", value: 1900, color: "#f59e0b" },
{ label: "Referral", value: 1300, color: "#f87171" },
{ label: "Email", value: 800, color: "#a78bfa" },
];
const total = DATA.reduce((a, d) => a + d.value, 0);
let mode = "donut";
const pieSvg = document.getElementById("pieSvg");
const legend = document.getElementById("pieLegend");
const center = document.getElementById("pieCenter");
const centerVal = document.getElementById("pieCenterVal");
const tooltip = document.getElementById("chartTooltip");
const SIZE = 260,
CX = SIZE / 2,
CY = SIZE / 2,
R = 110,
INNER_R = 65;
document.querySelectorAll(".ctrl-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".ctrl-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
mode = btn.dataset.mode;
draw();
});
});
function polarToXY(cx, cy, r, angle) {
return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
}
function arcPath(cx, cy, r, innerR, startAngle, endAngle, isDonut) {
const [sx, sy] = polarToXY(cx, cy, r, startAngle);
const [ex, ey] = polarToXY(cx, cy, r, endAngle);
const large = endAngle - startAngle > Math.PI ? 1 : 0;
if (isDonut) {
const [ix, iy] = polarToXY(cx, cy, innerR, endAngle);
const [ox, oy] = polarToXY(cx, cy, innerR, startAngle);
return `M${sx},${sy} A${r},${r} 0 ${large},1 ${ex},${ey} L${ix},${iy} A${innerR},${innerR} 0 ${large},0 ${ox},${oy} Z`;
}
return `M${cx},${cy} L${sx},${sy} A${r},${r} 0 ${large},1 ${ex},${ey} Z`;
}
function draw() {
pieSvg.setAttribute("viewBox", `0 0 ${SIZE} ${SIZE}`);
pieSvg.setAttribute("width", SIZE);
pieSvg.setAttribute("height", SIZE);
pieSvg.innerHTML = "";
center.hidden = mode !== "donut";
if (mode === "donut") {
centerVal.textContent = (total / 1000).toFixed(1) + "k";
}
let angle = -Math.PI / 2;
DATA.forEach((d, i) => {
const share = (d.value / total) * 2 * Math.PI;
const startAngle = angle;
const endAngle = angle + share;
angle = endAngle;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", arcPath(CX, CY, R, INNER_R, startAngle, endAngle, mode === "donut"));
path.setAttribute("fill", d.color);
path.setAttribute("class", "pie-slice");
path.style.animation = `fadeIn .4s ${i * 0.07}s ease both`;
path.addEventListener("mouseenter", (e) => showTooltip(e, d));
path.addEventListener("mousemove", (e) => posTooltip(e));
path.addEventListener("mouseleave", () => {
tooltip.hidden = true;
});
pieSvg.appendChild(path);
});
legend.innerHTML = "";
DATA.forEach((d) => {
const pct = ((d.value / total) * 100).toFixed(1);
const item = document.createElement("div");
item.className = "legend-item";
item.innerHTML = `<span class="legend-swatch" style="background:${d.color}"></span><span>${d.label}</span><span class="legend-pct">${pct}%</span>`;
legend.appendChild(item);
});
}
function showTooltip(e, d) {
const pct = ((d.value / total) * 100).toFixed(1);
tooltip.innerHTML = `<strong>${d.label}</strong><br/>${d.value.toLocaleString()} visits · ${pct}%`;
tooltip.hidden = false;
posTooltip(e);
}
function posTooltip(e) {
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
}
const style = document.createElement("style");
style.textContent = `@keyframes fadeIn { from { opacity: 0; transform: scale(.9); } to { opacity: 1; transform: none; } }`;
document.head.appendChild(style);
draw();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pie / Donut Chart</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-header">
<div>
<h1 class="chart-title">Traffic Sources</h1>
<p class="chart-sub">Last 30 days</p>
</div>
<div class="chart-controls">
<button class="ctrl-btn active" data-mode="donut">Donut</button>
<button class="ctrl-btn" data-mode="pie">Pie</button>
</div>
</div>
<div class="chart-body">
<div class="pie-wrap" id="pieWrap">
<svg id="pieSvg" class="pie-svg" aria-label="Pie chart"></svg>
<div class="pie-center" id="pieCenter" hidden>
<div class="pie-center-val" id="pieCenterVal"></div>
<div class="pie-center-lbl">Total</div>
</div>
</div>
<div class="pie-legend" id="pieLegend"></div>
</div>
<div class="chart-tooltip" id="chartTooltip" hidden></div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
const DATA = [
{ label: "Organic Search", value: 4200, color: "#818cf8" },
{ label: "Direct", value: 2800, color: "#34d399" },
{ label: "Social Media", value: 1900, color: "#f59e0b" },
{ label: "Referral", value: 1300, color: "#f87171" },
{ label: "Email", value: 800, color: "#a78bfa" },
];
const total = DATA.reduce((a, d) => a + d.value, 0);
const SIZE = 260,
CX = SIZE / 2,
CY = SIZE / 2,
R = 110,
INNER_R = 65;
function polarXY(r: number, angle: number): [number, number] {
return [CX + r * Math.cos(angle), CY + r * Math.sin(angle)];
}
function arcPath(r: number, innerR: number, start: number, end: number, isDonut: boolean) {
const [sx, sy] = polarXY(r, start);
const [ex, ey] = polarXY(r, end);
const large = end - start > Math.PI ? 1 : 0;
if (isDonut) {
const [ix, iy] = polarXY(innerR, end);
const [ox, oy] = polarXY(innerR, start);
return `M${sx},${sy} A${r},${r} 0 ${large},1 ${ex},${ey} L${ix},${iy} A${innerR},${innerR} 0 ${large},0 ${ox},${oy} Z`;
}
return `M${CX},${CY} L${sx},${sy} A${r},${r} 0 ${large},1 ${ex},${ey} Z`;
}
type Tooltip = { label: string; value: number; pct: string; x: number; y: number } | null;
export default function ChartPieRC() {
const [mode, setMode] = useState<"donut" | "pie">("donut");
const [tooltip, setTooltip] = useState<Tooltip>(null);
const [hovered, setHovered] = useState<number | null>(null);
let angle = -Math.PI / 2;
const slices = DATA.map((d, i) => {
const share = (d.value / total) * 2 * Math.PI;
const start = angle;
const end = angle + share;
angle = end;
return { ...d, start, end, i };
});
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div className="w-full max-w-[600px]">
<div className="flex gap-1 mb-6 justify-center">
{(["donut", "pie"] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`text-[11px] px-3 py-1 rounded border transition-colors capitalize ${mode === m ? "bg-[#818cf8]/20 border-[#818cf8] text-[#818cf8]" : "border-[#30363d] text-[#8b949e] hover:border-[#8b949e]"}`}
>
{m}
</button>
))}
</div>
<div className="flex flex-col sm:flex-row items-center gap-8">
<div className="relative flex-shrink-0">
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
{slices.map((s) => (
<path
key={s.label}
d={arcPath(R, INNER_R, s.start, s.end, mode === "donut")}
fill={s.color}
opacity={hovered === null || hovered === s.i ? 1 : 0.5}
transform={
hovered === s.i
? `translate(${(Math.cos((s.start + s.end) / 2) * 6).toFixed(1)},${(Math.sin((s.start + s.end) / 2) * 6).toFixed(1)})`
: "translate(0,0)"
}
style={{ transition: "transform 0.2s, opacity 0.2s", cursor: "pointer" }}
onMouseEnter={(e) => {
setHovered(s.i);
setTooltip({
label: s.label,
value: s.value,
pct: ((s.value / total) * 100).toFixed(1),
x: e.clientX,
y: e.clientY,
});
}}
onMouseMove={(e) =>
setTooltip((t) => (t ? { ...t, x: e.clientX, y: e.clientY } : null))
}
onMouseLeave={() => {
setHovered(null);
setTooltip(null);
}}
/>
))}
{mode === "donut" && (
<>
<text
x={CX}
y={CY - 6}
textAnchor="middle"
fill="#e6edf3"
fontSize={22}
fontWeight={800}
>
{(total / 1000).toFixed(1)}k
</text>
<text x={CX} y={CY + 12} textAnchor="middle" fill="#484f58" fontSize={11}>
total visits
</text>
</>
)}
</svg>
</div>
<div className="flex flex-col gap-2 flex-1 w-full">
{DATA.map((d, i) => {
const pct = ((d.value / total) * 100).toFixed(1);
return (
<div
key={d.label}
className={`flex items-center gap-2 py-1 px-2 rounded transition-colors cursor-default ${hovered === i ? "bg-[#161b22]" : ""}`}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
>
<span
className="w-3 h-3 rounded-sm flex-shrink-0"
style={{ background: d.color }}
/>
<span className="text-[#8b949e] text-[13px] flex-1">{d.label}</span>
<span className="text-[#484f58] text-[12px]">{d.value.toLocaleString()}</span>
<span
className="text-[12px] font-semibold w-12 text-right"
style={{ color: d.color }}
>
{pct}%
</span>
</div>
);
})}
</div>
</div>
</div>
{tooltip && (
<div
className="fixed pointer-events-none bg-[#161b22] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] shadow-lg z-50"
style={{ left: tooltip.x + 12, top: tooltip.y - 40 }}
>
<div className="font-semibold text-[#e6edf3]">{tooltip.label}</div>
<div className="text-[#8b949e]">
{tooltip.value.toLocaleString()} visits · {tooltip.pct}%
</div>
</div>
)}
</div>
);
}<script setup>
import { ref, computed } from "vue";
const DATA = [
{ label: "Organic Search", value: 4200, color: "#818cf8" },
{ label: "Direct", value: 2800, color: "#34d399" },
{ label: "Social Media", value: 1900, color: "#f59e0b" },
{ label: "Referral", value: 1300, color: "#f87171" },
{ label: "Email", value: 800, color: "#a78bfa" },
];
const total = DATA.reduce((a, d) => a + d.value, 0);
const SIZE = 260;
const CX = SIZE / 2;
const CY = SIZE / 2;
const R = 110;
const INNER_R = 65;
const mode = ref("donut");
const hovered = ref(null);
const tooltip = ref(null);
function polarXY(r, angle) {
return [CX + r * Math.cos(angle), CY + r * Math.sin(angle)];
}
function arcPath(r, innerR, start, end, isDonut) {
const [sx, sy] = polarXY(r, start);
const [ex, ey] = polarXY(r, end);
const large = end - start > Math.PI ? 1 : 0;
if (isDonut) {
const [ix, iy] = polarXY(innerR, end);
const [ox, oy] = polarXY(innerR, start);
return `M${sx},${sy} A${r},${r} 0 ${large},1 ${ex},${ey} L${ix},${iy} A${innerR},${innerR} 0 ${large},0 ${ox},${oy} Z`;
}
return `M${CX},${CY} L${sx},${sy} A${r},${r} 0 ${large},1 ${ex},${ey} Z`;
}
const slices = computed(() => {
let angle = -Math.PI / 2;
return DATA.map((d, i) => {
const share = (d.value / total) * 2 * Math.PI;
const start = angle;
const end = angle + share;
angle = end;
return { ...d, start, end, i };
});
});
function sliceTransform(s) {
if (hovered.value !== s.i) return "translate(0,0)";
const mid = (s.start + s.end) / 2;
return `translate(${(Math.cos(mid) * 6).toFixed(1)},${(Math.sin(mid) * 6).toFixed(1)})`;
}
function handleEnter(e, s) {
hovered.value = s.i;
tooltip.value = {
label: s.label,
value: s.value,
pct: ((s.value / total) * 100).toFixed(1),
x: e.clientX,
y: e.clientY,
};
}
function handleMove(e) {
if (tooltip.value) {
tooltip.value = { ...tooltip.value, x: e.clientX, y: e.clientY };
}
}
function handleLeave() {
hovered.value = null;
tooltip.value = null;
}
function pct(d) {
return ((d.value / total) * 100).toFixed(1);
}
</script>
<template>
<div class="page">
<div class="wrap">
<div class="mode-btns">
<button class="mode-btn" :class="{ active: mode === 'donut' }" @click="mode = 'donut'">donut</button>
<button class="mode-btn" :class="{ active: mode === 'pie' }" @click="mode = 'pie'">pie</button>
</div>
<div class="chart-layout">
<div class="chart-col">
<svg :width="SIZE" :height="SIZE" :viewBox="`0 0 ${SIZE} ${SIZE}`">
<path v-for="s in slices" :key="s.label"
:d="arcPath(R, INNER_R, s.start, s.end, mode === 'donut')"
:fill="s.color"
:opacity="hovered === null || hovered === s.i ? 1 : 0.5"
:transform="sliceTransform(s)"
style="transition: transform 0.2s, opacity 0.2s; cursor: pointer;"
@mouseenter="handleEnter($event, s)"
@mousemove="handleMove"
@mouseleave="handleLeave"/>
<template v-if="mode === 'donut'">
<text :x="CX" :y="CY - 6" text-anchor="middle" fill="#e6edf3" font-size="22" font-weight="800">{{ (total / 1000).toFixed(1) }}k</text>
<text :x="CX" :y="CY + 12" text-anchor="middle" fill="#484f58" font-size="11">total visits</text>
</template>
</svg>
</div>
<div class="legend-col">
<div v-for="(d, i) in DATA" :key="d.label" class="legend-row"
:class="{ highlighted: hovered === i }"
@mouseenter="hovered = i" @mouseleave="hovered = null">
<span class="legend-swatch" :style="{ background: d.color }"></span>
<span class="legend-label">{{ d.label }}</span>
<span class="legend-value">{{ d.value.toLocaleString() }}</span>
<span class="legend-pct" :style="{ color: d.color }">{{ pct(d) }}%</span>
</div>
</div>
</div>
</div>
<div v-if="tooltip" class="tooltip-fixed"
:style="{ left: tooltip.x + 12 + 'px', top: tooltip.y - 40 + 'px' }">
<div class="tooltip-title">{{ tooltip.label }}</div>
<div class="tooltip-sub">{{ Number(tooltip.value).toLocaleString() }} visits · {{ tooltip.pct }}%</div>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; display: flex; justify-content: center; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 600px; }
.mode-btns { display: flex; gap: 4px; margin-bottom: 1.5rem; justify-content: center; }
.mode-btn { font-size: 11px; padding: 4px 12px; border-radius: 4px; border: 1px solid #30363d; background: none; color: #8b949e; cursor: pointer; transition: all 0.2s; text-transform: capitalize; }
.mode-btn:hover { border-color: #8b949e; }
.mode-btn.active { background: rgba(129,140,248,0.2); border-color: #818cf8; color: #818cf8; }
.chart-layout { display: flex; flex-direction: column; align-items: center; gap: 2rem; }
@media (min-width: 480px) { .chart-layout { flex-direction: row; } }
.chart-col { flex-shrink: 0; }
.legend-col { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; width: 100%; }
.legend-row { display: flex; align-items: center; gap: 0.5rem; padding: 4px 8px; border-radius: 4px; cursor: default; transition: background 0.2s; }
.legend-row.highlighted { background: #161b22; }
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.legend-label { color: #8b949e; font-size: 13px; flex: 1; }
.legend-value { color: #484f58; font-size: 12px; }
.legend-pct { font-size: 12px; font-weight: 600; width: 48px; text-align: right; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-title { font-weight: 600; color: #e6edf3; }
.tooltip-sub { color: #8b949e; }
</style><script>
const DATA = [
{ label: "Organic Search", value: 4200, color: "#818cf8" },
{ label: "Direct", value: 2800, color: "#34d399" },
{ label: "Social Media", value: 1900, color: "#f59e0b" },
{ label: "Referral", value: 1300, color: "#f87171" },
{ label: "Email", value: 800, color: "#a78bfa" },
];
const total = DATA.reduce((a, d) => a + d.value, 0);
const SIZE = 260;
const CX = SIZE / 2;
const CY = SIZE / 2;
const R = 110;
const INNER_R = 65;
let mode = "donut";
let hovered = null;
let tooltip = null;
function polarXY(r, angle) {
return [CX + r * Math.cos(angle), CY + r * Math.sin(angle)];
}
function arcPath(r, innerR, start, end, isDonut) {
const [sx, sy] = polarXY(r, start);
const [ex, ey] = polarXY(r, end);
const large = end - start > Math.PI ? 1 : 0;
if (isDonut) {
const [ix, iy] = polarXY(innerR, end);
const [ox, oy] = polarXY(innerR, start);
return `M${sx},${sy} A${r},${r} 0 ${large},1 ${ex},${ey} L${ix},${iy} A${innerR},${innerR} 0 ${large},0 ${ox},${oy} Z`;
}
return `M${CX},${CY} L${sx},${sy} A${r},${r} 0 ${large},1 ${ex},${ey} Z`;
}
$: slices = (() => {
let angle = -Math.PI / 2;
return DATA.map((d, i) => {
const share = (d.value / total) * 2 * Math.PI;
const start = angle;
const end = angle + share;
angle = end;
return { ...d, start, end, i };
});
})();
function sliceTransform(s) {
if (hovered !== s.i) return "translate(0,0)";
const mid = (s.start + s.end) / 2;
return `translate(${(Math.cos(mid) * 6).toFixed(1)},${(Math.sin(mid) * 6).toFixed(1)})`;
}
function handleEnter(e, s) {
hovered = s.i;
tooltip = {
label: s.label,
value: s.value,
pct: ((s.value / total) * 100).toFixed(1),
x: e.clientX,
y: e.clientY,
};
}
function handleMove(e) {
if (tooltip) tooltip = { ...tooltip, x: e.clientX, y: e.clientY };
}
function handleLeave() {
hovered = null;
tooltip = null;
}
function pct(d) {
return ((d.value / total) * 100).toFixed(1);
}
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; display: flex; justify-content: center; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 600px; }
.mode-btns { display: flex; gap: 4px; margin-bottom: 1.5rem; justify-content: center; }
.mode-btn { font-size: 11px; padding: 4px 12px; border-radius: 4px; border: 1px solid #30363d; background: none; color: #8b949e; cursor: pointer; transition: all 0.2s; text-transform: capitalize; }
.mode-btn:hover { border-color: #8b949e; }
.mode-btn.active { background: rgba(129,140,248,0.2); border-color: #818cf8; color: #818cf8; }
.chart-layout { display: flex; flex-direction: column; align-items: center; gap: 2rem; }
@media (min-width: 480px) { .chart-layout { flex-direction: row; } }
.chart-col { flex-shrink: 0; }
.legend-col { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; width: 100%; }
.legend-row { display: flex; align-items: center; gap: 0.5rem; padding: 4px 8px; border-radius: 4px; cursor: default; transition: background 0.2s; }
.legend-row.highlighted { background: #161b22; }
.legend-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.legend-label { color: #8b949e; font-size: 13px; flex: 1; }
.legend-value { color: #484f58; font-size: 12px; }
.legend-pct { font-size: 12px; font-weight: 600; width: 48px; text-align: right; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-title { font-weight: 600; color: #e6edf3; }
.tooltip-sub { color: #8b949e; }
</style>
<div class="page">
<div class="wrap">
<div class="mode-btns">
<button class="mode-btn" class:active={mode === 'donut'} on:click={() => mode = 'donut'}>donut</button>
<button class="mode-btn" class:active={mode === 'pie'} on:click={() => mode = 'pie'}>pie</button>
</div>
<div class="chart-layout">
<div class="chart-col">
<svg width={SIZE} height={SIZE} viewBox="0 0 {SIZE} {SIZE}">
{#each slices as s}
<path
d={arcPath(R, INNER_R, s.start, s.end, mode === 'donut')}
fill={s.color}
opacity={hovered === null || hovered === s.i ? 1 : 0.5}
transform={sliceTransform(s)}
style="transition: transform 0.2s, opacity 0.2s; cursor: pointer;"
on:mouseenter={(e) => handleEnter(e, s)}
on:mousemove={handleMove}
on:mouseleave={handleLeave}/>
{/each}
{#if mode === 'donut'}
<text x={CX} y={CY - 6} text-anchor="middle" fill="#e6edf3" font-size="22" font-weight="800">{(total / 1000).toFixed(1)}k</text>
<text x={CX} y={CY + 12} text-anchor="middle" fill="#484f58" font-size="11">total visits</text>
{/if}
</svg>
</div>
<div class="legend-col">
{#each DATA as d, i}
<div class="legend-row" class:highlighted={hovered === i}
on:mouseenter={() => hovered = i} on:mouseleave={() => hovered = null}>
<span class="legend-swatch" style="background:{d.color}"></span>
<span class="legend-label">{d.label}</span>
<span class="legend-value">{d.value.toLocaleString()}</span>
<span class="legend-pct" style="color:{d.color}">{pct(d)}%</span>
</div>
{/each}
</div>
</div>
</div>
{#if tooltip}
<div class="tooltip-fixed" style="left:{tooltip.x + 12}px; top:{tooltip.y - 40}px;">
<div class="tooltip-title">{tooltip.label}</div>
<div class="tooltip-sub">{tooltip.value.toLocaleString()} visits · {tooltip.pct}%</div>
</div>
{/if}
</div>Features
- Pie & donut — toggle between solid pie and hollow donut
- Animated arcs — slices animate in with a stroke-dasharray reveal
- Hover highlight — slice scales and lifts on hover
- Legend — color-coded legend with label and percentage
- Center label — donut mode shows total value in center
How it works
- Data values are converted to radian angles summed across slices
- SVG
<path>arcs are computed withpolarToCartesian()helper - On hover, the active slice gets a
transform: scale(1.05)via CSS - Slice click dispatches a custom
sliceSelectevent with the data payload