Skip to contents

Everything here is a real ggplot2 layer, so it composes with aes(), stats, scales, facets, and coords — and renders on any device. Every example sets a seed so the wobble is reproducible.

Every plot uses a handwriting font throughout via options(ggsketch.base_family = "auto") in setup; without it, theme_sketch() keeps the device default font and only the labels that geoms draw themselves (geom_sketch_text(), geom_sketch_bracket()) are hand-drawn.

Bars and columns

geom_sketch_col() draws a roughened outline with a hachure (pencil-shading) fill.

sales <- data.frame(product = c("Alpha", "Bravo", "Charlie", "Delta", "Echo"),
                    units   = c(34, 51, 22, 47, 39))

ggplot(sales, aes(product, units)) +
  geom_sketch_col(fill = "#7BAFD4", seed = 1L) +
  labs(title = "Units sold", x = NULL) +
  theme_sketch()

Map fill to a variable like any ggplot2 bar. Each bar gets its own seed offset, so no two bars wobble identically.

ggplot(sales, aes(product, units, fill = product)) +
  geom_sketch_col(seed = 2L, show.legend = FALSE) +
  scale_fill_brewer(palette = "Set2") +
  labs(title = "Mapped fill", x = NULL) +
  theme_sketch()

geom_sketch_bar() counts rows for you (like geom_bar()):

ggplot(mpg, aes(class)) +
  geom_sketch_bar(fill = "#C39BD3", seed = 3L) +
  labs(title = "Vehicle count by class", x = NULL) +
  theme_sketch() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

Bars flip and stack just like the originals:

ggplot(sales, aes(reorder(product, units), units)) +
  geom_sketch_col(fill = "#F1948A", seed = 1L) +
  coord_flip() +
  labs(title = "Horizontal bars", x = NULL) +
  theme_sketch()

Chicklet charts

geom_sketch_chicklet() is the hand-drawn take on ggchicklet::geom_chicklet(): a stacked bar whose segments are separately rounded “pills” with a small gap between them. Add coord_flip() for the classic horizontal layout.

seasons <- expand.grid(season = factor(2019:2024),
                       result = c("Win", "Draw", "Loss"))
seasons$games <- c(22, 18, 14, 20, 25, 19,
                   8, 10, 12, 9, 6, 11,
                   8, 10, 12, 9, 7, 8)

ggplot(seasons, aes(season, games, fill = result)) +
  geom_sketch_chicklet(seed = 3L) +
  coord_flip() +
  scale_fill_manual(values = c(Win = "#4C9F70", Draw = "#E4B363",
                               Loss = "#C1666B")) +
  labs(title = "Season results by outcome", x = NULL, y = "Games",
       fill = NULL) +
  theme_sketch()

Lollipops

geom_sketch_lollipop() is a tidy alternative to bars for ranked or sparse values: a roughened stem from a baseline capped with a sketch point. The value axis expands to include the baseline.

ggplot(sales, aes(reorder(product, units), units)) +
  geom_sketch_lollipop(colour = "#7B241C", seed = 1L) +
  labs(title = "Units sold", x = NULL) +
  theme_sketch()

Histograms and frequency polygons

geom_sketch_histogram() bins a continuous variable and draws hand-drawn bars; geom_sketch_freqpoly() draws the same counts as a roughened line.

ggplot(faithful, aes(eruptions)) +
  geom_sketch_histogram(fill = "#7BAFD4", bins = 20, seed = 1L) +
  labs(title = "Old Faithful eruption times") +
  theme_sketch()

ggplot(mpg, aes(hwy, colour = drv)) +
  geom_sketch_freqpoly(bins = 15, linewidth = 0.9, seed = 2L) +
  labs(title = "Highway mpg by drivetrain", x = "hwy") +
  theme_sketch()

Dot plots

geom_sketch_dotplot() bins the data and stacks one roughened circular dot per observation. The dots are sized by the bin width, so the count axis is approximate — turn it off for a clean look.

ggplot(faithful, aes(eruptions)) +
  geom_sketch_dotplot(binwidth = 0.12, fill = "#7BAFD4", seed = 1L) +
  scale_y_continuous(NULL, breaks = NULL) +
  labs(title = "Old Faithful eruption times") +
  theme_sketch()

Empirical CDF

geom_sketch_ecdf() draws the empirical cumulative distribution as a hand-drawn stairstep.

ggplot(mpg, aes(hwy, colour = drv)) +
  geom_sketch_ecdf(linewidth = 0.8, seed = 1L) +
  labs(title = "Highway mpg ECDF by drivetrain", y = "F(x)") +
  theme_sketch()

Lines, paths, and points

econ <- economics[economics$date > as.Date("2000-01-01"), ]
ggplot(econ, aes(date, unemploy)) +
  geom_sketch_line(colour = "steelblue", linewidth = 0.8, seed = 1L) +
  labs(title = "US unemployment", x = NULL, y = "thousands") +
  theme_sketch()

geom_sketch_point() draws each point as a small roughened ellipse:

ggplot(mtcars, aes(wt, mpg, colour = factor(cyl))) +
  geom_sketch_point(size = 4, seed = 1L) +
  scale_colour_brewer("cylinders", palette = "Dark2") +
  labs(title = "Fuel economy vs weight") +
  theme_sketch()

Lines and points compose like any layers:

df <- data.frame(x = 1:12, y = c(3, 5, 4, 7, 6, 9, 8, 11, 9, 12, 11, 14))
ggplot(df, aes(x, y)) +
  geom_sketch_line(colour = "grey40", seed = 3L) +
  geom_sketch_point(size = 4, colour = "firebrick", seed = 8L) +
  labs(title = "Trend with markers") +
  theme_sketch()

Multiple groups, one seed per group:

ggplot(ggplot2::economics_long, aes(date, value01, colour = variable)) +
  geom_sketch_line(seed = 5L) +
  labs(title = "Five series, hand-drawn", x = NULL, y = NULL) +
  theme_sketch()

Point sizes

size behaves like any ggplot2 point size. Set it to a constant for bigger or smaller markers:

sz <- data.frame(x = 1:5, y = 1, s = c(2, 4, 6, 9, 13))
ggplot(sz, aes(x, y)) +
  geom_sketch_point(size = sz$s, colour = "#2E86C1", seed = 1L) +
  labs(title = "Fixed point sizes (2 → 13)", x = NULL, y = NULL) +
  theme_sketch()

Or map size to a variable for a bubble chart — pair it with scale_size_area() so the area (not radius) encodes the value:

ggplot(mtcars, aes(wt, mpg, size = hp, colour = factor(cyl))) +
  geom_sketch_point(alpha = 0.9, seed = 2L) +
  scale_size_area("horsepower", max_size = 14) +
  scale_colour_brewer("cylinders", palette = "Dark2") +
  labs(title = "Bubble chart: size = horsepower") +
  theme_sketch()

A small-multiples sweep of a single size aesthetic:

grid <- expand.grid(x = 1:6, y = 1:3)
grid$s <- seq(1.5, 11, length.out = nrow(grid))
ggplot(grid, aes(x, y, size = s)) +
  geom_sketch_point(colour = "#884EA0", show.legend = FALSE, seed = 3L) +
  scale_size_identity() +
  labs(title = "Increasing point size", x = NULL, y = NULL) +
  theme_sketch()

Point roughness

For geom_sketch_point(), roughness is a mappable aesthetic. As a constant it sets how wobbly every marker is — from clean circles up to very shaky:

