Agile Sprint Burndown Chart — ggplot2

A sprint burndown chart tracks the amount of remaining work in an agile sprint over time, plotted as a step (or line) series that descends as tasks are completed. A straight ideal-burndown guideline runs diagonally from the total committed scope on day one down to zero on the final day, giving the team an at-a-glance reference for whether they are ahead of or behind schedule. It is one of the most widely used project-management visualizations in software development, surfacing scope creep, stalled progress, and end-of-sprint risk early enough to act on.

Agile Sprint Burndown Chart rendered with ggplot2

R source (ggplot2)

#' anyplot.ai
#' burndown-sprint: Agile Sprint Burndown Chart
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 88/100 | Created: 2026-06-14

library(ggplot2)
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"
INK_MUTED   <- if (THEME == "light") "#6B6A63" else "#A8A79F"

IMPRINT_PALETTE <- c(
    "#009E73",  # 1 — brand green (actual burndown)
    "#C475FD",  # 2 — lavender
    "#4467A3",  # 3 — blue (ideal line)
    "#BD8233",  # 4 — ochre
    "#AE3030",  # 5 — matte red
    "#2ABCCD",  # 6 — cyan
    "#954477",  # 7 — rose
    "#99B314"   # 8 — lime
)

ANYPLOT_AMBER <- "#DDCC77"  # scope-change marker (warning/caution)

# Sprint data — 10 working days, starting with 52 story points
# Scope change on day 4 (+8 points added)
sprint_days      <- 1:10
total_scope      <- 52
scope_change_day <- 4

# Ideal burndown: straight line from total_scope to 0 over 10 days
ideal_initial <- seq(total_scope, 0, length.out = 11)[sprint_days]

# Actual remaining work (step function — discrete completions)
actual_remaining <- c(52, 47, 43, 40, 56, 50, 44, 35, 22, 0)

df <- data.frame(
    day    = sprint_days,
    actual = actual_remaining,
    ideal  = ideal_initial
)

# Scope change annotation position (between day 4 and 5)
scope_change_x <- scope_change_day + 0.5

# Title
plot_title <- "burndown-sprint · r · ggplot2 · anyplot.ai"
title_chars <- nchar(plot_title)
title_size  <- max(8, round(12 * 67 / title_chars))

p <- ggplot(df, aes(x = day)) +

    # Weekend shading band (between working week 1 and 2)
    annotate(
        "rect",
        xmin = 5.5, xmax = 6.5,
        ymin = -Inf, ymax = Inf,
        fill = INK_SOFT, alpha = 0.08
    ) +

    # Ideal burndown line (dashed, blue) — mapped for legend
    geom_line(
        aes(y = ideal, color = "Ideal", linetype = "Ideal"),
        linewidth = 0.9
    ) +

    # Actual remaining work as step series — mapped for legend
    geom_step(
        aes(y = actual, color = "Actual", linetype = "Actual"),
        linewidth = 1.2
    ) +

    # Points on actual line for daily readings
    geom_point(
        aes(y = actual, color = "Actual"),
        size = 3.0, shape = 16, show.legend = FALSE
    ) +

    # Scope change marker — vertical dotted line in amber
    geom_vline(
        xintercept = scope_change_x,
        color      = ANYPLOT_AMBER,
        linewidth  = 0.8,
        linetype   = "dotted"
    ) +

    # Scope change annotation label
    annotate(
        "text",
        x        = scope_change_x + 0.12,
        y        = 54,
        label    = "+8 pts\nscope added",
        color    = ANYPLOT_AMBER,
        size     = 2.8,
        hjust    = 0,
        fontface = "bold"
    ) +

    # Weekend annotation
    annotate(
        "text",
        x     = 6.0,
        y     = 2,
        label = "weekend",
        color = INK_MUTED,
        size  = 2.2,
        angle = 90
    ) +

    # Native legend via color + linetype aesthetic mappings
    scale_color_manual(
        values = c("Actual" = IMPRINT_PALETTE[1], "Ideal" = IMPRINT_PALETTE[3]),
        name   = NULL,
        breaks = c("Actual", "Ideal"),
        guide  = guide_legend(
            override.aes = list(
                linetype  = c("solid", "dashed"),
                linewidth = c(1.2, 0.9),
                shape     = c(16, NA)
            )
        )
    ) +
    scale_linetype_manual(
        values = c("Actual" = "solid", "Ideal" = "dashed"),
        name   = NULL,
        guide  = guide_none()
    ) +

    # Axes
    scale_x_continuous(
        breaks = sprint_days,
        labels = paste0("Day ", sprint_days),
        expand = expansion(mult = c(0.03, 0.05))
    ) +
    scale_y_continuous(
        limits = c(0, 62),
        breaks = seq(0, 60, by = 10),
        expand = expansion(mult = c(0.02, 0.04))
    ) +

    labs(
        title = plot_title,
        x     = "Sprint Day",
        y     = "Remaining Story Points"
    ) +

    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 = INK_SOFT, linewidth = 0.2),
        panel.grid.minor     = element_blank(),
        panel.border         = element_blank(),
        axis.line            = element_line(color = INK_SOFT, linewidth = 0.4),
        axis.ticks           = element_line(color = INK_SOFT, linewidth = 0.3),
        axis.title           = element_text(color = INK,      size = 10),
        axis.text            = element_text(color = INK_SOFT, size = 8),
        axis.text.x          = element_text(angle = 30, hjust = 1),
        plot.title           = element_text(color = INK, size = title_size,
                                            margin = margin(b = 10)),
        plot.margin          = margin(20, 20, 15, 15),
        legend.position        = "inside",
        legend.position.inside = c(0.98, 0.98),
        legend.justification   = c(1, 1),
        legend.background    = element_rect(fill = PAGE_BG, color = NA),
        legend.key           = element_rect(fill = PAGE_BG, color = NA),
        legend.text          = element_text(color = INK_SOFT, size = 8),
        legend.key.width     = unit(2, "lines"),
        legend.margin        = margin(2, 4, 2, 4)
    )

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

Part of Agile Sprint Burndown Chart on anyplot.ai.

Other implementations