# Palmer Penguins with xkcd

#### ToledoEM

#### 2026-08-19

## Overview

This vignette demonstrates every function in the xkcd package using the Palmer Penguins dataset — a fun alternative to `mtcars` featuring size measurements of three penguin species observed on islands near Palmer Station, Antarctica.

Code

    library(xkcd)
    library(dplyr)
    library(palmerpenguins)

    # Drop rows with missing values for cleaner plots
    penguins <- na.omit(penguins)

> Reproducibility note: All plots use `set.seed()` because xkcd lines are drawn with random jitter — fix the seed to get the same figure every time.

## 1. `theme_xkcd()` — The XKCD Look

`theme_xkcd()` applies a hand-drawn feel to any ggplot2 chart: no grid lines, black axis ticks, and — if the xkcd font is installed — the iconic comic font.

Code

    set.seed(123456)
    ggplot(penguins, aes(flipper_length_mm, body_mass_g, colour = species)) +
      geom_point(size = 2, alpha = 0.7) +
      labs(
        title = "Flipper length vs body mass",
        x = "Flipper length mm",
        y = "Body mass g",
        colour = "Species"
      ) +
      theme_xkcd()

`theme_xkcd()` returns a standard ggplot2 `theme` object, so you can layer additional `theme()` calls on top of it.

## 2. `xkcdaxis()` — Hand-Drawn Axes

`xkcdaxis()` replaces the default ggplot2 axis lines with wobbly, hand-drawn ones. Pass the x and y ranges of your data and it adds jittered axis arrows, a clipped coordinate system, and calls `theme_xkcd()` internally.

Code

    xrange <- range(penguins$bill_length_mm)
    yrange <- range(penguins$bill_depth_mm)

    set.seed(7)
    ggplot() +
      geom_point(
        aes(bill_length_mm, bill_depth_mm, colour = species),
        data = penguins, size = 2, alpha = 0.8
      ) +
      xkcdaxis(xrange, yrange) +
      labs(
        x = "Bill length mm",
        y = "Bill depth mm",
        colour = "Species",
        title = "Bill dimensions by species"
      )

`xkcdaxis()` returns a list of ggplot2 layers — just `+` it onto any plot.

## 3. `geom_xkcdpath()` — Wobbly Lines and Segments

`geom_xkcdpath()` is the low-level building block used by the other functions. It draws jittered, Bezier-smoothed line segments (using `x`, `y`, `xend`, `yend`) or fuzzy circles (using `x`, `y`, `diameter`).

### 3a. Annotating a trend with a segment

Code

    # Gentoo penguins — add an arrow-like segment pointing at the cluster
    xrange <- range(penguins$flipper_length_mm)
    yrange <- range(penguins$body_mass_g)

    arrow_df <- data.frame(
      x    = 228, y    = 4200,
      xend = 220, yend = 5300
    )

    set.seed(99)
    ggplot() +
      geom_point(
        aes(flipper_length_mm, body_mass_g, colour = species),
        data = penguins, size = 2, alpha = 0.7
      ) +
      geom_xkcdpath(
        mapping = aes(x = x, y = y, xend = xend, yend = yend),
        data    = arrow_df,
        linewidth = 1, xjitteramount = 1, yjitteramount = 60,
        mask = TRUE
      ) +
      annotate("text", x = 230, y = 4100,
               label = "Big Gentoos!", family = "xkcd", size = 5) +
      xkcdaxis(xrange, yrange) +
      labs(x = "Flipper length mm", y = "Body mass g", colour = "Species")

### 3b. Drawing a circle

Use `diameter` instead of `xend`/`yend` to draw a fuzzy circle. The `ratioxy` aesthetic keeps the circle from looking like an ellipse when x and y have different scales.