rg <- data.frame(x = 1:4, y = 1, r = c(0, 0.4, 0.9, 1.6))
ggplot(rg, aes(x, y)) +
  geom_sketch_point(aes(roughness = I(r)), size = 14, colour = "#2E86C1",
                    seed = 1L) +
  geom_sketch_text(aes(label = r), nudge_y = -0.5, size = 6) +
  labs(title = "roughness 0 → 1.6 (constant per point)",
       x = NULL, y = NULL) +
  ylim(0.3, 1.3) +
  theme_sketch() +
  theme(axis.text = element_blank())

Map it to a variable and the values are rescaled to a legible band by scale_roughness_continuous() (applied automatically, default c(0.01, 0.75)), so points can encode a third variable through how shaky they look:

ggplot(mtcars, aes(wt, mpg, roughness = hp, colour = factor(cyl))) +
  geom_sketch_point(size = 4, seed = 1L) +
  scale_colour_brewer("cylinders", palette = "Dark2") +
  labs(title = "roughness mapped to horsepower") +
  theme_sketch()

Use I() to pass raw roughness through unscaled, or scale_roughness_continuous(range = ...) to widen the band.

Jitter and count

geom_sketch_jitter() spreads overplotted points; geom_sketch_count() sizes a single point by how many observations sit there.

ggplot(mpg, aes(class, hwy)) +
  geom_sketch_jitter(width = 0.2, height = 0, colour = "#5D6D7E",
                     size = 2, seed = 1L) +
  labs(title = "Jittered highway mpg", x = NULL) +
  theme_sketch() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

ggplot(mpg, aes(cty, hwy)) +
  geom_sketch_count(colour = "#C0392B", seed = 2L) +
  scale_size_area(max_size = 8) +
  labs(title = "Overplot count") +
  theme_sketch()

Rectangles and tiles

rects <- data.frame(xmin = c(1, 3, 5), xmax = c(2, 4, 6),
                    ymin = 0, ymax = c(2, 4, 3))
ggplot(rects) +
  geom_sketch_rect(aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax,
                       fill = factor(xmin)),
                   seed = 1L, show.legend = FALSE) +
  labs(title = "geom_sketch_rect()") +
  theme_sketch()

A sketchy heatmap with geom_sketch_tile():

td <- expand.grid(x = 1:8, y = 1:6)
td$z <- td$x + td$y
ggplot(td, aes(x, y, fill = z)) +
  geom_sketch_tile(seed = 2L, hachure_gap = 0.18) +
  scale_fill_viridis_c() +
  labs(title = "geom_sketch_tile()") +
  theme_sketch()

A sketchy 2-D bin heatmap with geom_sketch_bin2d() (cells default to a hachure fill, shaded by count):

ggplot(faithful, aes(eruptions, waiting)) +
  geom_sketch_bin2d(bins = 12, seed = 3L) +
  scale_fill_viridis_c() +
  labs(title = "geom_sketch_bin2d()") +
  theme_sketch()

geom_sketch_hex() bins into hexagons instead (needs the hexbin package):

ggplot(faithful, aes(eruptions, waiting)) +
  geom_sketch_hex(bins = 12, seed = 4L) +
  scale_fill_viridis_c(option = "magma") +
  labs(title = "geom_sketch_hex()") +
  theme_sketch()

Polygons, ribbons, areas, and densities

Concave polygons fill correctly (the hachure respects every notch):

ang  <- seq(0, 2 * pi, length.out = 11)[-11]
r    <- rep(c(1, 0.45), length.out = 10)
star <- data.frame(x = r * cos(ang), y = r * sin(ang))
ggplot(star, aes(x, y)) +
  geom_sketch_polygon(fill = "tomato", seed = 1L) +
  coord_equal() +
  labs(title = "A concave star") +
  theme_sketch()

band <- data.frame(x = 1:20)
band$y  <- 10 + 5 * sin(seq(0, 3 * pi, length.out = 20))
band$lo <- band$y - 2
band$hi <- band$y + 2

ggplot(band, aes(x)) +
  geom_sketch_ribbon(aes(ymin = lo, ymax = hi), fill = "plum", seed = 2L) +
  geom_sketch_line(aes(y = y), seed = 3L) +
  labs(title = "Ribbon + line") +
  theme_sketch()

ggplot(band, aes(x, y)) +
  geom_sketch_area(fill = "lightgreen", seed = 3L) +
  labs(title = "geom_sketch_area()") +
  theme_sketch()

ggplot(faithful, aes(eruptions)) +
  geom_sketch_density(fill = "khaki", seed = 4L) +
  labs(title = "Old Faithful eruptions") +
  theme_sketch()

Violins

geom_sketch_violin() mirrors a kernel density into a closed polygon and hachure-fills it.

ggplot(mpg, aes(class, hwy, fill = class)) +
  geom_sketch_violin(seed = 1L, show.legend = FALSE) +
  scale_fill_brewer(palette = "Set3") +
  labs(title = "Highway mpg distribution by class", x = NULL) +
  theme_sketch() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

Smooths

A hand-drawn fit with a roughened confidence band:

ggplot(mtcars, aes(wt, mpg)) +
  geom_sketch_point(seed = 1L) +
  geom_sketch_smooth(method = "lm", formula = y ~ x, seed = 2L) +
  labs(title = "Linear fit with CI band") +
  theme_sketch()

ggplot(mpg, aes(displ, hwy)) +
  geom_sketch_point(colour = "grey50", seed = 1L) +
  geom_sketch_smooth(seed = 2L, colour = "darkorange") +
  labs(title = "loess fit") +
  theme_sketch()

Function curves

geom_sketch_function() sketches an analytic curve over the x range, for example to overlay a theoretical density.

ggplot(data.frame(x = c(-4, 4)), aes(x)) +
  geom_sketch_function(fun = dnorm, colour = "#2E86C1", linewidth = 0.9,
                       seed = 1L) +
  geom_sketch_function(fun = dnorm, args = list(sd = 1.6),
                       colour = "#C0392B", linewidth = 0.9, seed = 2L) +
  labs(title = "Two normal densities", y = "density") +
  theme_sketch()

Q-Q plots

geom_sketch_qq() draws the quantile-quantile points and geom_sketch_qq_line() the reference line. Map data to the sample aesthetic.

ggplot(mtcars, aes(sample = mpg)) +
  geom_sketch_qq(size = 2.5, seed = 1L) +
  geom_sketch_qq_line(colour = "#C8553D", linewidth = 0.8, seed = 2L) +
  labs(title = "Normal Q-Q plot of mpg", x = "theoretical", y = "sample") +
  theme_sketch()

Quantile regression

geom_sketch_quantile() fits and draws quantile regression lines (requires the optional quantreg package).

ggplot(mpg, aes(displ, hwy)) +
  geom_sketch_point(colour = "grey60", seed = 1L) +
  geom_sketch_quantile(quantiles = c(0.1, 0.5, 0.9), colour = "#6C3483",
                       linewidth = 0.9, seed = 2L) +
  labs(title = "10th / 50th / 90th percentile fits") +
  theme_sketch()

Circles and ellipses

Radii are in data units, so use coord_equal() for true circles:

cdf <- data.frame(x = c(1, 3, 2), y = c(1, 1, 2.5),
                  r = c(0.6, 0.9, 0.5), grp = c("a", "b", "c"))
ggplot(cdf, aes(x, y, r = r, fill = grp)) +
  geom_sketch_circle(seed = 1L, show.legend = FALSE) +
  coord_equal() +
  labs(title = "geom_sketch_circle()") +
  theme_sketch()

