Spine Plot for Two-Variable Proportions in MUI X Charts (JavaScript)

The same plot in 14 other libraries — JavaScript: Chart.js, D3.js, Apache ECharts, Highcharts; Python: Altair, Bokeh, lets-plot, Matplotlib, Plotly, plotnine, Pygal, Seaborn; R: ggplot2; Julia: Makie.jl. Compare all 15 side by side: Spine Plot for Two-Variable Proportions in Python, R, Julia and JavaScript.

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 MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// bar-spine: Spine Plot for Two-Variable Proportions
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 95/100 | Updated: 2026-09-27
import { BarChart } from "@mui/x-charts/BarChart";

const t = window.ANYPLOT_TOKENS;
const size = window.ANYPLOT_SIZE;
const INK_MUTED = window.ANYPLOT_THEME === "light" ? "#6B6A63" : "#A8A79F";

// Segment labels: a redundant text cue on top of stacking position so the
// three-way split doesn't rely on hue alone. Fixed (theme-independent) fill
// colors are picked per series for contrast against that series' fixed data
// color, not the page theme.
const MIN_LABEL_HEIGHT = 22; // px; hide the label if a segment can't fit one line of text
const segmentLabel = (item, context) =>
  context.bar.height < MIN_LABEL_HEIGHT ? null : `${item.value}%`;
const LABEL_FILL = { retained: "#1A1A17", downgraded: "#1A1A17", churned: "#FAF8F1" };
const labelSlotProps = (ownerState) => ({
  style: { fill: LABEL_FILL[ownerState.seriesId], fontSize: 12, fontWeight: 600 },
});

// --- Data (in-memory, deterministic) ----------------------------------------
// Customer counts per subscription tier, split into three outcomes: churned
// (cancelled), downgraded (moved to a cheaper tier but stayed a customer),
// and retained (stayed at the same tier). Bar WIDTH encodes each tier's
// marginal share of the customer base; segment HEIGHT encodes the
// conditional outcome split within that tier — the three-category split
// showcases the spine plot's conditional-distribution feature better than a
// binary churned/retained split would.
const tiers = [
  { name: "Free", churned: 1664, downgraded: 900, retained: 2636 },
  { name: "Basic", churned: 651, downgraded: 500, retained: 1949 },
  { name: "Pro", churned: 216, downgraded: 180, retained: 1404 },
  { name: "Enterprise", churned: 42, downgraded: 20, retained: 638 },
];

const totals = tiers.map((d) => d.churned + d.downgraded + d.retained);
const grandTotal = totals.reduce((sum, v) => sum + v, 0);
const churnPct = tiers.map((d, i) => Math.round((d.churned / totals[i]) * 1000) / 10);
const downgradePct = tiers.map((d, i) => Math.round((d.downgraded / totals[i]) * 1000) / 10);
// Retained is the remainder, guaranteeing each stack sums to exactly 100%.
const retainPct = tiers.map((_, i) => Math.round((100 - churnPct[i] - downgradePct[i]) * 10) / 10);

// --- Layout: variable-width columns, each a single 100%-stacked bar --------
const PAD_H = 32;
const PAD_V = 28;
const GAP = 10;
const AXIS_LABEL_W = 26; // rotated "Share of customers" caption, drawn outside the chart SVGs
const TICK_W = 52; // reserved for the shared y-axis tick numbers, drawn only on the first column
const TITLE_H = 34;
const LEGEND_H = 28;
const CAPTION_H = 24;

const rowWidth = size.width - PAD_H * 2;
const barAreaWidth = rowWidth - AXIS_LABEL_W - TICK_W;
const barWidths = totals.map((total) => Math.round((barAreaWidth * total) / grandTotal));
barWidths[barWidths.length - 1] += barAreaWidth - barWidths.reduce((sum, w) => sum + w, 0);
const chartWidths = barWidths.map((w, i) => (i === 0 ? TICK_W + w : w));
const chartHeight = size.height - PAD_V * 2 - TITLE_H - LEGEND_H - CAPTION_H - GAP * 3;

const title = "Customer Churn by Subscription Tier · bar-spine · javascript · muix · anyplot.ai";
const titleFontSize = Math.round(18 * (title.length > 67 ? 67 / title.length : 1));

// Amber (downgraded) falls below WCAG 3:1 contrast on the cream background —
// a thin ink-color stroke on that series only keeps the segment boundary
// legible without adding noise to the higher-contrast green/red segments.
const barSx = { "& .MuiBarElement-series-downgraded": { stroke: t.ink, strokeWidth: 1 } };