Code

    xrange <- c(160, 240)
    yrange <- c(2500, 6500)
    ratioxy <- diff(xrange) / diff(yrange)

    # diameter is in x-axis units; ratioxy corrects for the different x/y scales
    # so the circle appears round on screen
    circle_df <- data.frame(x = 200, y = 4000, diameter = 20)

    set.seed(5)
    ggplot() +
      geom_point(
        aes(flipper_length_mm, body_mass_g, colour = species),
        data = penguins, size = 2, alpha = 0.7
      ) +
      geom_xkcdpath(
        aes(x = x, y = y, diameter = diameter),
        data = circle_df, linewidth = 1.2, colour = "firebrick",
        ratioxy = ratioxy, mask = FALSE
      ) +
      annotate("text", x = 200, y = 3600,
               label = "Overlap zone", family = "xkcd", size = 4, colour = "firebrick") +
      xkcdaxis(xrange, yrange) +
      labs(x = "Flipper length mm", y = "Body mass g", colour = "Species",
           title = "A fuzzy circle highlights the overlap zone")

## 4. `xkcdrect()` — Fuzzy Rectangles

`xkcdrect()` draws filled rectangles with wobbly hand-drawn borders, perfect for bar-chart-style plots. Required aesthetics: `xmin`, `xmax`, `ymin`, `ymax`.

Code

    # Average body mass per species as a bar chart using fuzzy rectangles
    avg_mass <- penguins |>
      group_by(species) |>
      summarise(mean_mass = mean(body_mass_g), .groups = "drop") |>
      mutate(
        xmin = as.numeric(species) - 0.35,
        xmax = as.numeric(species) + 0.35,
        ymin = 0,
        ymax = mean_mass
      )

    xrange <- c(0.5, 3.5)
    yrange <- c(0, max(avg_mass$mean_mass) + 300)

    set.seed(11)
    ggplot() +
      xkcdrect(
        aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax),
        data = avg_mass,
        fill      = c("#f28e2b", "#4e79a7", "#59a14f"),
        colour    = "black",
        linewidth = 1
      ) +
      annotate("text",
               x     = 1:3,
               y     = avg_mass$mean_mass + 150,
               label = levels(penguins$species),
               family = "xkcd", size = 5) +
      xkcdaxis(xrange, yrange) +
      scale_x_continuous(breaks = 1:3, labels = levels(penguins$species)) +
      labs(
        x = "Species",
        y = "Mean body mass g",
        title = "Average penguin weight"
      )

## 5. `xkcdman()` — Stick Figures

`xkcdman()` draws a customisable stick figure. Every body part (spine, arms, legs, neck) is controlled by an angle. The key parameters are:

Aesthetic | Meaning
--- | ---
`x`, `y` | Head position
`scale` | Overall size
`ratioxy` | x/y scale ratio (keeps figure from being distorted)
`angleofspine` | Spine angle (−π/2 = upright)
`anglerighthumerus` / `anglelefthumerus` | Upper arm angles
`anglerightradius` / `angleleftradius` | Lower arm angles
`anglerightleg` / `angleleftleg` | Leg angles
`angleofneck` | Neck angle

### 5a. Two penguin researchers

The key to well-proportioned stick figures is `scale` and `ratioxy`. `scale` should be ~10–15% of `diff(yrange)` so the figure is visible. `ratioxy = diff(xrange) / diff(yrange)` corrects for axis distortion so limbs don’t look stretched. Place figures above the data cloud, inside the plot limits, and expand `yrange` to make room.