edf <- data.frame(x = c(1, 3), y = c(1, 2), a = c(1.4, 0.8), b = c(0.6, 1.2))
ggplot(edf, aes(x, y, a = a, b = b, fill = factor(x))) +
  geom_sketch_ellipse(seed = 2L, show.legend = FALSE) +
  coord_equal() +
  labs(title = "geom_sketch_ellipse()") +
  theme_sketch()

Segments and steps

sdf <- data.frame(x = 1:4, y = c(1, 3, 2, 4),
                  xend = 2:5, yend = c(3, 1, 4, 2))
ggplot(sdf) +
  geom_sketch_segment(aes(x = x, y = y, xend = xend, yend = yend),
                      colour = "darkgreen", linewidth = 1, seed = 3L) +
  labs(title = "geom_sketch_segment()") +
  theme_sketch()

stp <- data.frame(x = 1:8, y = c(1, 3, 2, 5, 4, 6, 5, 8))
ggplot(stp, aes(x, y)) +
  geom_sketch_step(colour = "purple", linewidth = 1, seed = 4L) +
  geom_sketch_point(seed = 5L) +
  labs(title = "geom_sketch_step()") +
  theme_sketch()

Curves and spokes

geom_sketch_curve() is a curved connector (a quadratic Bézier); curvature sets how much it bends.

cdf <- data.frame(x = c(1, 1, 1), y = c(1, 2, 3),
                  xend = c(4, 4, 4), yend = c(1, 2, 3))
ggplot(cdf, aes(x, y)) +
  geom_sketch_curve(aes(xend = xend, yend = yend), curvature = 0.4,
                    colour = "#1A5276", linewidth = 0.9, seed = 1L) +
  geom_sketch_point(seed = 2L) +
  geom_sketch_point(aes(x = xend, y = yend), seed = 3L) +
  labs(title = "geom_sketch_curve()") +
  theme_sketch()

geom_sketch_spoke() draws a segment from each point by angle and radius — useful for vector fields.

field <- expand.grid(x = 1:6, y = 1:6)
field$angle  <- with(field, atan2(y - 3.5, x - 3.5))
field$radius <- 0.6
ggplot(field, aes(x, y)) +
  geom_sketch_spoke(aes(angle = angle, radius = radius),
                    colour = "#117A65", seed = 1L) +
  geom_sketch_point(size = 1.5, seed = 2L) +
  coord_equal() +
  labs(title = "geom_sketch_spoke()") +
  theme_sketch()

Rugs

geom_sketch_rug() adds marginal ticks along the panel edges (sides).

ggplot(mtcars, aes(wt, mpg)) +
  geom_sketch_point(colour = "#2C3E50", seed = 1L) +
  geom_sketch_rug(sides = "bl", colour = "#7B241C", seed = 2L) +
  labs(title = "Scatter with marginal rug") +
  theme_sketch()

Intervals and uncertainty

The interval family draws hand-drawn ranges: geom_sketch_linerange(), geom_sketch_pointrange(), geom_sketch_errorbar(), and geom_sketch_crossbar().

est <- data.frame(
  group = c("A", "B", "C", "D"),
  mean  = c(4.1, 5.6, 3.2, 6.0),
  lo    = c(3.2, 4.9, 2.4, 5.1),
  hi    = c(5.0, 6.4, 4.1, 6.8)
)
ggplot(est, aes(group, mean)) +
  geom_sketch_pointrange(aes(ymin = lo, ymax = hi), colour = "#1F618D",
                         seed = 1L) +
  labs(title = "Point estimates with 95% intervals", x = NULL) +
  theme_sketch()

ggplot(est, aes(group, mean)) +
  geom_sketch_col(fill = "#AED6F1", width = 0.6, seed = 1L) +
  geom_sketch_errorbar(aes(ymin = lo, ymax = hi), width = 0.3, seed = 2L) +
  labs(title = "Bars with error bars", x = NULL) +
  theme_sketch()

ggplot(est, aes(group, mean)) +
  geom_sketch_crossbar(aes(ymin = lo, ymax = hi), fill = "#FCF3CF",
                       fill_style = "hachure", seed = 3L) +
  labs(title = "geom_sketch_crossbar()", x = NULL) +
  theme_sketch()

Reference lines

geom_sketch_abline(), geom_sketch_hline(), and geom_sketch_vline() span the panel with a gentle wobble.

ggplot(mtcars, aes(wt, mpg)) +
  geom_sketch_point(seed = 1L) +
  geom_sketch_hline(yintercept = 20, colour = "#C0392B", seed = 2L) +
  geom_sketch_vline(xintercept = 3.3, colour = "#2471A3", seed = 3L) +
  geom_sketch_abline(slope = -5, intercept = 37, colour = "#117864",
                     linetype = 2, seed = 4L) +
  labs(title = "Reference lines") +
  theme_sketch()

Contours and 2-D density

geom_sketch_contour() draws contour lines of a surface (needs z); geom_sketch_density2d() contours a 2-D kernel density estimate.

ggplot(faithfuld, aes(waiting, eruptions, z = density)) +
  geom_sketch_contour(colour = "#2E4053", seed = 1L) +
  labs(title = "geom_sketch_contour()") +
  theme_sketch()

ggplot(faithful, aes(eruptions, waiting)) +
  geom_sketch_point(colour = "grey70", seed = 1L) +
  geom_sketch_density2d(colour = "#884EA0", linewidth = 0.7, seed = 2L) +
  labs(title = "geom_sketch_density2d()") +
  theme_sketch()

geom_sketch_contour_filled() fills the bands between levels instead of just the lines. Each band may contain holes (the next level up, cut out); a hole-aware filler keeps them empty, so fill_style = "hachure" works too.

ggplot(faithfuld, aes(waiting, eruptions, z = density)) +
  geom_sketch_contour_filled(seed = 1L) +
  labs(title = "geom_sketch_contour_filled()") +
  theme_sketch()

ggplot(faithful, aes(eruptions, waiting)) +
  geom_sketch_density_2d_filled(fill_style = "hachure", seed = 2L) +
  labs(title = "geom_sketch_density_2d_filled() (hachure)") +
  theme_sketch()

Engraving and tonal shading

geom_sketch_engrave() shades a surface the way an etcher or banknote engraver does: continuous tone is built from the density of hatch lines, with cross-hatching deepening the shadows. It takes an x/y/z grid; high z is dark. Unlike the fill-pattern packages it computes tone from geometry rather than tiling a motif.

ggplot(faithfuld, aes(waiting, eruptions, z = density)) +
  geom_sketch_engrave(seed = 1L) +
  labs(title = "geom_sketch_engrave()") +
  theme_sketch()

With no pre-computed grid, shade raw points through a density stat:

ggplot(faithful, aes(eruptions, waiting)) +
  geom_sketch_engrave(stat = "density_2d", contour = FALSE,
                      aes(z = after_stat(density)), seed = 1L) +
  labs(title = "geom_sketch_engrave() from raw points") +
  theme_sketch()

geom_sketch_shade() shades each polygon region with a uniform density set by a tone aesthetic in [0, 1], so a mapped value reads directly as darkness. Mapping a raw variable to tone rescales it with scale_tone_continuous() (alias scale_engrave()), just as scale_size() rescales size.

