Spine Plot for Two-Variable Proportions — ggplot2

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 ggplot2

Renders

R source (ggplot2)

#' anyplot.ai
#' bar-spine: Spine Plot for Two-Variable Proportions
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 91/100 | Created: 2026-09-02

library(ggplot2)
library(dplyr)
library(scales)
library(ragg)

set.seed(42)

# --- Theme tokens -------------------------------------------------------
THEME    <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG  <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
INK      <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT <- if (THEME == "light") "#4A4A44" else "#B8B7B0"
IMPRINT_PALETTE <- c(
  "#009E73", # 1 - brand green (semantic: retained / good)
  "#AE3030"  # 5 - matte red   (semantic: churned / bad)
)
SEGMENT_TEXT <- "#FFFDF6" # warm near-white, legible on both fills

# --- Data -----------------------------------------------------------------
# SaaS customer base split by subscription tier (bar width = tier size) and
# retention outcome over the last billing cycle (segment height = share).
TIER_LEVELS   <- c("Basic", "Standard", "Premium", "Enterprise")
STATUS_LEVELS <- c("Retained", "Churned")

df_counts <- tibble::tibble(
  tier   = factor(rep(TIER_LEVELS, each = 2), levels = TIER_LEVELS),
  status = factor(rep(STATUS_LEVELS, times = 4), levels = STATUS_LEVELS),
  count  = c(816, 384, 738, 162, 460, 40, 288, 12)
)

tier_totals <- df_counts %>%
  group_by(tier) %>%
  summarise(total = sum(count), .groups = "drop") %>%
  arrange(tier) %>%
  mutate(
    width = total / sum(total),
    xmax  = cumsum(width),
    xmin  = xmax - width,
    xmid  = (xmin + xmax) / 2
  )

spine_df <- df_counts %>%
  left_join(tier_totals, by = "tier") %>%
  group_by(tier) %>%
  arrange(tier, status) %>%
  mutate(
    prop = count / total,
    ymax = cumsum(prop),
    ymin = ymax - prop,
    ymid = (ymin + ymax) / 2
  ) %>%
  ungroup()

# --- Plot -------------------------------------------------------------------
plot_title <- "Customer Retention by Subscription Tier · bar-spine · r · ggplot2 · anyplot.ai"
title_fontsize <- max(8, round(12 * 67 / nchar(plot_title)))

p <- ggplot(spine_df) +
  geom_rect(
    aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax, fill = status),
    color = NA
  ) +
  geom_text(
    data = filter(spine_df, prop > 0.06),
    aes(x = xmid, y = ymid, label = percent(prop, accuracy = 1)),
    color = SEGMENT_TEXT, size = 3.2, fontface = "bold"
  ) +
  scale_fill_manual(
    values = c(Retained = IMPRINT_PALETTE[1], Churned = IMPRINT_PALETTE[2]),
    name = "Status"
  ) +
  scale_x_continuous(
    breaks = tier_totals$xmid, labels = tier_totals$tier,
    expand = c(0, 0)
  ) +
  scale_y_continuous(
    labels = percent_format(accuracy = 1),
    expand = expansion(mult = c(0, 0.02))
  ) +
  labs(
    title = plot_title,
    x = "Subscription Tier (bar width ∝ customer base)",
    y = "Share of Customers"
  ) +
  theme_minimal(base_size = 8) +
  theme(
    plot.background    = element_rect(fill = PAGE_BG, color = PAGE_BG),
    panel.background   = element_rect(fill = PAGE_BG, color = NA),
    panel.grid.major.x = element_blank(),
    panel.grid.minor   = element_blank(),
    panel.grid.major.y = element_line(color = INK, linewidth = 0.3),
    axis.title         = element_text(color = INK, size = 10),
    axis.text          = element_text(color = INK_SOFT, size = 8),
    axis.ticks         = element_blank(),
    plot.title         = element_text(color = INK, size = title_fontsize, face = "bold"),
    legend.text        = element_text(color = INK_SOFT, size = 8),
    legend.title       = element_text(color = INK, size = 10)
  )

# --- Save -------------------------------------------------------------------
ggsave(
  filename = sprintf("plot-%s.png", THEME),
  plot     = p,
  device   = ragg::agg_png,
  width    = 8,
  height   = 4.5,
  units    = "in",
  dpi      = 400
)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/bar-spine/ggplot2/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": "r",
  "library": "ggplot2",
  "page": "https://anyplot.ai/bar-spine/r/ggplot2",
  "hub": "https://anyplot.ai/bar-spine",
  "code_json": "https://api.anyplot.ai/specs/bar-spine/ggplot2/code",
  "spec_json": "https://api.anyplot.ai/specs/bar-spine",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/r/ggplot2/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/bar-spine/r/ggplot2/plot-dark.png",
  "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