Spine Plot for Two-Variable Proportions — Chart.js

A spine plot (spineplot) is a stacked bar chart where bar widths are proportional to the marginal frequency of one categorical variable and the subdivisions within each bar show the conditional distribution of a second categorical variable. All bars are normalized to the same height (100%), so visual comparison focuses on how the conditional proportions shift across categories. It is a one-dimensional specialization of mosaic plots and excels at revealing associations between two categorical variables in contingency table data.

Spine Plot for Two-Variable Proportions rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// bar-spine: Spine Plot for Two-Variable Proportions
// Library: chartjs 4.4.7 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-09-02

const t = window.ANYPLOT_TOKENS;

// --- Data (in-memory, deterministic) ----------------------------------------
// Customer churn by subscription tier: bar WIDTH = marginal tier size (how many
// customers are on that plan), segment HEIGHT = conditional retained/churned
// split within the tier. Cheaper tiers churn far more than paid ones.
const tiers = ["Free", "Basic", "Pro", "Enterprise"];
const totals = [600, 900, 400, 150]; // customers per tier (marginal frequency)
const churned = [240, 198, 40, 6]; // churned customers per tier
const retained = totals.map((total, i) => total - churned[i]);
const retainedPct = retained.map((count, i) => (count / totals[i]) * 100);
const churnedPct = churned.map((count, i) => (count / totals[i]) * 100);

// Cumulative edges along x so bar width is exactly proportional to tier size
// and adjacent bars are flush (no gaps), per the spec.
const totalCustomers = totals.reduce((sum, count) => sum + count, 0);
const edges = totals.reduce((acc, count) => [...acc, acc[acc.length - 1] + count], [0]);
const centers = tiers.map((_, i) => (edges[i] + edges[i + 1]) / 2);

// Chart.js's bar controller lays out every bar in a dataset at one shared
// width (it has no per-bar thickness hook), so a spine plot's variable-width
// requirement can't be expressed through the stock bar element. Instead we
// mount an invisible scatter chart purely to get Chart.js's linear scales,
// legend and title, then draw the real proportional rectangles ourselves in a
// plugin using those scales' own pixel mapping — the documented extension
// point for custom geometry Chart.js's built-in controllers don't cover.
const SEGMENT_LABEL_COLOR = "#FAF8F1"; // fixed light label, reads on saturated fills in both themes

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

// --- Spine drawing plugin ---------------------------------------------------
const drawSpine = {
  id: "drawSpine",
  afterDatasetsDraw(chart) {
    const { ctx, chartArea, scales } = chart;
    const xScale = scales.x;
    const yScale = scales.y;
    const yZero = yScale.getPixelForValue(0);
    const yHundred = yScale.getPixelForValue(100);

    ctx.save();
    ctx.beginPath();
    ctx.rect(chartArea.left, chartArea.top, chartArea.width, chartArea.height);
    ctx.clip();

    tiers.forEach((tier, i) => {
      const xLeft = xScale.getPixelForValue(edges[i]);
      const xRight = xScale.getPixelForValue(edges[i + 1]);
      const xCenter = (xLeft + xRight) / 2;
      const ySplit = yScale.getPixelForValue(retainedPct[i]);

      ctx.fillStyle = t.palette[0]; // Retained — brand green, always first series
      ctx.fillRect(xLeft, ySplit, xRight - xLeft, yZero - ySplit);

      ctx.fillStyle = t.palette[4]; // Churned — matte red, semantic loss anchor
      ctx.fillRect(xLeft, yHundred, xRight - xLeft, ySplit - yHundred);

      // Bar edges: thin page-bg stroke for definition (default-style-guide.md
      // "Data Element Styling"), between the two stacked segments...
      ctx.fillStyle = t.pageBg;
      ctx.fillRect(xLeft, ySplit - 1, xRight - xLeft, 2);

      ctx.font = "600 17px -apple-system, BlinkMacSystemFont, sans-serif";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillStyle = SEGMENT_LABEL_COLOR;
      const retainedHeight = yZero - ySplit;
      const churnedHeight = ySplit - yHundred;
      if (retainedHeight >= 45) {
        ctx.fillText(`${Math.round(retainedPct[i])}%`, xCenter, (yZero + ySplit) / 2);
      }
      if (churnedHeight >= 45) {
        ctx.fillText(`${Math.round(churnedPct[i])}%`, xCenter, (ySplit + yHundred) / 2);
      }
    });

    // ...and between adjacent bars, so equal-colored neighbors stay legible.
    ctx.fillStyle = t.pageBg;
    for (let i = 1; i < edges.length - 1; i++) {
      const x = xScale.getPixelForValue(edges[i]);
      ctx.fillRect(x - 1, yHundred, 2, yZero - yHundred);
    }

    ctx.restore();
  },
};

// --- Title -------------------------------------------------------------------
const title = "Customer Churn by Subscription Tier · bar-spine · javascript · chartjs · anyplot.ai";
const titleSize = Math.round(22 * Math.min(1, 67 / title.length));

// --- Chart -------------------------------------------------------------------
new Chart(canvas, {
  type: "scatter",
  data: {
    datasets: [
      { label: "Retained", data: [], backgroundColor: t.palette[0], showLine: false },
      { label: "Churned", data: [], backgroundColor: t.palette[4], showLine: false },
    ],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: { padding: { top: 8, right: 20, bottom: 4, left: 4 } },
    plugins: {
      title: {
        display: true,
        text: title,
        color: t.ink,
        font: { size: titleSize, weight: "500" },
        padding: { bottom: 18 },
      },
      legend: {
        position: "top",
        align: "center",
        labels: { color: t.ink, font: { size: 16 }, boxWidth: 22, boxHeight: 16 },
      },
      tooltip: { enabled: false },
    },
    scales: {
      x: {
        type: "linear",
        min: 0,
        max: totalCustomers,
        afterBuildTicks: (axis) => {
          axis.ticks = centers.map((value) => ({ value }));
        },
        ticks: {
          autoSkip: false,
          maxTicksLimit: tiers.length,
          color: t.inkSoft,
          font: { size: 15 },
          callback: (value, index) => tiers[index],
        },
        grid: { display: false },
        title: {
          display: true,
          text: "Subscription tier — bar width ∝ customer count",
          color: t.ink,
          font: { size: 15 },
        },
      },
      y: {
        type: "linear",
        min: 0,
        max: 100,
        ticks: {
          stepSize: 20,
          color: t.inkSoft,
          font: { size: 14 },
          callback: (value) => `${value}%`,
        },
        grid: { color: t.grid },
        title: { display: true, text: "Share of customers", color: t.ink, font: { size: 15 } },
      },
    },
  },
  plugins: [drawSpine],
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/bar-spine/chartjs/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "bar-spine",
  "language": "javascript",
  "library": "chartjs",
  "page": "https://anyplot.ai/bar-spine/javascript/chartjs",
  "hub": "https://anyplot.ai/bar-spine",
  "code_json": "https://api.anyplot.ai/specs/bar-spine/chartjs/code",
  "spec_json": "https://api.anyplot.ai/specs/bar-spine",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/javascript/chartjs/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/javascript/chartjs/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/javascript/chartjs/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/javascript/chartjs/plot-dark.html",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Spine Plot for Two-Variable Proportions on anyplot.ai.

Other implementations