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.

#' 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
)
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.