hex <- data.frame(
  x = cos(seq(0, 2 * pi, length.out = 7))[-7],
  y = sin(seq(0, 2 * pi, length.out = 7))[-7]
)
regions <- do.call(rbind, lapply(1:3, function(k) {
  transform(hex, x = x + (k - 1) * 2.3, g = k, val = c(0.25, 0.55, 0.9)[k])
}))
ggplot(regions, aes(x, y, group = g)) +
  geom_sketch_shade(aes(tone = val), seed = 2L) +
  coord_equal() +
  labs(title = "geom_sketch_shade(): value → density") +
  theme_sketch()

Text

The sketch of text is a handwriting font, not roughened glyphs. geom_sketch_text() uses the first installed handwriting face (and falls back to the device default otherwise).

lab <- data.frame(x = c(2, 4, 3), y = c(3, 4, 1.5),
                  txt = c("hand", "drawn", "labels"))
ggplot(lab, aes(x, y, label = txt)) +
  geom_sketch_point(size = 3, colour = "#C0392B") +
  geom_sketch_text(size = 7, nudge_y = 0.4) +
  scale_x_continuous(expand = expansion(mult = 0.15)) +
  scale_y_continuous(expand = expansion(mult = 0.12)) +
  labs(title = "geom_sketch_text()") +
  theme_sketch()

Boxplots

A composed geom: rough IQR box, thick median, whiskers, and sketchy outliers.

ggplot(mpg, aes(class, hwy)) +
  geom_sketch_boxplot(seed = 1L) +
  labs(title = "Highway mpg by class", x = NULL) +
  theme_sketch() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

By default the box is outline-only (its fill is NA). Give it a fill for a solid box, or map fill and switch on a fill style for coloured, shaded boxes:

ggplot(mpg, aes(class, hwy, fill = class)) +
  geom_sketch_boxplot(fill_style = "hachure", seed = 1L, show.legend = FALSE) +
  scale_fill_brewer(palette = "Pastel1") +
  labs(title = "Hachure-filled boxes", x = NULL) +
  theme_sketch() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

Annotations

annotate_sketch() adds one-off hand-drawn marks (no aes() inheritance):

ggplot(mtcars, aes(wt, mpg)) +
  geom_sketch_point(seed = 1L) +
  annotate_sketch("rect", xmin = 3, xmax = 4, ymin = 15, ymax = 22,
                  fill = NA, colour = "red", seed = 2L) +
  annotate_sketch("segment", x = 2, y = 32, xend = 3.4, yend = 21,
                  colour = "blue", linewidth = 1, seed = 3L) +
  annotate_sketch("circle", x = 5, y = 30, r = 0.4,
                  colour = "darkgreen", fill = "green", seed = 4L) +
  labs(title = "Highlighting with annotate_sketch()") +
  theme_sketch()

Significance brackets

geom_sketch_bracket() draws a hand-drawn comparison bracket with an optional handwriting label, for marking pairwise comparisons (a sketchy ggsignif).

brackets <- data.frame(
  xmin  = c(1, 2),
  xmax  = c(2, 3),
  y     = c(40, 45),
  label = c("p = 0.03", "n.s.")
)
ggplot(mpg, aes(drv, hwy)) +
  geom_sketch_boxplot(seed = 1L) +
  geom_sketch_bracket(
    data = brackets,
    aes(xmin = xmin, xmax = xmax, y = y, label = label),
    seed = 2L
  ) +
  labs(title = "Pairwise comparisons", x = "drivetrain") +
  theme_sketch()

Pie & annotation toolkit

Pie and donut charts

geom_sketch_pie() draws a hand-drawn pie sized by the amount aesthetic and coloured by fill. Slices stay circular on any panel shape, so they look right without coord_fixed(); pair it with theme_void() to drop the unused axes.

shares <- data.frame(
  group  = c("Sketch", "Polish", "Coffee", "Doubt"),
  amount = c(40, 25, 20, 15)
)

ggplot(shares, aes(amount = amount, fill = group)) +
  geom_sketch_pie(seed = 1L) +
  scale_fill_sketch() +
  labs(title = "Where the time goes") +
  coord_fixed() +
  theme_void()

geom_sketch_donut() is the same with a hole; any fill_style hatches the slices instead of filling them solid.

ggplot(shares, aes(amount = amount, fill = group)) +
  geom_sketch_donut(fill_style = "hachure", seed = 2L) +
  scale_fill_sketch() +
  theme_void()

Rounded bars

Rectangular geoms (geom_sketch_rect(), geom_sketch_tile(), geom_sketch_col()/bar()) take a corner_radius for rounded corners — a fraction of each half-side, so 0 is square and 1 is fully rounded.

ggplot(shares, aes(group, amount, fill = group)) +
  geom_sketch_col(corner_radius = 0.25, fill_style = "solid", seed = 1L,
                  show.legend = FALSE) +
  scale_fill_sketch() +
  labs(title = "Rounded columns", x = NULL) +
  theme_sketch()

Content-aware arrows

annotate_sketch_arrow() points at a feature with a hand-drawn arrow. It is content-aware: the shaft curves automatically toward the target, the arrowhead orients to the curve’s end tangent, and the label sits clear of the shaft. A number for curvature (or 0 for straight) overrides the automatic bow, and arrow_type = "closed" gives a filled head.

ggplot(mtcars, aes(wt, mpg)) +
  geom_sketch_point(colour = "grey30", seed = 1L) +
  annotate_sketch_arrow(x = 4.1, y = 33, xend = 5.25, yend = 18,
                        label = "heavy & thirsty", colour = "#C0392B",
                        seed = 2L) +
  annotate_sketch_arrow(x = 2.2, y = 12, xend = 1.7, yend = 30,
                        label = "light & frugal", colour = "#1F618D",
                        arrow_type = "closed", seed = 3L) +
  labs(title = "Pointing things out") +
  theme_sketch()

Callouts

annotate_sketch_callout() puts a handwriting note in a roughened rounded box that auto-sizes to the text, with a leader arrow to the target. Omit xend/yend for a plain boxed label.

ggplot(faithful, aes(eruptions, waiting)) +
  geom_sketch_point(colour = "grey40", seed = 1L) +
  annotate_sketch_callout(x = 2.1, y = 95, label = "short bursts",
                          xend = 1.9, yend = 75, fill = "#EAF2F8",
                          colour = "#1F618D", seed = 2L) +
  labs(title = "Boxed callouts") +
  theme_sketch()

Hull marks

geom_sketch_mark_hull() circles a group of points with a roughened hull — the sketch take on ggforce::geom_mark_hull(). Map group/colour/fill to mark each cluster; with a fill the hull is shaded, otherwise it is outline-only.

ggplot(iris, aes(Sepal.Length, Sepal.Width, colour = Species)) +
  geom_sketch_mark_hull(aes(fill = Species), expand = 0.08, seed = 1L) +
  geom_sketch_point(seed = 2L) +
  scale_colour_sketch() +
  scale_fill_sketch() +
  labs(title = "Grouping clusters") +
  theme_sketch()

Bounding marks

geom_sketch_mark_ellipse(), geom_sketch_mark_circle(), and geom_sketch_mark_rect() complete the family — a roughened ellipse, circle, or rectangle around each group. The panel expands to fit the mark, so it is never clipped.

ggplot(iris, aes(Sepal.Length, Sepal.Width, colour = Species)) +
  geom_sketch_mark_ellipse(aes(fill = Species), seed = 1L) +
  geom_sketch_point(seed = 2L) +
  scale_colour_sketch() +
  scale_fill_sketch() +
  labs(title = "Bounding ellipses") +
  theme_sketch()

