Nested Donut Chart — Chart.js

A nested donut chart displays hierarchical data as multiple concentric rings, where each ring represents a level of the hierarchy. Inner rings show parent categories while outer rings show their subdivisions. This visualization effectively reveals part-to-whole relationships across multiple levels while maintaining the familiar donut format.

Nested Donut Chart rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// donut-nested: Nested Donut Chart
// Library: chartjs 4.4.7 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-08-18

//# anyplot-orientation: square
const t = window.ANYPLOT_TOKENS;

// --- Data (in-memory, deterministic) ----------------------------------------
// Company budget: department totals (inner ring) split into expense
// categories (outer ring). Category values sum exactly to their parent's
// total, and children are listed grouped by department in the same order as
// the departments array, so the two rings' arc boundaries line up radially.
const departments = [
  {
    name: "Engineering",
    color: t.palette[0],
    children: [
      { name: "R&D", value: 18 },
      { name: "Infrastructure", value: 9 },
      { name: "QA", value: 6 },
    ],
  },
  {
    name: "Marketing",
    color: t.palette[1],
    children: [
      { name: "Advertising", value: 10 },
      { name: "Content", value: 6 },
      { name: "Events", value: 4 },
    ],
  },
  {
    name: "Sales",
    color: t.palette[2],
    children: [
      { name: "Direct Sales", value: 14 },
      { name: "Partnerships", value: 5 },
      { name: "Customer Success", value: 6 },
    ],
  },
  {
    name: "Operations",
    color: t.palette[3],
    children: [
      { name: "Facilities", value: 8 },
      { name: "Logistics", value: 5 },
    ],
  },
];

// --- Color families: children share their parent's hue at rising lightness -
function hexToHsl(hex) {
  const r = parseInt(hex.slice(1, 3), 16) / 255;
  const g = parseInt(hex.slice(3, 5), 16) / 255;
  const b = parseInt(hex.slice(5, 7), 16) / 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h = 0;
  let s = 0;
  const l = (max + min) / 2;
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
    else if (max === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h /= 6;
  }
  return [h * 360, s * 100, l * 100];
}

function hslToHex(h, s, l) {
  const sf = s / 100;
  const lf = l / 100;
  const c = (1 - Math.abs(2 * lf - 1)) * sf;
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  const m = lf - c / 2;
  let rgb;
  if (h < 60) rgb = [c, x, 0];
  else if (h < 120) rgb = [x, c, 0];
  else if (h < 180) rgb = [0, c, x];
  else if (h < 240) rgb = [0, x, c];
  else if (h < 300) rgb = [x, 0, c];
  else rgb = [c, 0, x];
  const toHex = (v) =>
    Math.round((v + m) * 255)
      .toString(16)
      .padStart(2, "0");
  return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`;
}

function childShade(baseHex, index, count) {
  const [h, s, l] = hexToHsl(baseHex);
  const step = count > 1 ? 24 / (count - 1) : 0;
  const lightness = Math.min(l + 12 + step * index, 88);
  return hslToHex(h, s, lightness);
}

// --- Flatten into the two rings ---------------------------------------------
const departmentLabels = departments.map((d) => d.name);
const departmentTotals = departments.map((d) =>
  d.children.reduce((sum, c) => sum + c.value, 0),
);
const departmentColors = departments.map((d) => d.color);

const categoryLabels = departments.flatMap((d) => d.children.map((c) => c.name));
const categoryValues = departments.flatMap((d) => d.children.map((c) => c.value));
const categoryColors = departments.flatMap((d) =>
  d.children.map((c, i) => childShade(d.color, i, d.children.length)),
);
const grandTotal = departmentTotals.reduce((sum, v) => sum + v, 0);

// --- Mount -------------------------------------------------------------------
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

// --- Plugin: direct labels on the department ring's large segments ---------
// (Chart.js draws dataset 0 as the outermost ring, working inward, so dataset
// index 1 — the department ring — is the one with enough radial room and few
// enough segments for on-arc labels; the finer category ring gets a legend.)
const ringLabelsPlugin = {
  id: "ringLabels",
  afterDatasetsDraw(chart) {
    const { ctx } = chart;
    const meta = chart.getDatasetMeta(1);
    ctx.save();
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.lineJoin = "round";
    meta.data.forEach((arc, i) => {
      const angle = (arc.startAngle + arc.endAngle) / 2;
      const radius = (arc.innerRadius + arc.outerRadius) / 2;
      const x = arc.x + Math.cos(angle) * radius;
      const y = arc.y + Math.sin(angle) * radius;

      ctx.font = "600 21px sans-serif";
      ctx.strokeStyle = "rgba(0,0,0,0.35)";
      ctx.lineWidth = 4;
      ctx.strokeText(departmentLabels[i], x, y - 14);
      ctx.fillStyle = "#FFFFFF";
      ctx.fillText(departmentLabels[i], x, y - 14);

      ctx.font = "400 20px sans-serif";
      ctx.strokeText(`$${departmentTotals[i]}M`, x, y + 14);
      ctx.fillText(`$${departmentTotals[i]}M`, x, y + 14);
    });

    // Center label in the donut hole: the grand total ties the two rings
    // together into one headline number rather than a plain breakdown.
    const center = meta.data[0];
    ctx.font = "400 17px sans-serif";
    ctx.fillStyle = t.inkSoft;
    ctx.strokeStyle = "transparent";
    ctx.lineWidth = 0;
    ctx.fillText("Total Budget", center.x, center.y - 16);
    ctx.font = "700 30px sans-serif";
    ctx.fillStyle = t.ink;
    ctx.fillText(`$${grandTotal}M`, center.x, center.y + 16);
    ctx.restore();
  },
};

// --- Chart -------------------------------------------------------------------
new Chart(canvas, {
  type: "doughnut",
  data: {
    labels: categoryLabels,
    datasets: [
      {
        label: "Expense Category",
        data: categoryValues,
        backgroundColor: categoryColors,
        borderColor: t.pageBg,
        borderWidth: 3,
        weight: 1.4,
      },
      {
        label: "Department",
        data: departmentTotals,
        backgroundColor: departmentColors,
        borderColor: t.pageBg,
        borderWidth: 3,
        weight: 1,
      },
    ],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    cutout: "34%",
    layout: {
      padding: { left: 50, right: 50, top: 10, bottom: 10 },
    },
    plugins: {
      title: {
        display: true,
        text: "donut-nested · javascript · chartjs · anyplot.ai",
        color: t.ink,
        font: { size: 22, weight: "500" },
        padding: { bottom: 24 },
      },
      legend: {
        position: "right",
        labels: {
          color: t.inkSoft,
          font: { size: 15 },
          boxWidth: 16,
          boxHeight: 16,
          padding: 10,
          generateLabels() {
            return categoryLabels.map((text, i) => ({
              text,
              fillStyle: categoryColors[i],
              strokeStyle: categoryColors[i],
              fontColor: t.inkSoft,
              lineWidth: 0,
              index: i,
              datasetIndex: 0,
            }));
          },
        },
      },
      tooltip: {
        callbacks: {
          title: () => "",
          label(ctx) {
            const label =
              ctx.datasetIndex === 0
                ? categoryLabels[ctx.dataIndex]
                : departmentLabels[ctx.dataIndex];
            return `${label}: $${ctx.parsed}M`;
          },
        },
      },
    },
  },
  plugins: [ringLabelsPlugin],
});

Part of Nested Donut Chart on anyplot.ai.

Other implementations