Code

    xrange <- range(penguins$flipper_length_mm)
    # Expand y upward to give room for figures above the data
    yrange <- c(min(penguins$body_mass_g) - 200, max(penguins$body_mass_g) + 1200)
    ratioxy <- diff(xrange) / diff(yrange)

    # scale ≈ 10% of yrange so figures are clearly visible
    scale_val <- diff(yrange) * 0.10

    dataman <- data.frame(
      x             = c(178, 228),
      y             = c(max(penguins$body_mass_g) + 500,
                        min(penguins$body_mass_g) + 1500),
      scale         = scale_val,
      ratioxy       = ratioxy,
      angleofspine  = -pi / 2,
      anglerighthumerus = c(-pi / 6, -pi / 6),
      anglelefthumerus  = c(-pi / 2 - pi / 6, -pi / 2 - pi / 6),
      anglerightradius  = c(pi / 5, -pi / 5),
      angleleftradius   = c(pi / 5, -pi / 5),
      anglerightleg = 3 * pi / 2 - pi / 12,
      angleleftleg  = 3 * pi / 2 + pi / 12,
      angleofneck   = -pi / 2
    )

    mapping <- aes(
      x = x, y = y, scale = scale, ratioxy = ratioxy,
      angleofspine      = angleofspine,
      anglerighthumerus = anglerighthumerus,
      anglelefthumerus  = anglelefthumerus,
      anglerightradius  = anglerightradius,
      angleleftradius   = angleleftradius,
      anglerightleg     = anglerightleg,
      angleleftleg      = angleleftleg,
      angleofneck       = angleofneck
    )

    set.seed(22)
    ggplot() +
      geom_point(
        aes(flipper_length_mm, body_mass_g, colour = species),
        data = penguins, size = 2, alpha = 0.7
      ) +
      xkcdaxis(xrange, yrange) +
      xkcdman(mapping, dataman) +
      annotate("text", x = 174, y = max(penguins$body_mass_g) + 1050,
               label = "Small\nones!", family = "xkcd", size = 4) +
      annotate("text", x = 234, y = max(penguins$body_mass_g) - 1050,
               label = "Big\nones!", family = "xkcd", size = 4) +
      labs(x = "Flipper length mm", y = "Body mass g", colour = "Species",
           title = "Two researchers discuss the data")

### 5b. One stick figure per island

One figure stands at the centroid of each island’s data. `runif()` gives each figure a slightly different pose.

Code

    island_means <- penguins |>
      group_by(island) |>
      summarise(
        mx = mean(flipper_length_mm),
        my = mean(body_mass_g),
        .groups = "drop"
      )

    xrange <- range(penguins$flipper_length_mm)
    yrange <- c(min(penguins$body_mass_g) - 200, max(penguins$body_mass_g) + 1400)
    ratioxy <- diff(xrange) / diff(yrange)
    scale_val <- diff(yrange) * 0.10

    set.seed(33)
    dataman <- data.frame(
      x             = island_means$mx,
      y             = island_means$my + 800,
      scale         = scale_val,
      ratioxy       = ratioxy,
      angleofspine  = -pi / 2,
      anglerighthumerus = runif(3, -pi / 6 - pi / 10, -pi / 6 + pi / 10),
      anglelefthumerus  = runif(3, -pi / 2 - pi / 6 - pi / 10, -pi / 2 - pi / 6 + pi / 10),
      anglerightradius  = runif(3, pi / 5 - pi / 10, pi / 5 + pi / 10),
      angleleftradius   = runif(3, pi / 5 - pi / 10, pi / 5 + pi / 10),
      anglerightleg = 3 * pi / 2 - pi / 12,
      angleleftleg  = 3 * pi / 2 + pi / 12,
      angleofneck   = -pi / 2
    )

    mapping <- aes(
      x = x, y = y, scale = scale, ratioxy = ratioxy,
      angleofspine      = angleofspine,
      anglerighthumerus = anglerighthumerus,
      anglelefthumerus  = anglelefthumerus,
      anglerightradius  = anglerightradius,
      angleleftradius   = angleleftradius,
      anglerightleg     = anglerightleg,
      angleleftleg      = angleleftleg,
      angleofneck       = angleofneck
    )

    set.seed(33)
    ggplot() +
      geom_point(
        aes(flipper_length_mm, body_mass_g, colour = island),
        data = penguins, size = 2, alpha = 0.7
      ) +
      xkcdaxis(xrange, yrange) +
      xkcdman(mapping, dataman) +
      annotate("text",
               x = island_means$mx,
               y = island_means$my + 1350,
               label = island_means$island,
               family = "xkcd", size = 4) +
      labs(x = "Flipper length mm", y = "Body mass g", colour = "Island",
           title = "One researcher per island",caption = "Trogersen and Dream Island overlap!!")