ggplot(iris, aes(Sepal.Length, Sepal.Width, colour = Species)) +
  geom_sketch_mark_rect(seed = 3L) +
  geom_sketch_point(seed = 2L) +
  scale_colour_sketch() +
  labs(title = "Bounding rectangles") +
  theme_sketch()

New in 2.0

ggsketch 2.0 grows in two directions: new chart families (all built from the same roughened grobs, so they inherit every fill style for free) and a drawing-medium simulator — strokes that imitate pencil, ink, brush, charcoal, marker and crayon, watercolour washes, textured papers, and hand-drawn coords.

Dumbbell and slope charts

geom_sketch_dumbbell() draws a connector between two values per row, capped with a sketch dot at each end — ideal for before/after comparisons.

dumb <- data.frame(g = c("Alpha", "Bravo", "Charlie", "Delta"),
                   before = c(20, 35, 28, 42),
                   after  = c(34, 51, 22, 47))
ggplot(dumb, aes(x = before, xend = after, y = reorder(g, after))) +
  geom_sketch_dumbbell(colour_x = "#B03A2E", colour_xend = "#1F618D",
                       seed = 1L) +
  labs(title = "Before vs after", x = "value", y = NULL) +
  theme_sketch()

geom_sketch_slope() connects each group across two (or more) categories, for showing how a ranking changes.

slope <- data.frame(
  time  = factor(rep(c("Before", "After"), each = 4),
                 levels = c("Before", "After")),
  value = c(20, 35, 28, 42, 34, 51, 22, 47),
  who   = rep(c("Alpha", "Bravo", "Charlie", "Delta"), 2)
)
ggplot(slope, aes(time, value, group = who, colour = who)) +
  geom_sketch_slope(seed = 1L) +
  scale_colour_sketch() +
  labs(title = "Slope chart", x = NULL) +
  theme_sketch()

Waterfall

geom_sketch_waterfall() floats each step’s delta from the running total before it to the running total after it, with dotted hand-drawn connectors carrying the level across the gaps. Rows flagged measure = "total" draw the running total from zero.

ledger <- data.frame(
  step  = factor(c("Start", "Sales", "Refunds", "Costs", "Tax", "Net"),
                 levels = c("Start", "Sales", "Refunds", "Costs", "Tax", "Net")),
  delta = c(120, 80, -25, -60, -18, 0),
  kind  = c("relative", "relative", "relative", "relative", "relative", "total")
)
ggplot(ledger, aes(step, delta, measure = kind)) +
  geom_sketch_waterfall(seed = 1L) +
  labs(title = "geom_sketch_waterfall()", x = NULL, y = "amount") +
  theme_sketch()

Funnel and pyramid

geom_sketch_funnel() centres one bar per stage on zero, its width the stage’s value, with translucent trapezoids carrying each stage into the next. geom_sketch_pyramid() mirrors bars about zero by a two-level side aesthetic — the population pyramid.

funnel <- data.frame(
  stage = factor(c("Visited", "Signed up", "Activated", "Paid"),
                 levels = rev(c("Visited", "Signed up", "Activated", "Paid"))),
  n     = c(1200, 460, 210, 80)
)
ggplot(funnel, aes(n, stage, fill = stage)) +
  geom_sketch_funnel(seed = 1L, show.legend = FALSE) +
  scale_fill_sketch() +
  scale_x_continuous(labels = abs) +
  labs(title = "geom_sketch_funnel()", x = "users", y = NULL) +
  theme_sketch()


pop <- data.frame(
  age = factor(rep(c("0-19", "20-39", "40-59", "60+"), 2),
               levels = c("0-19", "20-39", "40-59", "60+")),
  sex = rep(c("Female", "Male"), each = 4),
  n   = c(340, 420, 380, 240, 360, 440, 370, 200)
)
ggplot(pop, aes(n, age, side = sex, fill = sex)) +
  geom_sketch_pyramid(seed = 1L) +
  scale_fill_manual(values = c(Female = "#b56b6f", Male = "#5b7290")) +
  scale_x_continuous(labels = abs) +
  labs(title = "geom_sketch_pyramid()", x = "count", y = NULL) +
  theme_sketch()

Beeswarm

geom_sketch_beeswarm() spreads points sideways so none overlap, keeping their exact value — a deterministic, seeded swarm.

ggplot(iris, aes(Species, Sepal.Length, colour = Species)) +
  geom_sketch_beeswarm(size = 2.5, seed = 1L, show.legend = FALSE) +
  scale_colour_sketch() +
  labs(title = "geom_sketch_beeswarm()", x = NULL) +
  theme_sketch()

Ridgelines

geom_sketch_ridgeline() stacks per-group densities into overlapping ridges, drawn back-to-front. Any fill_style works, including watercolour.

ggplot(iris, aes(Sepal.Length, Species, fill = Species)) +
  geom_sketch_ridgeline(scale = 1.6, seed = 1L, show.legend = FALSE) +
  scale_fill_sketch() +
  labs(title = "geom_sketch_ridgeline()", y = NULL) +
  theme_sketch()

Streamgraphs

geom_sketch_streamgraph() stacks group values around a flowing baseline (offset = "silhouette", "zero", or "wiggle").

set.seed(1)
stream <- expand.grid(t = 1:12, grp = c("a", "b", "c", "d"))
stream$v <- abs(sin(stream$t / 3 + match(stream$grp, letters)) + 1.2) * 5
ggplot(stream, aes(t, v, fill = grp)) +
  geom_sketch_streamgraph(seed = 1L) +
  scale_fill_sketch() +
  labs(title = "geom_sketch_streamgraph()", x = "time", y = NULL) +
  theme_sketch()

Waffle and treemap

geom_sketch_waffle() turns counts into a grid of squares (largest-remainder rounding to 100 cells by default).

waf <- data.frame(grp = c("Rent", "Food", "Travel", "Other"),
                  spend = c(45, 25, 20, 10))
ggplot(waf, aes(fill = grp, weight = spend)) +
  geom_sketch_waffle(seed = 1L) +
  scale_fill_sketch() +
  coord_equal() +
  labs(title = "geom_sketch_waffle()") +
  theme_void()

geom_sketch_treemap() lays out nested rectangles by area (a squarified treemap) and can label each tile.

tm <- data.frame(grp = c("Alpha", "Bravo", "Charlie", "Delta", "Echo"),
                 val = c(40, 25, 15, 12, 8))
ggplot(tm, aes(area = val, fill = grp, label = grp)) +
  geom_sketch_treemap(seed = 1L, show.legend = FALSE) +
  scale_fill_sketch() +
  coord_equal() +
  labs(title = "geom_sketch_treemap()") +
  theme_void()

Calendar heatmaps

geom_sketch_calendar() maps a date aesthetic onto a GitHub-style grid of weeks (columns) and weekdays (rows).

set.seed(1)
cal <- data.frame(day = as.Date("2024-01-01") + 0:180)
cal$value <- cumsum(rnorm(nrow(cal)))
ggplot(cal, aes(date = day, fill = value)) +
  geom_sketch_calendar(seed = 1L) +
  scale_fill_viridis_c() +
  coord_equal() +
  labs(title = "geom_sketch_calendar()") +
  theme_sketch() +
  theme(axis.title = element_blank())

Gantt charts

geom_sketch_gantt() draws one bar per task from x (start) to xend (end) on a discrete y — the whiteboard project plan. Map progress (0–1) to overlay the completed fraction as a darker solid bar.

