Lollipop Chart in R: A Cleaner Alternative to Bar Charts

A lollipop chart is a bar chart stripped down to its essentials, a thin line (stem) topped with a dot, built in ggplot2 with geom_segment() and geom_point().

Introduction

Bar charts are workhorses. They communicate magnitude clearly. But when you have 15-25 categories, a dense forest of filled bars becomes visually noisy, the bars dominate the ink budget, and the actual values (the tops of the bars) are lost in the clutter.

A lollipop chart solves this. By replacing the filled bar with a thin line and a single dot, you reduce visual noise and make the data points themselves the focal element. The reader's eye goes directly to the dot, not to a wall of colored rectangles.

The tradeoff is that lollipops are slightly less precise than bars for length comparison, humans are better at comparing bar lengths than dot positions along a line. But when you have many categories and want a clean ranked visualization, lollipops are often the better choice.

How do you create a basic lollipop chart in R?

A lollipop chart needs two geoms: geom_segment() for the stem (from 0 to the value) and geom_point() for the dot at the top.

RBasic lollipop chart of temperatures
library(ggplot2) # Sample: average temperature by month df <- data.frame( month = month.abb, temp = c(2, 4, 8, 13, 17, 21, 24, 23, 18, 12, 7, 3) ) df$month <- factor(df$month, levels = month.abb) # Basic lollipop chart p_basic <- ggplot(df, aes(x = month, y = temp)) + geom_segment( aes(x = month, xend = month, y = 0, yend = temp), color = "grey60", linewidth = 0.8 ) + geom_point(color = "steelblue", size = 4) + labs( title = "Average Monthly Temperature", x = NULL, y = "Temperature (°C)" ) + theme_minimal() p_basic

  

geom_segment() takes four coordinates: x, xend (same category on both ends), y = 0 (baseline), and yend = temp (the value). geom_point() then places a dot at (month, temp).

Try it: Change geom_point(color = "steelblue", size = 4) to geom_point(shape = 21, fill = "steelblue", color = "white", size = 5, stroke = 1.5). Shape 21 is a hollow circle you can fill, the white stroke creates a clean border.

How do you order a lollipop chart by value?

When categories have no natural order (months, countries, products), ordering by value turns a random-looking plot into a clear ranking.

ROrder lollipops by value
# Reorder categories by temperature value df_ordered <- df df_ordered$month <- reorder(df_ordered$month, df_ordered$temp) p_ordered <- ggplot(df_ordered, aes(x = month, y = temp)) + geom_segment( aes(x = month, xend = month, y = 0, yend = temp), color = "grey60", linewidth = 0.8 ) + geom_point(color = "steelblue", size = 4) + labs( title = "Monthly Temperature, Ranked Lowest to Highest", x = NULL, y = "Temperature (°C)" ) + theme_minimal() p_ordered

  

reorder(month, temp) reorders the factor levels of month by ascending temp. The chart now reads left-to-right from coldest to warmest.

Try it: Change reorder(df_ordered$month, df_ordered$temp) to reorder(df_ordered$month, -df_ordered$temp) to sort descending (highest first).

How do you make a horizontal lollipop chart?

When category names are long, country names, product names, multi-word labels, flip the axes so labels run horizontally and are readable without tilting your head.

RHorizontal lollipop for long labels
# Horizontal lollipop: switch x and y, use long category names countries <- data.frame( country = c("Norway", "Switzerland", "Ireland", "Germany", "Denmark", "Austria", "Belgium", "Luxembourg", "Sweden", "Australia"), hdi = c(0.961, 0.962, 0.945, 0.942, 0.948, 0.916, 0.937, 0.930, 0.947, 0.946) ) countries$country <- reorder(countries$country, countries$hdi) p_horiz <- ggplot(countries, aes(x = country, y = hdi)) + geom_segment( aes(x = country, xend = country, y = 0.9, yend = hdi), color = "grey60", linewidth = 0.8 ) + geom_point(color = "#1565C0", size = 4) + coord_flip() + # flip to horizontal labs( title = "Human Development Index, Top Countries", x = NULL, y = "HDI Score" ) + theme_minimal() p_horiz

  

coord_flip() swaps x and y axes, country names now run vertically on the left, values extend horizontally. Note that y = 0.9 in geom_segment() sets the baseline at 0.9 (not 0), since all HDI values are close to 1. Starting from 0 would make tiny differences invisible.

Try it: Change the baseline from y = 0.9 to y = 0 to see how a zero baseline changes the apparent differences between countries.

How do you color lollipops above and below a threshold?

Coloring by a condition (above/below average, above/below zero, pass/fail threshold) adds a semantic layer that helps readers immediately see which categories are "good" vs "bad".

RColor above and below average
# Color by above/below average temperature avg_temp <- mean(df$temp) df$status <- ifelse(df$temp >= avg_temp, "Above average", "Below average") p_color <- ggplot(df, aes(x = month, y = temp, color = status)) + geom_hline(yintercept = avg_temp, linetype = "dashed", color = "grey50", linewidth = 0.6) + geom_segment( aes(x = month, xend = month, y = avg_temp, yend = temp), linewidth = 0.8 ) + geom_point(size = 4) + scale_color_manual( values = c("Above average" = "#E53935", "Below average" = "#1565C0") ) + annotate("text", x = 0.7, y = avg_temp + 0.8, label = paste0("Avg: ", round(avg_temp, 1), "°C"), size = 3.5, color = "grey40", hjust = 0) + labs( title = "Monthly Temperature vs Annual Average", x = NULL, y = "Temperature (°C)", color = NULL ) + theme_minimal() + theme(legend.position = "top") p_color

  

The stems now start at avg_temp (not 0) and extend to each value. Red dots are above average, blue are below, the diverging stems from the reference line make the pattern immediately visible.

Try it: Change the reference from avg_temp to 15 (a round number) and see how choosing a different threshold changes the story.

How do you create a diverging lollipop chart?

A diverging lollipop encodes positive values pointing right (or up) and negative values pointing left (or down). It's perfect for showing change, deviation, or gain/loss.

RDiverging lollipop for gains and losses
# Diverging lollipop: year-over-year change by sector div_df <- data.frame( sector = c("Technology", "Healthcare", "Finance", "Energy", "Consumer", "Utilities", "Materials", "Industrials"), change = c(18.5, 12.3, -4.1, -9.7, 6.2, -2.8, 3.4, 8.1) ) div_df$sector <- reorder(div_df$sector, div_df$change) div_df$direction <- ifelse(div_df$change >= 0, "Positive", "Negative") p_diverg <- ggplot(div_df, aes(x = sector, y = change, color = direction)) + geom_hline(yintercept = 0, color = "grey40", linewidth = 0.5) + geom_segment( aes(x = sector, xend = sector, y = 0, yend = change), linewidth = 0.9 ) + geom_point(size = 4) + geom_text( aes(label = paste0(ifelse(change > 0, "+", ""), change, "%")), hjust = ifelse(div_df$change >= 0, -0.3, 1.3), size = 3.2, fontface = "bold" ) + scale_color_manual(values = c("Positive" = "#2E7D32", "Negative" = "#C62828")) + coord_flip() + labs( title = "Sector Performance, Year-over-Year Change", subtitle = "Percentage change vs. prior year", x = NULL, y = "Change (%)", color = NULL ) + theme_minimal() + theme(legend.position = "none", panel.grid.major.y = element_blank()) p_diverg

  

Value labels are placed to the right of positive dots and to the left of negative dots using hjust = ifelse(change >= 0, -0.3, 1.3). This keeps labels from overlapping the stems.

Try it: Remove + theme(legend.position = "none") to show the legend. Then try removing the geom_text() layer entirely, do you still know which bars are positive vs negative? This is the difference between encoding the sign (stem direction) and annotating the magnitude.

Complete Example: Polished Lollipop Chart

RPolished lollipop highlighting R
# Polished lollipop: programming language popularity pop_df <- data.frame( language = c("Python", "JavaScript", "TypeScript", "Java", "C#", "C++", "PHP", "Shell", "R", "Swift"), usage_pct = c(51.1, 62.3, 38.5, 30.6, 27.1, 23.0, 18.2, 19.8, 12.9, 9.5) ) pop_df$language <- reorder(pop_df$language, pop_df$usage_pct) # Highlight R pop_df$highlight <- pop_df$language == "R" p_final <- ggplot(pop_df, aes(x = language, y = usage_pct)) + geom_segment( aes(x = language, xend = language, y = 0, yend = usage_pct, color = highlight), linewidth = 0.9, show.legend = FALSE ) + geom_point( aes(color = highlight), size = 4.5, show.legend = FALSE ) + geom_text( aes(label = paste0(usage_pct, "%")), hjust = -0.4, size = 3.2, color = "grey30" ) + scale_color_manual(values = c("FALSE" = "steelblue", "TRUE" = "#E53935")) + coord_flip() + scale_y_continuous(limits = c(0, 75), labels = function(x) paste0(x, "%")) + labs( title = "Programming Language Popularity", subtitle = "% of developers using the language (Stack Overflow 2023)", x = NULL, y = NULL ) + theme_minimal(base_size = 13) + theme( plot.title = element_text(face = "bold"), plot.subtitle = element_text(color = "grey50", size = 11), panel.grid.major.y = element_blank(), axis.text.y = element_text(face = ifelse(levels(pop_df$language) == "R", "bold", "plain")) ) p_final

  

Common Mistakes and How to Fix Them

Mistake 1: Forgetting to set the baseline to 0 (or the right reference)

❌ Omitting y = 0 in geom_segment() makes stems appear as floating lines.

RCommon mistake: missing baseline y
# Wrong: y must be the baseline geom_segment(aes(x = cat, xend = cat, yend = value))

  

✅ Always specify both endpoints:

RCorrect segment with both endpoints
# Correct geom_segment(aes(x = cat, xend = cat, y = 0, yend = value))

  

Mistake 2: Not ordering categories

❌ Alphabetical or arbitrary category order makes it impossible to see ranking.

RCommon mistake: alphabetical order
# Wrong: default alphabetical order ggplot(df, aes(x = country, y = gdp))

  

✅ Reorder by value when categories have no natural order.

RCorrect order using reorder
# Correct df$country <- reorder(df$country, df$gdp)

  

Mistake 3: Using lollipops for time series

Lollipops imply ranked comparison. For temporal data (months, years), a line chart communicates the trend better. Lollipops work for time when you want to emphasize individual period values rather than trend.

Mistake 4: Tiny dots on thick stems

When size (dot) is smaller than linewidth (stem), the dot disappears behind the stem. Keep the dot visually dominant.

RCommon mistake: stem too thick
# Wrong: stem too thick relative to dot geom_segment(linewidth = 3) + geom_point(size = 2) # Correct: dot should be visually larger than stem geom_segment(linewidth = 0.8) + geom_point(size = 4)

  

Mistake 5: Overlapping x-axis labels

Long category labels on a vertical lollipop chart get clipped or overlap. Fix: use coord_flip() for horizontal layout, or rotate labels with theme(axis.text.x = element_text(angle = 45, hjust = 1)).

Practice Exercises

Exercise 1: Ranked lollipop

Create a horizontal lollipop chart using the mtcars dataset, showing average mpg by number of cylinders (cyl). Order by mpg descending (highest mpg on top).

Show solution
RCylinder MPG lollipop solution
library(ggplot2) cyl_mpg <- aggregate(mpg ~ cyl, data = mtcars, FUN = mean) cyl_mpg$cyl <- paste(cyl_mpg$cyl, "cylinders") cyl_mpg$cyl <- reorder(cyl_mpg$cyl, cyl_mpg$mpg) ggplot(cyl_mpg, aes(x = cyl, y = mpg)) + geom_segment(aes(x = cyl, xend = cyl, y = 0, yend = mpg), color = "grey60", linewidth = 0.9) + geom_point(color = "steelblue", size = 5) + coord_flip() + labs(title = "Average MPG by Cylinder Count", x = NULL, y = "Miles per Gallon") + theme_minimal()

  

Exercise 2: Diverging lollipop for deviations

Using airquality, compute the average Temp per month, then calculate each month's deviation from the overall mean. Create a diverging lollipop chart (horizontal) with green for above-average months and orange for below-average months.

Show solution
RAirquality diverging lollipop solution
library(ggplot2) monthly <- aggregate(Temp ~ Month, data = airquality, FUN = mean) monthly$dev <- monthly$Temp - mean(monthly$Temp) monthly$direction <- ifelse(monthly$dev >= 0, "Above", "Below") monthly$Month <- factor(month.abb[monthly$Month], levels = month.abb[monthly$Month]) monthly$Month <- reorder(monthly$Month, monthly$dev) ggplot(monthly, aes(x = Month, y = dev, color = direction)) + geom_hline(yintercept = 0, color = "grey40", linewidth = 0.5) + geom_segment(aes(x = Month, xend = Month, y = 0, yend = dev), linewidth = 0.9) + geom_point(size = 4) + scale_color_manual(values = c("Above" = "#2E7D32", "Below" = "#E65100")) + coord_flip() + labs(title = "Monthly Temperature Deviation from Mean", subtitle = "New York, 1973 (airquality dataset)", x = NULL, y = "Deviation (°F)", color = NULL) + theme_minimal() + theme(legend.position = "top")

  

Summary

Task Code
Basic lollipop geom_segment(aes(y=0, yend=val)) + geom_point()
Order by value reorder(category, value)
Horizontal layout + coord_flip()
Color by condition ifelse() variable + scale_color_manual()
Diverging lollipop Baseline at 0, stems extend positive/negative
Value labels geom_text(aes(label = val), hjust = -0.3)

Lollipop vs bar chart:

  • Use a lollipop when you have many categories (15+), when the visual clutter of filled bars is distracting, or when you want to emphasize the value position rather than the bar fill area.
  • Use a bar chart when precise length comparison matters, when you need filled color for grouping, or when your audience is less familiar with lollipop charts.

FAQ

What is the difference between a lollipop chart and a Cleveland dot plot? A Cleveland dot plot shows dots only, no stems. It's used to compare multiple groups per category (two dots per row, one per group). A lollipop has a stem from zero and is used for single-variable ranked comparisons.

Can I add multiple lollipops per category (grouped lollipops)? Yes, use position_dodge() in both geom_segment() and geom_point(), similar to a grouped bar chart. But more than 2 groups gets cluttered; consider small multiples instead.

How do I add value labels that don't overlap the dots? Use hjust = -0.3 for vertical lollipops (labels to the right of dots) or hjust = 1.3 for labels to the left. For horizontal charts, use vjust = -0.5 (above) or vjust = 1.5 (below).

Is there a dedicated lollipop geom in ggplot2? No, you compose it from geom_segment() + geom_point(). The ggalt package has geom_lollipop() as a convenience wrapper, but the base ggplot2 approach gives more flexibility.

References

  • Wickham H. (2016). ggplot2: Elegant Graphics for Data Analysis. Springer.
  • R Graph Gallery, Lollipop chart: r-graph-gallery.com/lollipop-plot.html
  • Wilke C. (2019). Fundamentals of Data Visualization, Chapter 6: Visualizing amounts
  • data-to-viz.com, Lollipop chart

Continue Learning

  • ggplot2 Bar Charts, the classic alternative for comparing categorical magnitudes
  • Error Bars in ggplot2, add uncertainty intervals to your point estimates
  • R Waffle Chart, display counts as a grid of unit squares for intuitive proportions