## 6. Putting It All Together

A single plot that uses every function: `theme_xkcd()`, `xkcdaxis()`, `xkcdrect()`, `xkcdman()`, and `geom_xkcdpath()`.

Code

    # Yearly penguin count as fuzzy bars + a stick figure + annotation arrow
    counts <- penguins |>
      group_by(year, species) |>
      summarise(n = n(), .groups = "drop") |>
      group_by(year) |>
      summarise(total = sum(n), .groups = "drop") |>
      mutate(
        xmin = year - 0.35,
        xmax = year + 0.35,
        ymin = 0,
        ymax = total
      )

    xrange <- c(2006.5, 2009.5)
    # Expand y to give the figure room above the tallest bar
    yrange <- c(0, max(counts$total) + 60)
    ratioxy <- diff(xrange) / diff(yrange)
    scale_val <- diff(yrange) * 0.12   # ~12% of y range = clearly visible

    # Figure stands above the 2009 bar (tallest), pointing left
    dataman <- data.frame(
      x             = 2009,
      y             = min(counts$total) - 30,
      scale         = scale_val,
      ratioxy       = ratioxy,
      angleofspine  = -pi / 2,
      anglerighthumerus = -pi / 6,
      anglelefthumerus  = -pi / 2 - pi / 6,
      anglerightradius  = pi / 5,
      angleleftradius   = pi / 5,
      anglerightleg = 3 * pi / 2 - pi / 12,
      angleleftleg  = 3 * pi / 2 + pi / 12,
      angleofneck   = -pi / 2
    )

    man_mapping <- aes(
      x = x, y = y, scale = scale, ratioxy = ratioxy,
      angleofspine      = angleofspine,
      anglerighthumerus = anglerighthumerus,
      anglelefthumerus  = anglelefthumerus,
      anglerightradius  = anglerightradius,
      angleleftradius   = angleleftradius,
      anglerightleg     = anglerightleg,
      angleleftleg      = angleleftleg,
      angleofneck       = angleofneck
    )

    # Arrow from annotation label to 2009 bar top
    arrow_df <- data.frame(
      x = 2007.8, y = max(counts$total) + 48,
      xend = 2008.6, yend = max(counts$total) + 10
    )

    set.seed(55)
    ggplot() +
      xkcdrect(
        aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax),
        data = counts,
        fill = "#aecbfa", colour = "black", linewidth = 1
      ) +
      geom_xkcdpath(
        aes(x = x, y = y, xend = xend, yend = yend),
        data = arrow_df,
        linewidth = 1, xjitteramount = 0.03, yjitteramount = 3, mask = TRUE
      ) +
      xkcdman(man_mapping, dataman,color="white") +
      xkcdaxis(xrange, yrange) +
      annotate("text", x = 2007.5, y = max(counts$total) + 48,
               label = "More penguins\nevery year!", family = "xkcd", size = 4) +
      annotate("text", x = counts$year, y = counts$total + 8,
               label = counts$total, family = "xkcd", size = 5) +
      scale_x_continuous(breaks = c(2007, 2008, 2009)) +
      labs(x = "Year", y = "Penguins observed",
           title = "Palmer penguins surveyed per year")

## Function Quick Reference

Function | What it does
--- | ---
`theme_xkcd()` | Applies XKCD theme (no grid, comic font if available)
`xkcdaxis(xrange, yrange)` | Draws wobbly hand-drawn axes
`geom_xkcdpath()` | Draws jittered segments or circles
`xkcdrect()` | Draws fuzzy filled rectangles
`xkcdman()` | Draws a customisable stick figure

All functions are ggplot2-compatible and can be combined freely with standard `geom_*`, `annotate()`, `scale_*`, and `facet_*` calls.