plan <- data.frame(
  task  = factor(c("Design", "Build", "Test", "Ship"),
                 levels = rev(c("Design", "Build", "Test", "Ship"))),
  start = as.Date(c("2026-01-05", "2026-01-19", "2026-02-09", "2026-02-23")),
  end   = as.Date(c("2026-01-23", "2026-02-13", "2026-02-27", "2026-03-06")),
  done  = c(1, 0.7, 0.25, 0)
)
ggplot(plan, aes(start, xend = end, y = task, fill = task, progress = done)) +
  geom_sketch_gantt(seed = 1L, show.legend = FALSE) +
  scale_fill_sketch() +
  labs(title = "geom_sketch_gantt()", x = NULL, y = NULL) +
  theme_sketch()

Hand-drawn networks

sketch_graph() turns an edge list (or an igraph object) into ready-to-plot nodes and edges frames, placing the nodes with a pure-R force-directed layout — so the feature needs no graph dependency. Draw the result with geom_sketch_edge() (roughened, optionally curved connectors) and geom_sketch_node() (roughened markers with optional handwriting labels).

edges <- data.frame(
  from = c("A", "A", "A", "B", "C", "C", "D", "E", "E", "F", "B", "G"),
  to   = c("B", "C", "D", "C", "D", "E", "E", "F", "G", "G", "E", "H")
)
g <- sketch_graph(edges, seed = 1L)

ggplot() +
  geom_sketch_edge(data = g$edges,
                   aes(x = x, y = y, xend = xend, yend = yend),
                   colour = "grey55", seed = 1L) +
  geom_sketch_node(data = g$nodes, aes(x = x, y = y, label = name),
                   size = 7, colour = "#1F618D", seed = 2L) +
  coord_equal() +
  labs(title = "A hand-drawn network") +
  theme_void()

Any node or edge column carried through sketch_graph() maps like a normal aesthetic, and curvature bows the edges. Here node size encodes degree:

deg <- table(c(edges$from, edges$to))
g2  <- sketch_graph(edges, seed = 4L)
g2$nodes$degree <- as.integer(deg[g2$nodes$name])

ggplot() +
  geom_sketch_edge(data = g2$edges,
                   aes(x = x, y = y, xend = xend, yend = yend),
                   curvature = 0.25, colour = "#B9770E", seed = 3L) +
  geom_sketch_node(data = g2$nodes,
                   aes(x = x, y = y, size = degree, label = name),
                   colour = "#7D3C98", show.legend = FALSE, seed = 4L) +
  scale_size_area(max_size = 13) +
  coord_equal() +
  labs(title = "Curved edges, nodes sized by degree") +
  theme_void()

Pass an igraph object straight to sketch_graph() for richer generators and graph algorithms:

set.seed(1)
ig <- igraph::sample_pa(16, directed = FALSE)
g3 <- sketch_graph(ig, seed = 7L)

ggplot() +
  geom_sketch_edge(data = g3$edges,
                   aes(x = x, y = y, xend = xend, yend = yend),
                   colour = "grey60", seed = 5L) +
  geom_sketch_node(data = g3$nodes, aes(x = x, y = y),
                   size = 5, colour = "#148F77", seed = 6L) +
  coord_equal() +
  labs(title = "A preferential-attachment graph (via igraph)") +
  theme_void()

Hand-drawn maps (sf)

geom_sketch_sf() is a sketch take on geom_sf(): in one call it roughens whichever simple-features geometry is present — (MULTI)POLYGON features get a hole-aware hachure (or any fill_style), (MULTI)LINESTRING features become sketch paths, and (MULTI)POINT features become sketch points. It needs the optional sf package and plots in planar coordinates (pre-project lon/lat data with sf::st_transform() for a faithful map).

nc <- sf::st_read(system.file("shape/nc.shp", package = "sf"), quiet = TRUE)

ggplot() +
  geom_sketch_sf(data = nc, aes(fill = BIR74), seed = 1L) +
  scale_fill_viridis_c(option = "magma") +
  labs(title = "North Carolina births, 1974") +
  theme_void()

Switch the fill_style, exactly like any other sketch fill:

ggplot() +
  geom_sketch_sf(data = nc, aes(fill = SID74), fill_style = "cross_hatch",
                 seed = 2L) +
  scale_fill_distiller(palette = "RdPu", direction = 1) +
  labs(title = "Cross-hatched choropleth of SIDS cases") +
  theme_void()

Radar charts

geom_sketch_radar() draws a spider chart: each series is a closed polygon over evenly spaced axes, with a roughened web (rings, spokes, and labels) behind. Map axis, value, group, and colour/fill; like the pie geoms it lives in its own square space, so pair it with coord_equal() and theme_void().

skills <- data.frame(
  axis  = rep(c("Speed", "Power", "Range", "Control", "Stamina", "Magic"), 2),
  value = c(8, 6, 9, 5, 7, 4, 5, 9, 4, 8, 6, 9),
  who   = rep(c("Aria", "Bilo"), each = 6)
)
ggplot(skills, aes(axis = axis, value = value, group = who,
                   colour = who, fill = who)) +
  geom_sketch_radar(alpha = 0.3, seed = 1L) +
  scale_colour_sketch() +
  scale_fill_sketch() +
  coord_equal() +
  labs(title = "Character stats") +
  theme_void()

A single series reads cleanly with a watercolour wash and more grid rings:

ggplot(subset(skills, who == "Aria"),
       aes(axis = axis, value = value, group = who)) +
  geom_sketch_radar(fill = "#2E86C1", fill_style = "watercolor",
                    n_rings = 5, seed = 3L) +
  coord_equal() +
  labs(title = "One series, watercolour") +
  theme_void()

Chord diagrams

geom_sketch_chord() lays nodes on a circle — each given a rim arc sized by its total flow — and draws every weighted relation as a ribbon through the centre. Give it an edge table and the from, to, and value columns; ribbons colour by source node.

trade <- data.frame(
  from  = c("Asia", "Asia", "Europe", "Africa", "Africa", "America",
            "America", "Asia"),
  to    = c("Europe", "America", "America", "Asia", "Europe", "Africa",
            "Europe", "Africa"),
  value = c(8, 6, 5, 3, 2, 4, 7, 5)
)
ggplot() +
  geom_sketch_chord(trade, from, to, value, seed = 1L) +
  scale_fill_sketch() +
  coord_equal() +
  labs(title = "Trade flows") +
  theme_void()

Arc diagrams

geom_sketch_arc_diagram() is a linear cousin of the chord diagram: nodes sit on a horizontal line and each weighted relation arcs over the axis as a roughened semicircle, coloured by source and thickened by value.

rel <- data.frame(
  from  = c("Anna", "Anna", "Ben", "Cara", "Cara", "Dan", "Eve", "Anna"),
  to    = c("Ben", "Cara", "Cara", "Dan", "Eve", "Eve", "Anna", "Dan"),
  value = c(3, 1, 2, 4, 2, 1, 3, 2)
)
ggplot() +
  geom_sketch_arc_diagram(rel, from, to, value, seed = 5L, max_linewidth = 3) +
  scale_colour_sketch() +
  labs(title = "Who talks to whom") +
  theme_void() +
  theme(legend.position = "none", plot.title = element_text(hjust = 0.5))

Dendrograms

geom_sketch_dendrogram() draws a hierarchical-clustering tree — the right-angle elbows roughened into a hand-drawn wobble. Pass an hclust object or a numeric data frame (it clusters for you).

cars12 <- mtcars[1:12, c("mpg", "disp", "hp", "wt", "qsec")]
ggplot() +
  geom_sketch_dendrogram(cars12, seed = 3L, line_width = 1) +
  labs(title = "Clustering cars") +
  theme_void() +
  theme(plot.title = element_text(hjust = 0.5),
        plot.margin = margin(8, 8, 28, 14))

Bump (ranking) charts

geom_sketch_bump() draws each series’ rank at every time point, joined across adjacent times by smooth roughened curves so a crossing reads as an overtake. Give it long data with the time, series, and value columns.

standings <- data.frame(
  year = rep(2018:2023, each = 5),
  team = rep(c("Falcons", "Bears", "Wolves", "Hawks", "Lions"), times = 6),
  pts  = c(20, 18, 15, 12, 10,  15, 20, 18, 10, 12,  18, 12, 20, 15, 8,
           12, 18, 15, 20, 14,  10, 15, 12, 18, 22,  22, 14, 10, 16, 18)
)
ggplot() +
  geom_sketch_bump(standings, year, team, pts, seed = 4L, point_size = 4) +
  scale_colour_sketch() +
  labs(title = "Season standings") +
  theme_void() +
  theme(legend.position = "none", plot.title = element_text(hjust = 0.5))

Alluvial / Sankey diagrams

geom_sketch_alluvial() draws two or more categorical axes as stacks of strata, joined by flows whose thickness is the frequency of each category combination. Give it a wide data frame, the axes columns in order, and an optional value weight; flows colour by the first axis (or a named fill column).

titanic <- as.data.frame(Titanic)
ggplot() +
  geom_sketch_alluvial(titanic, axes = c("Class", "Sex", "Age", "Survived"),
                       value = "Freq", seed = 1L) +
  scale_fill_sketch() +
  labs(title = "Titanic passengers", fill = "Class") +
  theme_void()

Parallel coordinates

geom_sketch_parallel() draws several numeric columns as vertical axes and every observation as a roughened polyline crossing them. Axes scale independently; map colour to a grouping column.

ggplot() +
  geom_sketch_parallel(iris,
    axes = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"),
    colour = "Species", alpha = 0.6, seed = 1L) +
  scale_colour_sketch() +
  labs(title = "Iris parallel coordinates") +
  theme_void() +
  theme(plot.title = element_text(hjust = 0.5))

Mosaic plots

geom_sketch_mosaic() splits the square into columns by the marginal counts of x, then each column by the conditional counts of y, so every tile’s area is the joint frequency.

titanic <- as.data.frame(Titanic)
ggplot() +
  geom_sketch_mosaic(titanic, x = Class, y = Survived, value = Freq,
                     seed = 1L) +
  scale_fill_sketch() +
  labs(title = "Survival by class", fill = "Survived") +
  theme_void() +
  theme(plot.title = element_text(hjust = 0.5))

Coxcomb / Nightingale rose

geom_sketch_rose() gives each category an equal angular wedge whose radius (or, with area_true = TRUE, whose area) encodes the value — Florence Nightingale’s coxcomb. An optional fill stacks radially within each wedge.

mortality <- data.frame(
  quarter = rep(c("Q1", "Q2", "Q3", "Q4", "Q5", "Q6"), each = 3),
  cause   = rep(c("Disease", "Wounds", "Other"), times = 6),
  n       = c(20, 8, 4,  30, 10, 5,  18, 6, 3,  12, 9, 2,  8, 5, 2,  14, 7, 3)
)
ggplot() +
  geom_sketch_rose(mortality, quarter, n, fill = cause, area_true = TRUE,
                   seed = 8L, alpha = 0.85) +
  scale_fill_sketch() +
  labs(title = "Mortality by quarter (area-true)", fill = NULL) +
  coord_equal() +
  theme_void() +
  theme(plot.title = element_text(hjust = 0.5))

Marimekko charts

geom_sketch_marimekko() draws variable-width stacked bars: column width is one category’s share of the total, the stacked segments are a second category’s shares, and each tile’s area is the joint value. Width percentages sit on top.

revenue_mix <- data.frame(
  region  = rep(c("North America", "Europe", "Asia", "Other"), each = 3),
  product = rep(c("Phones", "Laptops", "Tablets"), times = 4),
  revenue = c(50, 35, 15,  30, 40, 20,  45, 25, 30,  10, 12, 6)
)
ggplot() +
  geom_sketch_marimekko(revenue_mix, region, product, revenue, seed = 6L) +
  scale_fill_sketch() +
  labs(title = "Revenue share by region and product", fill = NULL) +
  theme_void() +
  theme(plot.title = element_text(hjust = 0.5),
        plot.margin = margin(10, 10, 18, 10))

Sunburst charts

geom_sketch_sunburst() draws a hierarchy as nested rings of annular sectors: the columns in levels go from the inner root ring outward, and each deeper ring splits its parent’s angle by the children’s summed value, so a child always nests inside its parent. Fill by the top-level ancestor for the classic look.

gss <- data.frame(
  region = rep(c("West", "East", "North"), each = 4),
  dept   = rep(c("Sales", "Sales", "Eng", "Eng"), 3),
  team   = paste0("T", 1:12),
  n      = c(6, 3, 8, 2, 5, 4, 3, 7, 2, 6, 4, 5)
)
ggplot() +
  geom_sketch_sunburst(gss, levels = c("region", "dept", "team"), value = "n",
                       fill_by = "root", label = TRUE, label_size = 2.6,
                       seed = 7L) +
  scale_fill_sketch() +
  coord_equal() +
  labs(title = "Headcount by region › dept › team") +
  theme_void() +
  theme(legend.position = "none", plot.title = element_text(hjust = 0.5))

Animation: boiling lines

animate_sketch() animates a sketch plot two ways. type = "boil" re-renders it while shifting every roughening seed per frame, so the whole drawing shimmers like a hand-animated cel; type = "draw_on" reveals the finished drawing behind a moving wipe, as if a hand were drawing it on. Frames are stitched into a GIF when gifski or magick is installed; otherwise the frame paths are returned. It animates any sketch plot with no change to its code.

p <- ggplot(mpg, aes(class)) +
  geom_sketch_bar(fill = "#7BAFD4", seed = 1L) +
  labs(title = "Boiling bars", x = NULL) +
  theme_sketch() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

gif <- animate_sketch(p, nframes = 10, fps = 8,
                      file = knitr::fig_path(".gif"),
                      width = 6, height = 4, res = 110)
knitr::include_graphics(gif)

And the same plot drawn on, left to right:

drawn <- animate_sketch(p, type = "draw_on", nframes = 14, fps = 10,
                        file = knitr::fig_path(".gif"),
                        width = 6, height = 4, res = 110)
knitr::include_graphics(drawn)

Drawing media

The medium controls how a stroke is laid down. sketch_media() lists all of them — from pen and pencil through brush, charcoal, chalk, marker, highlighter, and spray; set one as a constant on a line/path/segment geom, or map it as an aesthetic with scale_medium_discrete().

sketch_media()
#>  [1] "pen"          "ink"          "fountain_pen" "ballpoint"    "brush"       
#>  [6] "pencil"       "charcoal"     "pastel"       "chalk"        "marker"      
#> [11] "highlighter"  "crayon"       "spray"
lv <- c("pencil", "ink", "brush", "charcoal", "marker")
waves <- data.frame(x = rep(1:40, length(lv)),
                    g = factor(rep(lv, each = 40), levels = lv))
