Spine Plot for Two-Variable Proportions — MUI X Charts

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: 91/100 | Created: 2026-09-02
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
// Retained/Churned split doesn't rely on the green/red 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", 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 churned vs. retained.
// Bar WIDTH encodes each tier's marginal share of the customer base; segment
// HEIGHT encodes the conditional churn/retention split within that tier.
const tiers = [
  { name: "Free", churned: 1664, retained: 3536 },
  { name: "Basic", churned: 651, retained: 2449 },
  { name: "Pro", churned: 216, retained: 1584 },
  { name: "Enterprise", churned: 42, retained: 658 },
];

const totals = tiers.map((d) => d.churned + 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 retainPct = churnPct.map((pct) => Math.round((100 - pct) * 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));

// --- 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.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 }}>
        <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: 8, right: 0, bottom: 26, left: i === 0 ? TICK_W : 0 }}
            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: "churned",
                data: [churnPct[i]],
                label: "Churned",
                stack: "total",
                color: t.palette[4],
              },
            ]}
            barLabel={segmentLabel}
            slotProps={{ legend: { hidden: true }, barLabel: labelSlotProps }}
          />
        ))}
      </div>

      <div style={{ height: CAPTION_H, fontSize: 12, fontStyle: "italic", color: INK_MUTED }}>
        Bar width ∝ tier size · segment height = conditional churn/retention 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": 91.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations