Annotating charts

Notes
Modified

September 24, 2026

NoteLearning objectives
  • Annotate charts with text labels using geom_text(), geom_label(), and {ggrepel}
  • Add arbitrary annotation layers with annotate()
TipSupplemental readings

Text on plots

There are two distinct strategies for placing text on a chart:

  1. Label data points — place text at the (x, y) position of an observation
  2. Add arbitrary annotations — place text (or other marks) at any location you choose

Labeling data points

geom_text() and geom_label() both place text at each observation’s coordinates:

ggplot(gapminder_europe, aes(x = gdpPercap, y = lifeExp)) +
  geom_point() +
  geom_text(aes(label = country))
1
geom_text() places the country name at each point’s (x, y) coordinate. The result is unreadable — every label overlaps.

geom_label() draws a background rectangle behind each label, which helps readability slightly but does not solve the overlap:

ggplot(gapminder_europe, aes(x = gdpPercap, y = lifeExp)) +
  geom_point() +
  geom_label(mapping = aes(label = country))
1
geom_label() draws a white background box behind each label. It is more readable than geom_text() but still overlaps severely.

Avoid overlap with {ggrepel}

The {ggrepel} package provides geom_text_repel() and geom_label_repel(), which automatically nudge labels to avoid overlap while drawing a connecting line back to the original point:

ggplot(gapminder_europe, aes(x = gdpPercap, y = lifeExp)) +
  geom_point() +
  geom_text_repel(mapping = aes(label = country))
1
geom_text_repel() uses a force-directed algorithm to push labels apart while keeping them as close as possible to their data points. The thin lines connect displaced labels back to their points.

Even with repelling, 30+ labels is too many for most charts. The better solution is to label selectively — only the points that matter for the story:

gapminder_europe <- gapminder_europe |>
  mutate(
    highlight = country %in% c("Albania", "Norway", "Hungary")
  )

ggplot(gapminder_europe, aes(x = gdpPercap, y = lifeExp)) +
  geom_point(aes(color = highlight)) +
  geom_label_repel(
    data = filter(gapminder_europe, highlight),
    mapping = aes(label = country, fill = highlight),
    color = "white"
  ) +
  scale_color_manual(values = c("grey70", "#B31B1B")) +
  scale_fill_manual(values = "#B31B1B") +
  guides(color = "none", fill = "none") +
  labs(x = "GDP per capita", y = "Life expectancy")
1
Create a logical indicator for which points should be labeled.
2
Pass only the highlighted rows to geom_label_repel(). The other points are still plotted but unlabeled.

This combination — gray non-highlighted points, colored highlighted points with labels — is a versatile design pattern for drawing attention to specific observations without removing context.

The same highlighting pattern applies to line charts:

gapminder |>
  mutate(is_oceania = continent == "Oceania") |>
  ggplot(aes(
    x = year,
    y = lifeExp,
    group = country,
    color = is_oceania,
    linewidth = is_oceania
  )) +
  geom_line() +
  scale_color_manual(values = c("grey80", "#B31B1B")) +
  scale_linewidth_manual(values = c(0.3, 1)) +
  guides(color = "none", linewidth = "none") +
  labs(
    title = "Life expectancy trends, 1952–2007",
    x = NULL,
    y = "Life expectancy"
  )
1
scale_linewidth_manual() makes the highlighted lines thicker and the background lines thin, reinforcing the contrast.

Arbitrary annotations with annotate()

annotate() places a single geom at a specific location you specify — independent of any data. Use it to add callout text, highlight a region, or draw a reference line at a meaningful value.

ggplot(gapminder_europe, aes(x = gdpPercap, y = lifeExp)) +
  geom_point() +

  annotate(
    geom = "text",
    x = 40000,
    y = 76,
    label = "High-income\ncountries"
  )
1
geom = "text" places a text string at (40000, 76). Any geom name works here.

Use "rect" to shade a region:

ggplot(gapminder_europe, aes(x = gdpPercap, y = lifeExp)) +
  geom_point() +
  annotate(
    geom = "rect",
    xmin = 30000,
    xmax = 55000,
    ymin = 78,
    ymax = 82,
    fill = "#B31B1B",
    alpha = 0.15
  ) +
  annotate(
    geom = "label",
    x = 42500,
    y = 76.5,
    label = "Rich and long-living"
  ) +
  annotate(
    geom = "segment",
    x = 42500,
    xend = 42500,
    y = 77.0,
    yend = 77.8,
    arrow = arrow(length = unit(0.1, "in"))
  )
1
Low alpha keeps the shading subtle enough that points underneath it remain visible.
2
arrow() adds an arrowhead at (xend, yend).

Markdown in annotations with {ggtext}

{ggtext} extends annotations with Markdown and HTML rendering. Use element_markdown() in theme() for title/subtitle text, and geom = "richtext" in annotate() for inline formatted annotations:

ggplot(gapminder_europe, aes(x = gdpPercap, y = lifeExp)) +
  geom_point() +
  annotate(
    geom = "richtext",
    x = 42000,
    y = 76,
    label = "Countries with GDP<br>>$30K are **mostly<br>above 80** years",
    fill = NA,
    label.color = NA,
    hjust = 0.5,
    size = 3
  ) +
  labs(
    title = "GDP and life expectancy in **Europe**, 2007",
    x = "GDP per capita",
    y = "Life expectancy"
  ) +
  theme(plot.title = element_markdown())
1
geom = "richtext" renders Markdown/HTML inside the annotation box. fill = NA and label.color = NA remove the box background and border.
2
**bold** in the title requires element_markdown() to render.

Summary

  • geom_text() / geom_label() place text at data coordinates; geom_text_repel() (from {ggrepel}) avoids overlap automatically
  • Label selectively: show only the points that serve the story, and mute the others to provide context
  • annotate() places a single geom at an arbitrary location; use it for callouts, shaded regions, and reference lines
  • {ggtext} adds Markdown/HTML rendering to titles, subtitles, and annotations

Acknowledgements

Material derived in part from Data Visualization with R and Fundamentals of Data Visualization.