waves$y <- as.integer(waves$g) + sin(waves$x / 4) * 0.3
ggplot(waves, aes(x, y, group = g, medium = g, colour = g)) +
  geom_sketch_line(linewidth = 1.1, seed = 1L) +
  scale_medium_discrete(media = lv) +
  scale_colour_sketch() +
  labs(title = "medium mapped to a variable", x = NULL, y = NULL) +
  theme_sketch()

Watercolour fills

fill_style = "watercolor" paints stacked translucent washes with soft bleeds and granulation, on any polygon-, ribbon-, ellipse-, or band-filled geom.

ang  <- seq(0, 2 * pi, length.out = 11)[-11]
r    <- rep(c(1, 0.45), length.out = 10)
star <- data.frame(x = r * cos(ang), y = r * sin(ang))
ggplot(star, aes(x, y)) +
  geom_sketch_polygon(fill = "#2E86C1", fill_style = "watercolor", seed = 1L) +
  coord_equal() +
  labs(title = "fill_style = \"watercolor\"") +
  theme_sketch()

ggplot(mpg, aes(class, hwy, fill = class)) +
  geom_sketch_violin(fill_style = "watercolor", seed = 1L, show.legend = FALSE) +
  scale_fill_brewer(palette = "Set2") +
  labs(title = "Watercolour violins", x = NULL) +
  theme_sketch() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

Textured paper

theme_sketch(paper = ) draws the panel on a textured ground — sketch_papers() lists them (notebook, graph, dotted, aged, blueprint, chalkboard, kraft). Dark grounds flip the text light automatically.

sketch_papers()
#> [1] "none"       "notebook"   "graph"      "dotted"     "aged"      
#> [6] "blueprint"  "chalkboard" "kraft"
ggplot(sales, aes(product, units)) +
  geom_sketch_col(fill = "#7BAFD4", seed = 1L) +
  labs(title = "Notebook paper", x = NULL) +
  theme_sketch(paper = "notebook")

ggplot(mtcars, aes(wt, mpg)) +
  geom_sketch_point(colour = "white", seed = 1L) +
  labs(title = "Blueprint ground") +
  theme_sketch(paper = "blueprint")

You can also drop a paper onto any theme via element_sketch_paper():

ggplot(faithful, aes(eruptions, waiting)) +
  geom_sketch_point(colour = "#1F618D", seed = 1L) +
  theme_sketch() +
  theme(panel.background = element_sketch_paper("graph"))

Hand-drawn coordinate frames

coord_sketch() roughens the gridlines and ticks under any theme — no need for a sketch theme at all:

ggplot(mtcars, aes(wt, mpg)) +
  geom_sketch_point(seed = 1L) +
  labs(title = "coord_sketch() under a plain theme") +
  coord_sketch(seed = 1L)

coord_sketch_polar() is the polar companion — a wobbly circular grid for rose and pie-style charts:

rose <- data.frame(g = c("a", "b", "c", "d", "e", "f"),
                   v = c(3, 5, 2, 4, 6, 3))
ggplot(rose, aes(g, v, fill = g)) +
  geom_sketch_col(seed = 1L, show.legend = FALSE) +
  scale_fill_sketch() +
  coord_sketch_polar(seed = 1L) +
  labs(title = "coord_sketch_polar()", x = NULL, y = NULL) +
  theme_sketch()

Composition: facets, scales, coords

Sketch geoms respect the full grammar.

ggplot(mpg, aes(displ, hwy)) +
  geom_sketch_point(size = 2.5, colour = "#34495E", seed = 9L) +
  geom_sketch_smooth(method = "lm", formula = y ~ x, seed = 10L) +
  facet_wrap(~drv, labeller = label_both) +
  labs(title = "Faceted by drivetrain") +
  theme_sketch()

Dark mode

Every example above works with theme_sketch(dark = TRUE):

ggplot(sales, aes(product, units, fill = product)) +
  geom_sketch_col(seed = 1L, show.legend = FALSE) +
  scale_fill_brewer(palette = "Set2") +
  labs(title = "Dark preset", x = NULL) +
  theme_sketch(dark = TRUE)

A hand-drawn frame

By default theme_sketch() keeps the gridlines, panel border, and axis ticks crisp. Pass rough_frame = TRUE and the frame is roughened too, so it matches the marks.

ggplot(sales, aes(product, units)) +
  geom_sketch_col(fill = "#7BAFD4", seed = 1L) +
  labs(title = "Everything wobbles", x = NULL) +
  theme_sketch(rough_frame = TRUE, seed = 1L)

The roughened elements are real theme elements — element_sketch_line() and element_sketch_rect() — so you can also drop them into any theme yourself and tune their roughness, bowing, and seed:

ggplot(mtcars, aes(wt, mpg)) +
  geom_sketch_point(seed = 1L) +
  theme_sketch() +
  theme(
    panel.grid.major = element_sketch_line(roughness = 0.8, seed = 7L),
    axis.ticks       = element_sketch_line(roughness = 0.6, seed = 8L)
  )

A matching palette

scale_colour_sketch() / scale_fill_sketch() use a qualitative palette (sketch_palette()) chosen to suit the hand-drawn look:

ggplot(mpg, aes(displ, hwy, colour = drv)) +
  geom_sketch_point(size = 2.5, seed = 1L) +
  scale_colour_sketch() +
  labs(title = "scale_colour_sketch()") +
  theme_sketch(rough_frame = TRUE, seed = 2L)

For continuous data the *_sketch_c() variants give an ink-on-paper gradient:

ggplot(faithful, aes(eruptions, waiting, colour = waiting)) +
  geom_sketch_point(size = 2.5, seed = 1L) +
  scale_colour_sketch_c() +
  labs(title = "scale_colour_sketch_c()") +
  theme_sketch()

The scribble fill

"scribble" is one continuous winding stroke that overshoots the boundary, like scribbling to fill a shape:

ggplot(sales, aes(product, units, fill = product)) +
  geom_sketch_col(fill_style = "scribble", seed = 3L, show.legend = FALSE) +
  scale_fill_sketch() +
  labs(title = "fill_style = \"scribble\"", x = NULL) +
  theme_sketch()

It works anywhere a fill_style is accepted. The ten stroked styles (the eleventh, "watercolor", is painted rather than stroked — see below):

styles <- c("hachure", "cross_hatch", "zigzag", "zigzag_line", "scribble",
            "dots", "dashed", "stipple", "pencil_shade", "solid")
grid <- expand.grid(col = 1:5, row = c(2.2, 1))
grid$style <- styles
ggplot(grid) +
  lapply(seq_len(nrow(grid)), function(i) {
    geom_sketch_rect(
      data = grid[i, ],
      aes(xmin = col - 0.45, xmax = col + 0.45,
          ymin = row - 0.35, ymax = row + 0.35),
      fill = "#7BAFD4", fill_style = grid$style[i], seed = i
    )
  }) +
  geom_sketch_text(aes(col, row - 0.52, label = style), size = 3.4) +
  coord_equal() +
  labs(title = "The stroked fill styles", x = NULL, y = NULL) +
  theme_sketch() +
  theme(axis.text = element_blank())

Reproducible handwriting fonts

geom_sketch_text() picks up a handwriting face preinstalled on your OS, but for results that reproduce on any machine or CI runner, register a font file explicitly with register_sketch_font() and a font-aware device (ragg, svglite, cairo):

register_sketch_font("Caveat", "path/to/Caveat-Regular.ttf")

ggplot(lab, aes(x, y, label = txt)) +
  geom_sketch_text(family = "Caveat", size = 8) +
  theme_sketch()