// Reference ticks at 25/50/75%, drawn as an overlay ON TOP of the bars (MUI
// X's own `grid` prop paints behind the bar fills, invisible on a
// fully-opaque 100%-stack) so readers can gauge the wide Free bar's segment
// boundaries without tracing back to the shared y-axis. Kept short and
// confined to the axis edge of the (widest) Free bar — its percentage labels
// sit horizontally centered, well clear of this reach — so the ticks never
// cross a label.
const MARGIN_TOP = 8;
const MARGIN_BOTTOM = 26;
const REF_TICK_W = 130;
const plotAreaHeight = chartHeight - MARGIN_TOP - MARGIN_BOTTOM;
const refLines = [75, 50, 25].map((pct) => MARGIN_TOP + ((100 - pct) / 100) * plotAreaHeight);

// --- Chart (default-exported component — the harness mounts it) -------------
export default function Chart() {
  return (
    <div
      style={{
        width: size.width,
        height: size.height,
        padding: `${PAD_V}px ${PAD_H}px`,
        boxSizing: "border-box",
        display: "flex",
        flexDirection: "column",
        gap: GAP,
        fontFamily: "system-ui, sans-serif",
      }}
    >
      <div style={{ height: TITLE_H, fontSize: titleFontSize, fontWeight: 600, color: t.ink }}>
        {title}
      </div>

      <div
        style={{
          height: LEGEND_H,
          display: "flex",
          alignItems: "center",
          gap: 20,
          fontSize: 13,
          color: t.inkSoft,
        }}
      >
        <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
          <span style={{ width: 11, height: 11, background: t.palette[0], display: "inline-block" }} />
          Retained
        </span>
        <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
          <span
            style={{
              width: 11,
              height: 11,
              background: t.amber,
              border: `1px solid ${t.ink}`,
              boxSizing: "border-box",
              display: "inline-block",
            }}
          />
          Downgraded
        </span>
        <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
          <span style={{ width: 11, height: 11, background: t.palette[4], display: "inline-block" }} />
          Churned
        </span>
        <span style={{ marginLeft: "auto" }}>n = {grandTotal.toLocaleString("en-US")} customers</span>
      </div>

      <div style={{ display: "flex", height: chartHeight, position: "relative" }}>
        <div
          style={{
            width: AXIS_LABEL_W,
            height: chartHeight,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            flexShrink: 0,
          }}
        >
          <span
            style={{
              display: "inline-block",
              transform: "rotate(-90deg)",
              whiteSpace: "nowrap",
              fontSize: 13,
              color: t.inkSoft,
            }}
          >
            Share of customers
          </span>
        </div>
        {tiers.map((tier, i) => (
          <BarChart
            key={tier.name}
            width={chartWidths[i]}
            height={chartHeight}
            skipAnimation
            margin={{ top: MARGIN_TOP, right: 0, bottom: MARGIN_BOTTOM, left: i === 0 ? TICK_W : 0 }}
            sx={barSx}
            xAxis={[
              {
                scaleType: "band",
                data: [tier.name],
                categoryGapRatio: 0,
                tickLabelStyle: { fontSize: 13 },
                disableTicks: true,
                disableLine: true,
              },
            ]}
            yAxis={[
              {
                min: 0,
                max: 100,
                disableTicks: i !== 0,
                disableLine: i !== 0,
                valueFormatter: i === 0 ? (v) => `${v}%` : () => "",
                tickLabelStyle: { fontSize: 12 },
              },
            ]}
            series={[
              {
                id: "retained",
                data: [retainPct[i]],
                label: "Retained",
                stack: "total",
                color: t.palette[0],
              },
              {
                id: "downgraded",
                data: [downgradePct[i]],
                label: "Downgraded",
                stack: "total",
                color: t.amber,
              },
              {
                id: "churned",
                data: [churnPct[i]],
                label: "Churned",
                stack: "total",
                color: t.palette[4],
              },
            ]}
            barLabel={segmentLabel}
            slotProps={{ legend: { hidden: true }, barLabel: labelSlotProps }}
          />
        ))}
        <div
          style={{
            position: "absolute",
            left: AXIS_LABEL_W + TICK_W,
            width: REF_TICK_W,
            top: 0,
            height: chartHeight,
            pointerEvents: "none",
          }}
        >
          {refLines.map((top) => (
            <div
              key={top}
              style={{ position: "absolute", left: 0, right: 0, top, borderTop: `1px solid ${t.grid}` }}
            />
          ))}
        </div>
      </div>

      <div style={{ height: CAPTION_H, fontSize: 12, fontStyle: "italic", color: INK_MUTED }}>
        Bar width ∝ tier size · segment height = conditional retained/downgraded/churned rate within
        tier
      </div>
    </div>
  );
}

Retrieve this implementation

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

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

Other implementations