How to Create a Dumbbell Chart in ggplot2

Dumbbell charts are one of the most underutilized visualizations in data analysis. They display two values for each category connected by a line, resembling a dumbbell weight. This design makes them...

Key Insights

  • Dumbbell charts excel at visualizing change between two points by connecting start and end values with a line, making magnitude and direction of change immediately apparent
  • Build dumbbell charts in ggplot2 using geom_segment() for connecting lines and geom_point() for the endpoint markers, giving you complete control over styling
  • Sort categories by difference magnitude and use color strategically to highlight increases versus decreases, transforming raw data into actionable insights

Introduction to Dumbbell Charts

Dumbbell charts are one of the most underutilized visualizations in data analysis. They display two values for each category connected by a line, resembling a dumbbell weight. This design makes them perfect for showing before/after comparisons, tracking changes over time, or comparing two groups side by side.

Use dumbbell charts when you need to show the gap between two related values. They work exceptionally well for salary comparisons between genders, budget versus actual spending, survey results from different years, or performance metrics across time periods. The visual weight of the connecting line emphasizes the magnitude of change, while the direction is immediately obvious.

Unlike bar charts that show absolute values or line charts that track continuous change, dumbbell charts focus attention on the delta between exactly two points. This makes them ideal for executive dashboards and reports where decision-makers need to quickly identify which categories changed most significantly.

Setting Up Your Environment

You’ll need ggplot2 for visualization and dplyr for data manipulation. Install and load these packages:

# Install if needed
install.packages("ggplot2")
install.packages("dplyr")

# Load libraries
library(ggplot2)
library(dplyr)

Let’s create a dataset showing employee satisfaction scores from 2022 and 2024:

satisfaction_data <- data.frame(
  department = c("Engineering", "Sales", "Marketing", 
                 "HR", "Customer Support", "Finance"),
  score_2022 = c(72, 68, 75, 81, 65, 78),
  score_2024 = c(85, 71, 82, 79, 88, 82)
)

print(satisfaction_data)

This dataset represents a realistic scenario where you’d want to visualize departmental improvement or decline in satisfaction scores.

Basic Dumbbell Chart Implementation

The foundation of a dumbbell chart combines geom_segment() for the connecting lines and geom_point() for the endpoints. Here’s the basic structure:

ggplot(satisfaction_data) +
  geom_segment(
    aes(x = score_2022, xend = score_2024,
        y = department, yend = department),
    color = "gray70",
    size = 1
  ) +
  geom_point(aes(x = score_2022, y = department), 
             color = "#D55E00", size = 4) +
  geom_point(aes(x = score_2024, y = department), 
             color = "#009E73", size = 4) +
  labs(
    title = "Employee Satisfaction by Department",
    x = "Satisfaction Score",
    y = NULL
  ) +
  theme_minimal()

This code creates segments from the 2022 score to the 2024 score for each department, then adds colored points at each end. The orange points represent 2022, while green points show 2024 values. The gray connecting line makes the magnitude of change visible at a glance.

Customizing Colors and Aesthetics

Strategic color choices transform a basic chart into an insightful visualization. Let’s differentiate between improvements and declines:

# Calculate change direction
satisfaction_data <- satisfaction_data %>%
  mutate(
    change = score_2024 - score_2022,
    change_direction = ifelse(change >= 0, "Improved", "Declined")
  )

# Enhanced visualization
ggplot(satisfaction_data) +
  geom_segment(
    aes(x = score_2022, xend = score_2024,
        y = department, yend = department,
        color = change_direction),
    size = 1.5,
    alpha = 0.7
  ) +
  geom_point(aes(x = score_2022, y = department), 
             color = "#756BB1", size = 5, alpha = 0.8) +
  geom_point(aes(x = score_2024, y = department), 
             color = "#31A354", size = 5, alpha = 0.8) +
  scale_color_manual(
    values = c("Improved" = "#31A354", "Declined" = "#DE2D26")
  ) +
  labs(
    title = "Employee Satisfaction Changes: 2022 vs 2024",
    x = "Satisfaction Score",
    y = NULL,
    color = "Trend"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    legend.position = "top",
    panel.grid.major.y = element_blank(),
    panel.grid.minor = element_blank()
  )

This version color-codes the connecting lines based on whether satisfaction improved or declined, making patterns instantly recognizable. The larger point size and adjusted alpha values create visual hierarchy.

Adding Labels and Annotations

Data labels eliminate guesswork and make your charts more informative:

ggplot(satisfaction_data) +
  geom_segment(
    aes(x = score_2022, xend = score_2024,
        y = department, yend = department,
        color = change_direction),
    size = 1.5
  ) +
  geom_point(aes(x = score_2022, y = department), 
             color = "#756BB1", size = 5) +
  geom_point(aes(x = score_2024, y = department), 
             color = "#31A354", size = 5) +
  geom_text(
    aes(x = score_2022, y = department, label = score_2022),
    nudge_y = 0.3, size = 3.5, color = "#756BB1", fontface = "bold"
  ) +
  geom_text(
    aes(x = score_2024, y = department, label = score_2024),
    nudge_y = 0.3, size = 3.5, color = "#31A354", fontface = "bold"
  ) +
  scale_color_manual(
    values = c("Improved" = "#31A354", "Declined" = "#DE2D26"),
    guide = "none"
  ) +
  labs(
    title = "Employee Satisfaction Changes: 2022 vs 2024",
    subtitle = "Purple: 2022 scores | Green: 2024 scores",
    x = "Satisfaction Score (0-100)",
    y = NULL
  ) +
  theme_minimal(base_size = 13) +
  theme(
    panel.grid.major.y = element_blank(),
    panel.grid.minor = element_blank(),
    plot.title = element_text(face = "bold")
  )

The nudge_y parameter positions labels above points to prevent overlap. Using matching colors for labels and points creates visual consistency.

Advanced Techniques

Sorting categories by change magnitude reveals patterns more effectively:

# Reorder departments by change magnitude
satisfaction_data <- satisfaction_data %>%
  mutate(department = reorder(department, change))

ggplot(satisfaction_data) +
  geom_segment(
    aes(x = score_2022, xend = score_2024,
        y = department, yend = department,
        color = change_direction),
    size = 2
  ) +
  geom_point(aes(x = score_2022, y = department), 
             color = "#756BB1", size = 6) +
  geom_point(aes(x = score_2024, y = department), 
             color = "#31A354", size = 6) +
  geom_text(
    aes(x = score_2024 + 2, y = department, 
        label = paste0(ifelse(change > 0, "+", ""), change)),
    size = 4, fontface = "bold"
  ) +
  scale_color_manual(
    values = c("Improved" = "#31A354", "Declined" = "#DE2D26"),
    guide = "none"
  ) +
  labs(
    title = "Employee Satisfaction Changes by Department",
    subtitle = "Sorted by magnitude of change | Purple: 2022 | Green: 2024",
    x = "Satisfaction Score",
    y = NULL
  ) +
  theme_minimal(base_size = 14) +
  theme(
    panel.grid.major.y = element_blank(),
    panel.grid.minor = element_blank(),
    plot.title = element_text(face = "bold", size = 16)
  )

This sorted view immediately highlights Customer Support’s dramatic improvement and HR’s slight decline.

Real-World Use Case

Let’s create a polished chart for a quarterly business review showing sales performance across regions:

# Realistic sales data
sales_data <- data.frame(
  region = c("North America", "Europe", "Asia Pacific", 
             "Latin America", "Middle East", "Africa"),
  q1_revenue = c(2.4, 1.8, 1.5, 0.9, 0.7, 0.5),
  q4_revenue = c(3.1, 2.2, 2.3, 1.1, 0.8, 0.7)
) %>%
  mutate(
    change = q4_revenue - q1_revenue,
    pct_change = (change / q1_revenue) * 100,
    region = reorder(region, change)
  )

# Executive-ready visualization
ggplot(sales_data) +
  geom_segment(
    aes(x = q1_revenue, xend = q4_revenue,
        y = region, yend = region),
    color = "#525252",
    size = 1.8,
    lineend = "round"
  ) +
  geom_point(aes(x = q1_revenue, y = region), 
             color = "#3182BD", size = 7, shape = 21, 
             fill = "#DEEBF7", stroke = 2) +
  geom_point(aes(x = q4_revenue, y = region), 
             color = "#31A354", size = 7, shape = 21, 
             fill = "#E5F5E0", stroke = 2) +
  geom_text(
    aes(x = q4_revenue + 0.15, y = region, 
        label = sprintf("+%.1f%%", pct_change)),
    size = 4, fontface = "bold", color = "#31A354"
  ) +
  scale_x_continuous(
    labels = function(x) paste0("$", x, "M"),
    limits = c(0, 3.5),
    expand = c(0, 0)
  ) +
  labs(
    title = "Regional Revenue Growth: Q1 to Q4",
    subtitle = "Blue: Q1 Revenue | Green: Q4 Revenue | All figures in millions USD",
    x = NULL,
    y = NULL,
    caption = "Data as of Q4 2024"
  ) +
  theme_minimal(base_size = 14) +
  theme(
    panel.grid.major.y = element_blank(),
    panel.grid.minor = element_blank(),
    panel.grid.major.x = element_line(color = "#E0E0E0", linetype = "dotted"),
    plot.title = element_text(face = "bold", size = 18, margin = margin(b = 5)),
    plot.subtitle = element_text(color = "#525252", margin = margin(b = 15)),
    plot.caption = element_text(color = "#737373", hjust = 0),
    axis.text = element_text(color = "#525252"),
    plot.margin = margin(20, 20, 20, 20)
  )

This production-ready chart includes percentage change labels, currency formatting, and professional styling suitable for stakeholder presentations. The sorted regions immediately show Asia Pacific as the growth leader, while the visual design maintains clarity without clutter.

Dumbbell charts deliver maximum insight with minimal complexity. Master this visualization technique, and you’ll have a powerful tool for communicating change and comparison in your data stories.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.