Basic Icicle Chart — ggplot2

An icicle chart displaying hierarchical data as adjacent rectangles in a layered structure, where each rectangle's size represents its value in the hierarchy. Unlike treemaps that nest rectangles, icicle charts stack them in rows (horizontal) or columns (vertical), making parent-child relationships explicitly visible through spatial adjacency. This layout excels at showing both the hierarchy levels and the proportional values simultaneously.

Basic Icicle Chart rendered with ggplot2

Renders

R source (ggplot2)

#' anyplot.ai
#' icicle-basic: Basic Icicle Chart
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 91/100 | Created: 2026-09-05

library(ggplot2)
library(dplyr)
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"
NEUTRAL <- INK
IMPRINT_PALETTE <- c("#009E73", "#C475FD", "#4467A3", "#BD8233",
                     "#AE3030", "#2ABCCD", "#954477", "#99B314")

# --- Data: file system hierarchy (folder/file sizes in KB) ------------------
nodes <- tibble::tribble(
  ~id,                   ~parent,        ~value,
  "project",             NA,             NA,
  "src",                 "project",      NA,
  "docs",                "project",      NA,
  "tests",               "project",      NA,
  "assets",              "project",      NA,
  "config",              "project",      NA,
  "components",          "src",          NA,
  "utils",               "src",          NA,
  "hooks",               "src",          NA,
  "api",                 "src",          NA,
  "guides",              "docs",         NA,
  "api-docs",            "docs",         NA,
  "unit",                "tests",        NA,
  "integration",         "tests",        NA,
  "images",              "assets",       NA,
  "fonts",               "assets",       NA,
  "Button.tsx",          "components",   18,
  "Modal.tsx",           "components",   24,
  "Table.tsx",           "components",   31,
  "format.ts",           "utils",         9,
  "validate.ts",         "utils",        12,
  "useAuth.ts",          "hooks",         7,
  "useFetch.ts",         "hooks",         6,
  "client.ts",           "api",          15,
  "routes.ts",           "api",          11,
  "getting-started.md",  "guides",        8,
  "deployment.md",       "guides",       14,
  "endpoints.md",        "api-docs",     19,
  "Button.test.ts",      "unit",         10,
  "Table.test.ts",       "unit",         13,
  "auth.test.ts",        "integration",  22,
  "logo.svg",            "images",        5,
  "hero.png",            "images",       48,
  "Inter.woff2",         "fonts",        36,
  "ci.yml",              "config",        4,
  "build.config.js",     "config",        6,
  "eslint.config.js",    "config",       20,
  "tsconfig.json",       "config",       15
)

# Depth of each node from the root (iterative — resolves one generation per pass)
nodes$depth <- ifelse(is.na(nodes$parent), 0L, NA_integer_)
for (pass in 1:3) {
  parent_depth <- setNames(nodes$depth, nodes$id)
  pending <- is.na(nodes$depth)
  nodes$depth[pending] <- parent_depth[nodes$parent[pending]] + 1L
}
max_depth <- max(nodes$depth)

# Node size = own value for leaves, sum of children for folders (bottom-up)
nodes$size <- nodes$value
for (d in rev(0:(max_depth - 1))) {
  for (pid in nodes$id[nodes$depth == d]) {
    child_sizes <- nodes$size[nodes$parent == pid & !is.na(nodes$parent)]
    if (length(child_sizes) > 0) nodes$size[nodes$id == pid] <- sum(child_sizes)
  }
}

# Horizontal extent: each node's width is proportional to its size within
# the span its parent occupies (top-down pass fills children from parents)
nodes$x0 <- NA_real_
nodes$x1 <- NA_real_
nodes$x0[nodes$depth == 0] <- 0
nodes$x1[nodes$depth == 0] <- nodes$size[nodes$depth == 0]
for (d in 0:(max_depth - 1)) {
  for (pid in nodes$id[nodes$depth == d]) {
    child_idx <- which(nodes$parent == pid)
    if (length(child_idx) == 0) next
    parent_x0 <- nodes$x0[nodes$id == pid]
    parent_x1 <- nodes$x1[nodes$id == pid]
    child_sizes <- nodes$size[child_idx]
    cum_end <- cumsum(child_sizes) / sum(child_sizes) * (parent_x1 - parent_x0)
    nodes$x0[child_idx] <- parent_x0 + c(0, head(cum_end, -1))
    nodes$x1[child_idx] <- parent_x0 + cum_end
  }
}

# Vertical position: root row at top, deeper levels stack below it
nodes$y1 <- max_depth + 1 - nodes$depth
nodes$y0 <- nodes$y1 - 1

