Spine Plot for Two-Variable Proportions — D3.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 D3.js

Renders

JavaScript source (D3.js)

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

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;

// Theme-adaptive muted anchor (not shipped in ANYPLOT_TOKENS — see default-style-guide.md)
const MUTED = t.theme === "dark" ? "#A8A79F" : "#6B6A63";
const LIGHT_TXT = "#FFFDF6";
const DARK_TXT = "#1A1A17";

// --- WCAG contrast helpers (pick readable in-segment label color) -----------
const luminance = (hex) => {
  const c = hex.replace("#", "");
  const [r, g, b] = [0, 2, 4].map((i) => parseInt(c.substr(i, 2), 16) / 255);
  const lin = (v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
};
const contrast = (hexA, hexB) => {
  const [la, lb] = [luminance(hexA), luminance(hexB)];
  const [hi, lo] = la > lb ? [la, lb] : [lb, la];
  return (hi + 0.05) / (lo + 0.05);
};
const textColorFor = (bgHex) =>
  contrast(bgHex, DARK_TXT) >= contrast(bgHex, LIGHT_TXT) ? DARK_TXT : LIGHT_TXT;

// --- Data: clinical trial outcome by dosage group (in-memory, deterministic) -
const data = [
  { category: "Placebo", n: 120, counts: { Improved: 24, "No Change": 60, Worsened: 36 } },
  { category: "Low Dose", n: 95, counts: { Improved: 43, "No Change": 38, Worsened: 14 } },
  { category: "Medium Dose", n: 150, counts: { Improved: 98, "No Change": 37, Worsened: 15 } },
  { category: "High Dose", n: 80, counts: { Improved: 56, "No Change": 16, Worsened: 8 } },
];

// Improved/No Change/Worsened read as good/neutral/bad — semantic exception (default-style-guide.md)
const fillKeys = ["Improved", "No Change", "Worsened"];
const fillColor = { Improved: t.palette[0], "No Change": MUTED, Worsened: t.palette[4] };

// --- Layout --------------------------------------------------------------
const margin = { top: 155, right: 60, bottom: 115, left: 90 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// Bar width proportional to marginal count n; segments stacked to 100 %.
const grandTotal = d3.sum(data, (d) => d.n);
let cursor = 0;
const bars = data.map((d) => {
  const barWidth = (d.n / grandTotal) * iw;
  const x0 = cursor;
  cursor += barWidth;
  let stack = 0;
  const segments = fillKeys.map((key) => {
    const pct = (d.counts[key] / d.n) * 100;
    const seg = { key, pct, y0: stack, y1: stack + pct };
    stack += pct;
    return seg;
  });
  return { ...d, x0, barWidth, segments };
});

const y = d3.scaleLinear().domain([0, 100]).range([ih, 0]);

// --- SVG mount -------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Y-axis (cumulative share) + gridlines ----------------------------------
const yAxis = g
  .append("g")
  .call(
    d3
      .axisLeft(y)
      .tickValues([0, 25, 50, 75, 100])
      .tickFormat((v) => `${v}%`)
      .tickSize(-iw)
  );
yAxis.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px").attr("x", -10);
yAxis.selectAll("line").attr("stroke", t.grid).attr("stroke-width", 1);
yAxis.select(".domain").remove();

g.append("text")
  .attr("x", -ih / 2)
  .attr("y", -62)
  .attr("transform", "rotate(-90)")
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "15px")
  .text("Share of group");

// --- Spine bars --------------------------------------------------------------
const barGroups = g
  .selectAll(".bar")
  .data(bars)
  .join("g")
  .attr("transform", (d) => `translate(${d.x0},0)`);

// Segments + in-segment percentage labels (barWidth is per-group, so both are
// drawn together in one pass over the bound bar datum).
barGroups.each(function (bar) {
  const group = d3.select(this);
  group
    .selectAll("rect")
    .data(bar.segments)
    .join("rect")
    .attr("x", 0)
    .attr("y", (seg) => y(seg.y1))
    .attr("width", bar.barWidth)
    .attr("height", (seg) => y(seg.y0) - y(seg.y1))
    .attr("fill", (seg) => fillColor[seg.key])
    .attr("stroke", t.pageBg)
    .attr("stroke-width", 1.5);

  bar.segments.forEach((seg) => {
    const segHeight = y(seg.y0) - y(seg.y1);
    if (segHeight < 34 || bar.barWidth < 50) return;
    group
      .append("text")
      .attr("x", bar.barWidth / 2)
      .attr("y", (y(seg.y1) + y(seg.y0)) / 2)
      .attr("text-anchor", "middle")
      .attr("dominant-baseline", "central")
      .attr("fill", textColorFor(fillColor[seg.key]))
      .style("font-size", "15px")
      .style("font-weight", "600")
      .text(`${Math.round(seg.pct)}%`);
  });
});

// Baseline under the bars.
g.append("line")
  .attr("x1", 0)
  .attr("x2", iw)
  .attr("y1", ih)
  .attr("y2", ih)
  .attr("stroke", t.inkSoft)
  .attr("stroke-width", 1);

// --- X-axis: category name + group size, centered under each variable-width bar
barGroups.each(function (bar) {
  const group = d3.select(this);
  const cx = bar.barWidth / 2;
  group
    .append("text")
    .attr("x", cx)
    .attr("y", ih + 32)
    .attr("text-anchor", "middle")
    .attr("fill", t.ink)
    .style("font-size", "16px")
    .style("font-weight", "600")
    .text(bar.category);
  group
    .append("text")
    .attr("x", cx)
    .attr("y", ih + 54)
    .attr("text-anchor", "middle")
    .attr("fill", t.inkSoft)
    .style("font-size", "13px")
    .text(`n = ${bar.n}`);
});

g.append("text")
  .attr("x", iw / 2)
  .attr("y", ih + 88)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "13px")
  .style("font-style", "italic")
  .text("Bar width is proportional to group size (n)");

// --- Legend (measured with getBBox for exact centering) ---------------------
const legend = svg.append("g");
const legendItems = fillKeys.map((key) => {
  const item = legend.append("g");
  item
    .append("rect")
    .attr("width", 22)
    .attr("height", 22)
    .attr("rx", 3)
    .attr("fill", fillColor[key]);
  item
    .append("text")
    .attr("x", 32)
    .attr("y", 16)
    .attr("fill", t.ink)
    .style("font-size", "15px")
    .text(key);
  return { node: item, width: item.node().getBBox().width };
});
const gap = 40;
const legendWidth = d3.sum(legendItems, (d) => d.width) + gap * (legendItems.length - 1);
let lx = (width - legendWidth) / 2;
legendItems.forEach((d) => {
  d.node.attr("transform", `translate(${lx},96)`);
  lx += d.width + gap;
});

// --- Title -------------------------------------------------------------------
const titleText = "Clinical Trial Outcome by Dosage · bar-spine · javascript · d3 · anyplot.ai";
const titleFontSize = Math.max(14, Math.round(22 * Math.min(1, 67 / titleText.length)));
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 46)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", `${titleFontSize}px`)
  .style("font-weight", "600")
  .text(titleText);

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/bar-spine/d3/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": "d3",
  "page": "https://anyplot.ai/bar-spine/javascript/d3",
  "hub": "https://anyplot.ai/bar-spine",
  "code_json": "https://api.anyplot.ai/specs/bar-spine/d3/code",
  "spec_json": "https://api.anyplot.ai/specs/bar-spine",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/javascript/d3/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/javascript/d3/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/javascript/d3/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/javascript/d3/plot-dark.html",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations