Spine Plot for Two-Variable Proportions — Highcharts

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 Highcharts

Renders

JavaScript source (Highcharts)

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

const t = window.ANYPLOT_TOKENS;

// --- Data (in-memory, deterministic) ----------------------------------------
// SaaS subscription tiers: bar width = marginal customer count per tier,
// stacked segment heights = conditional retained/churned split within tier.
const TIERS = [
  { name: "Free", retained: 2800, churned: 1200 },
  { name: "Basic", retained: 2000, churned: 500 },
  { name: "Pro", retained: 1080, churned: 120 },
  { name: "Enterprise", retained: 288, churned: 12 },
];

// Highcharts' core bundle has no variable-width column series (that lives in
// the variwide module, which isn't loaded). A spine plot is built instead as
// a stacked, fully-opaque step-area chart on a linear x-axis: each tier gets
// two x-positions (its cumulative-width start/end) holding the same y value,
// so the area is flat across the tier's width and steps to the next tier's
// height at the boundary. A tiny epsilon keeps boundary x-values strictly
// increasing (exact duplicates would double-count in Highcharts' per-x
// stacking sum) while staying far below one screen pixel.
const EPS = 1;
let cursor = 0;
const segments = TIERS.map((tier, i) => {
  const total = tier.retained + tier.churned;
  const x0 = cursor;
  const x1 = cursor + total;
  cursor = x1;
  return {
    name: tier.name,
    x0,
    x1,
    center: (x0 + x1) / 2,
    startX: i === 0 ? x0 : x0 + EPS,
    endX: i === TIERS.length - 1 ? x1 : x1 - EPS,
    retainedPct: (tier.retained / total) * 100,
    churnedPct: (tier.churned / total) * 100,
  };
});
const totalWidth = cursor;

function findSegment(x) {
  return (
    segments.find((s) => x >= s.x0 - EPS && x <= s.x1 + EPS) ||
    segments[segments.length - 1]
  );
}

// Percentage data labels are a secondary, non-color cue for telling the
// Retained/Churned bands apart (mitigates red-green CVD ambiguity) and
// satisfy the spec's "consider adding percentage labels when space permits"
// note. Only the segment's center point (inserted below, between its flat
// startX/endX run) carries a label, and only when the band's own height
// leaves enough room to render one legibly.
const MIN_LABEL_SHARE = 8;
const labelCenterXs = new Set(segments.map((s) => s.center));
function segmentLabelFormatter() {
  if (!labelCenterXs.has(this.x) || this.y < MIN_LABEL_SHARE) return null;
  return `${Math.round(this.y)}%`;
}
const labelStyle = {
  color: "#FFFFFF",
  fontSize: "13px",
  fontWeight: "600",
  textOutline: "1px rgba(26, 26, 23, 0.55)",
};

// Retained (good) keeps the brand-green first slot; churned (bad) takes the
// semantic-red anchor rather than the next ordinal palette position.
const series = [
  {
    name: "Retained",
    color: t.palette[0],
    data: segments.flatMap((s) => [
      { x: s.startX, y: s.retainedPct },
      { x: s.center, y: s.retainedPct },
      { x: s.endX, y: s.retainedPct },
    ]),
  },
  {
    name: "Churned",
    color: t.palette[4],
    data: segments.flatMap((s) => [
      { x: s.startX, y: s.churnedPct },
      { x: s.center, y: s.churnedPct },
      { x: s.endX, y: s.churnedPct },
    ]),
  },
];

// --- Chart -------------------------------------------------------------------
const titleText =
  "SaaS Subscription Churn by Tier · bar-spine · javascript · highcharts · anyplot.ai";
const titleRatio = titleText.length > 67 ? 67 / titleText.length : 1;
const titleFontSize = Math.max(14, Math.round(22 * titleRatio));

Highcharts.chart("container", {
  chart: {
    type: "area",
    backgroundColor: "transparent",
    animation: false,
    style: { fontFamily: "inherit" },
  },
  credits: { enabled: false },
  colors: t.palette,
  title: {
    text: titleText,
    style: { color: t.ink, fontSize: `${titleFontSize}px`, fontWeight: "600" },
  },
  xAxis: {
    min: 0,
    max: totalWidth,
    title: {
      text: "Subscription tier — width ∝ customer count",
      style: { color: t.inkSoft, fontSize: "16px" },
    },
    tickPositions: segments.map((s) => s.center),
    gridLineWidth: 0,
    lineColor: t.inkSoft,
    tickColor: t.inkSoft,
    tickLength: 6,
    labels: {
      formatter: function () {
        return findSegment(this.value).name;
      },
      style: { color: t.inkSoft, fontSize: "14px" },
    },
  },
  yAxis: {
    min: 0,
    max: 100,
    tickInterval: 20,
    title: {
      text: "Share of tier (%)",
      style: { color: t.inkSoft, fontSize: "16px" },
    },
    gridLineColor: t.grid,
    labels: {
      format: "{value}%",
      style: { color: t.inkSoft, fontSize: "14px" },
    },
  },
  legend: {
    itemStyle: { color: t.inkSoft, fontSize: "14px" },
    itemHoverStyle: { color: t.ink },
    symbolRadius: 2,
    itemDistance: 24,
    margin: 20,
  },
  tooltip: {
    shared: true,
    valueDecimals: 1,
    valueSuffix: "%",
    backgroundColor: t.elevatedBg,
    borderColor: t.grid,
    style: { color: t.ink, fontSize: "13px" },
    formatter: function () {
      const seg = findSegment(this.x);
      const lines = [`<b>${seg.name}</b>`];
      this.points.forEach((p) => {
        lines.push(`${p.series.name}: ${p.y.toFixed(1)}%`);
      });
      return lines.join("<br/>");
    },
  },
  plotOptions: {
    series: { animation: false },
    area: {
      stacking: "normal",
      fillOpacity: 1,
      lineWidth: 1,
      lineColor: t.pageBg,
      marker: { enabled: false },
      states: { hover: { enabled: false } },
      dataLabels: {
        enabled: true,
        formatter: segmentLabelFormatter,
        style: labelStyle,
      },
    },
  },
  series,
});

Retrieve this implementation

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

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

Other implementations