# Branch = the top-level folder each node descends from (root is its own branch)
nodes$branch <- NA_character_
nodes$branch[nodes$depth == 0] <- "root"
for (d in 1:max_depth) {
  for (nid in nodes$id[nodes$depth == d]) {
    nodes$branch[nodes$id == nid] <- if (d == 1) {
      nid
    } else {
      nodes$branch[nodes$id == nodes$parent[nodes$id == nid]]
    }
  }
}

px_per_unit <- 3200 / nodes$size[nodes$depth == 0]

nodes <- nodes %>%
  mutate(
    fill_color = case_when(
      branch == "root"   ~ NEUTRAL,
      branch == "src"    ~ IMPRINT_PALETTE[1],
      branch == "docs"   ~ IMPRINT_PALETTE[2],
      branch == "tests"  ~ IMPRINT_PALETTE[3],
      branch == "assets" ~ IMPRINT_PALETTE[4],
      branch == "config" ~ IMPRINT_PALETTE[5]
    ),
    depth_alpha = case_when(
      depth <= 1 ~ 1.00,
      depth == 2 ~ 0.75,
      depth == 3 ~ 0.55
    ),
    label_color = if_else(depth <= 1, PAGE_BG, INK),
    width = x1 - x0,
    # A label only fits if the rect is wider than its own text at render size —
    # avoids the classic icicle/treemap failure mode of text spilling past narrow cells.
    char_px = if_else(depth <= 1, 30, 24.3),
    required_width = (nchar(id) * char_px * 1.15) / px_per_unit,
    show_label = width >= required_width
  )

headers <- filter(nodes, show_label, depth <= 1)
leaves  <- filter(nodes, show_label, depth > 1)

# Focal point: the single largest terminal file gets an ink outline + bolder
# label so the viewer has one clear "biggest thing" to anchor on.
largest_id <- nodes %>%
  filter(!(id %in% nodes$parent)) %>%
  slice_max(value, n = 1) %>%
  pull(id)

highlight      <- filter(nodes, id == largest_id)
leaves_plain   <- filter(leaves, id != largest_id)
leaves_focal   <- filter(leaves, id == largest_id)

# --- Plot --------------------------------------------------------------------
title_text <- "icicle-basic · r · ggplot2 · anyplot.ai"

p <- ggplot(nodes) +
  geom_rect(
    aes(xmin = x0, xmax = x1, ymin = y0, ymax = y1,
        fill = fill_color, alpha = depth_alpha),
    color = PAGE_BG, linewidth = 0.4
  ) +
  geom_rect(
    data = highlight,
    aes(xmin = x0, xmax = x1, ymin = y0, ymax = y1),
    color = INK, fill = NA, linewidth = 1.1
  ) +
  geom_text(
    data = headers,
    aes(x = (x0 + x1) / 2, y = (y0 + y1) / 2, label = id, color = label_color),
    size = 3.2, fontface = "bold"
  ) +
  geom_text(
    data = leaves_plain,
    aes(x = (x0 + x1) / 2, y = (y0 + y1) / 2, label = id, color = label_color),
    size = 2.8
  ) +
  geom_text(
    data = leaves_focal,
    aes(x = (x0 + x1) / 2, y = (y0 + y1) / 2, label = id, color = label_color),
    size = 3.4, fontface = "bold"
  ) +
  scale_fill_identity() +
  scale_alpha_identity() +
  scale_color_identity() +
  scale_x_continuous(expand = c(0, 0)) +
  scale_y_continuous(expand = c(0, 0)) +
  labs(title = title_text) +
  theme_void(base_size = 8) +
  theme(
    plot.background = element_rect(fill = PAGE_BG, color = PAGE_BG),
    panel.background = element_rect(fill = PAGE_BG, color = NA),
    plot.title = element_text(color = INK, size = 12, hjust = 0, margin = margin(b = 10)),
    plot.margin = margin(t = 14, r = 18, b = 10, l = 18)
  )

# --- 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/icicle-basic/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": "icicle-basic",
  "language": "r",
  "library": "ggplot2",
  "page": "https://anyplot.ai/icicle-basic/r/ggplot2",
  "hub": "https://anyplot.ai/icicle-basic",
  "code_json": "https://api.anyplot.ai/specs/icicle-basic/ggplot2/code",
  "spec_json": "https://api.anyplot.ai/specs/icicle-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/icicle-basic/r/ggplot2/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/icicle-basic/r/ggplot2/plot-dark.png",
  "quality_score": 91.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Basic Icicle Chart on anyplot.ai.

Other implementations