User Retention Curve by Cohort — ggplot2

A line chart showing the percentage of retained users over time since signup, with separate curves for different cohorts. All curves start at 100% at time zero and typically exhibit exponential decay, revealing how well a product retains users over their lifecycle. By overlaying multiple cohorts, teams can visually compare whether retention is improving or degrading across signup periods.

User Retention Curve by Cohort rendered with ggplot2

R source (ggplot2)

#' anyplot.ai
#' line-retention-cohort: User Retention Curve by Cohort
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 90/100 | Created: 2026-06-20

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

set.seed(42)

# Theme tokens
THEME       <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG     <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
ELEVATED_BG <- if (THEME == "light") "#FFFDF6" else "#242420"
INK         <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT    <- if (THEME == "light") "#4A4A44" else "#B8B7B0"
INK_MUTED   <- if (THEME == "light") "#6B6A63" else "#A8A79F"
GRID_COLOR  <- scales::alpha(INK, 0.15)

# Imprint palette positions 1-4 for 4 categorical cohorts
COHORT_COLORS <- c("#009E73", "#C475FD", "#4467A3", "#BD8233")

# Data — 4 monthly signup cohorts tracked weekly for 12 weeks
cohort_info <- data.frame(
  cohort    = c("Jan 2025", "Feb 2025", "Mar 2025", "Apr 2025"),
  n_users   = c(1245L, 1387L, 1623L, 1891L),
  floor_pct = c(10.5, 12.5, 15.0, 18.0),
  decay     = c(0.26, 0.22, 0.18, 0.14),
  stringsAsFactors = FALSE
)

weeks <- 0:12

df <- do.call(rbind, lapply(seq_len(nrow(cohort_info)), function(i) {
  cp  <- cohort_info[i, ]
  # Exponential decay toward a floor; week 0 = exactly 100%
  ret   <- cp$floor_pct + (100 - cp$floor_pct) * exp(-cp$decay * weeks)
  noise <- c(0, rnorm(length(weeks) - 1, 0, 0.7))
  data.frame(
    week      = weeks,
    retention = pmax(0, pmin(100, ret + noise)),
    cohort    = cp$cohort,
    n_users   = cp$n_users
  )
}))

df$cohort <- factor(df$cohort, levels = cohort_info$cohort)

legend_labels <- setNames(
  paste0(cohort_info$cohort, " (n=", format(cohort_info$n_users, big.mark = ","), ")"),
  cohort_info$cohort
)

# Thinner lines for older cohorts → visual emphasis on recent improvement
lw_vals <- c(0.55, 0.70, 0.88, 1.10)

plot_title <- "line-retention-cohort · r · ggplot2 · anyplot.ai"

p <- ggplot(df, aes(x = week, y = retention, color = cohort, linewidth = cohort)) +
  # 20% retention benchmark reference
  geom_hline(
    yintercept = 20,
    linetype   = "dashed",
    color      = INK_MUTED,
    linewidth  = 0.35
  ) +
  annotate(
    "text",
    x = 0.2, y = 22.5,
    label    = "20% retention target",
    color    = INK_MUTED,
    size     = 2.5,
    hjust    = 0,
    fontface = "italic"
  ) +
  geom_line(lineend = "round") +
  scale_color_manual(
    values = COHORT_COLORS,
    labels = legend_labels,
    name   = "Signup Cohort"
  ) +
  scale_linewidth_manual(
    values = lw_vals,
    labels = legend_labels,
    name   = "Signup Cohort"
  ) +
  scale_x_continuous(
    breaks = seq(0, 12, by = 2),
    labels = paste0("Week ", seq(0, 12, by = 2)),
    expand = expansion(mult = c(0.02, 0.03))
  ) +
  scale_y_continuous(
    limits = c(0, 100),
    breaks = seq(0, 100, by = 20),
    labels = function(x) paste0(x, "%"),
    expand = expansion(mult = c(0.01, 0.03))
  ) +
  labs(
    title = plot_title,
    x     = "Weeks Since Signup",
    y     = "Retained Users (%)"
  ) +
  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  = element_line(color = GRID_COLOR, linewidth = 0.4),
    panel.grid.minor  = element_blank(),
    panel.border      = element_blank(),
    axis.line         = element_line(color = INK_SOFT,   linewidth = 0.35),
    axis.title        = element_text(color = INK,        size = 10),
    axis.text         = element_text(color = INK_SOFT,   size = 8),
    plot.title        = element_text(color = INK,        size = 12, face = "bold"),
    legend.background = element_rect(fill = ELEVATED_BG, color = INK_SOFT, linewidth = 0.3),
    legend.text       = element_text(color = INK_SOFT,   size = 8),
    legend.title      = element_text(color = INK,        size = 10),
    legend.position   = "right",
    legend.key.width  = unit(1.5, "cm"),
    plot.margin       = margin(15, 20, 10, 10)
  )

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

Part of User Retention Curve by Cohort on anyplot.ai.

Other implementations