Lightweight extension of the base R graphics system

Grant McDermott

userR! 2025

August 9, 2025

Disclaimer

tinyplot was developed in my own time and does not relate to my position at Amazon.

All views expressed during this talk are my own, and do not necessarily reflect the views of my employer.

Motivating example

Everyone’s favourite penguins…

base::plot

Simple scatter plot

plot(bill_dep ~ bill_len, data = penguins)

base::plot

Let’s add some grouping

plot(bill_dep ~ bill_len, data = penguins, col = species)

NB: col = species works here because species is a factor.

base::plot

Add a legend

plot(bill_dep ~ bill_len, data = penguins, col = species)
legend("topright", legend = unique(penguins$species), col = 1:3, pch = 1, title = "Species")

Q: Can you spot the error?

base::plot

Add a legend

plot(bill_dep ~ bill_len, data = penguins, col = species)
legend("topright", legend = levels(penguins$species), col = 1:3, pch = 1, title = "Species")

A: We should have used levels(species), not unique(species).

base::plot

How about a different plot type?

plot(bill_dep ~ bill_len, data = penguins, col = species, type = 'b')

Ugh… our grouped coloring logic only works for the “points” components.

base::plot

Problems and pitfalls of our base plot approach

  • How do we automate the legend mapping and avoid manual error?
  • What if we want to place the legend outside of the plot region?
  • How do we group by additional variables?
  • What if we want groups with a different plot type (e.g, lines)?
  • What if we need to group by a continuous variable?
  • What if we need to facet by another variable?
  • What if we want to add a summary function, e.g. regression fit?
  • The plots are kind of ugly. Can we make them look better?

Enter tinyplot

Install:

install.packages("tinyplot") # cran
# install.packages("tinyplot", repos = "https://grantmcdermott.r-universe.dev") # dev

Load:

library("tinyplot")


Tip

In the plots that follow, plt(...) is a shorthand alias for tinyplot(...).

tinyplot::plt

Simplest case: drop-in replacement for base::plot

plt(bill_dep ~ bill_len, data = penguins)

But we can do a lot more than that…

tinyplot::plt

How do we automate the legend mapping?

plt(bill_dep ~ bill_len | species, data = penguins)

tinyplot::plt

How do we group by additional variables?

plt(bill_dep ~ bill_len | sex + species, data = penguins, pch = "by")

tinyplot::plt

What if we want groups with a different plot type?

plt(bill_dep ~ bill_len | species, data = penguins, type = "b")

tinyplot::plt

What if we need to group by a continuous variable?

plt(bill_dep ~ bill_len | body_mass, data = penguins)

tinyplot::plt

What if we need to facet by another variable?

plt(bill_dep ~ bill_len | species, data = penguins, facet = ~island)

tinyplot::plt

What if we want to add a summary function, e.g. regression fit?

plt(bill_dep ~ bill_len | species, data = penguins, facet = ~island)
plt_add(type = "lm")

tinyplot::plt

The plots are kind of ugly. Can we make them look better?

tinytheme("clean") # or "clean2", "minimal", "ipsum", "dark", "tufte", ...
plt(bill_dep ~ bill_len | species, data = penguins, facet = ~island)
plt_add(type = "lm")

NB: Themes are persistent; subsequent (tiny)plots will inherit this aesthetic.

Background

Origin story

Two sources of frustration:

Package development 📦

  • Annoying trade-offs for supporting basic viz. methods for my packages.
  • “I just need a simple errorbar here. Do I really have to finagle segments to make this work?” “What about a legend..?”

Teaching 🎓

  • Teach simple viz. approaches vs. scalability down the road.
  • Base plotting is great for simple plots, but quickly loses its appeal for more complex plots. (And ggplot2/lattice have different APIs.)

grid vs graphics

R has two low-level graphics systems

Note: Adapted from Murrell (2023).

Base graphics in R

Very flexible… but tricksy

Base graphics can produce amazing plots.

  • plot() is just an (opinionated) wrapper around lower-level functions. (Koncevičius 2022)

But going beyond the defaults is often (much) more work that I want to do.

grid vs graphics (redux)

R has two low-level graphics systems

Note: Adapted from Murrell (2023).

Note: Adapted from Murrell (2023).

tinyplot goals:

  1. Make base R graphics more user-friendly.
  2. Improved feature parity vs. grid-based 📦s like ggplot2 and lattice.

Origin story 🤝

Collaboration

A basic version of the core routine (then called “plot2.R”) sat on my computer for a long time.

I eventually packaged it up… and invited two key collaborators:

Vincent Arel-Bundock

Achim Zeileis

Vincent and Achim have helped push tinyplot far beyond my original goals.

tinyplot API

tinyplot API

Group(s) after the pipe |

plt(
  bill_dep ~ bill_len | species,
  data = penguins
)

plt(
  bill_dep ~ bill_len | sex + species,
  data = penguins
)

tinyplot API

Groups map to colors; use the "by" keyword for other mappings

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  pch = "by"
)

Also works for lwd, lty, etc.

tinyplot API

Legend can be moved, customized and turned off

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  legend = "left!"
)

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  legend = list("bottom!", title = NULL)
)

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  legend = list("bottomright", bty = "o")
)

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  legend = FALSE
)

A "!" suffix places the legend outside the plot area.

tinyplot API

facets

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  facet = ~island
)

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  facet = ~ sex + island
)

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  facet = sex ~ island
)

tinytheme("clean2")

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  # frame = FALSE, ## non-theme option
  facet = ~ sex + island
)

tinytheme('clean') # revert theme
plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  facet = ~ sex + island,
  facet.args = list(free = TRUE)
)

tinyplot API

types

All tinyplot types can be passed as either a:

  • string ("p", "density, "lm", …), or
  • function (type_points(), type_density(), type_lm(), …)

In general, the functional equivalents are denoted type_*() and support direct argument passing for customization, e.g.

?type_lm
args(type_lm)
function (se = TRUE, level = 0.95) 
NULL

Note

Custom args can also be passed through plt(...), so long as there isn’t a top-level clash.

tinyplot API

types

plt(
  bill_dep ~ bill_len | species,
  data = penguins,
  type = "lm", level = 0.8      # string
  # type = type_lm(level = 0.8) # function
)

plt(
  ~ bill_len | species,
  data = penguins,
  type = "density"        # string
  # type = type_density() # function
)

tinyplot API

Layers

plt(
  bill_dep ~ bill_len | species,
  data = penguins
)
plt_add(type = "lm")

plt(
  ~ bill_len | species,
  data = penguins,
  type = "density"
)
plt_add(type = "rug")

tinyplot API

Themes

Themes provide a convenient way to set a preferred aesthetic for your plots.

  • Dynamic reduction of whitespace, etc.
  • Remember: tinytheme(...) is persistent.

Quick plotting function, which we’ll re-use for showcasing some themes on the next slide:

p = function() plt(
    body_mass ~ flipper_len | bill_len, penguins,
    facet = ~sex, yaxl = ",",
    main = "Palmer penguins",
    sub = "Brought to you by tinyplot!"
)

tinyplot API

Themes

tinytheme()
p()

tinytheme("tufte")
p()

tinytheme("classic")
p()

tinytheme("clean")
p()

tinytheme("clean2")
p()

tinytheme("minimal")
p()

tinytheme("ipsum")
p()

tinytheme("dark")
p()

tinytheme(
  "ipsum",
  pch = 19,
  cex = 1.2, cex.main = 2,
  cex.sub = 1.5, cex.lab = 1.5,
  palette.sequential = "zissou",
  family ="HersheyScript"
)
p()

Other features

Many other bells and whistles

  • Easily export plots with the file argument.
  • Easy alpha transparency with the alpha and fill arguments.
  • Transform axis labels (x/yaxl) and breaks (x/yaxb).
  • Custom types.
  • etc.

Conclusions

Advantages of tinyplot

The sales pitch summary

  • Concise
  • Consistent
  • Ergonomic
  • Extensive
  • Lightweight

For the longer version: tinyplot pros

Disadvantages of tinyplot

What are the caveats?

  • Layering gotchas
  • Custom layout
  • Missing features

For the longer version: tinyplot pros

Acknowledgements

tinyplot would not be where it is today without…

The R Core team:

  • Especially Paul Murrell, who has almost single-handedly built and maintained R’s graphics foundations for everyone else.

My wonderful tinyplot co-maintainers:

  • Vincent Arel-Bundock and Achim Zeileis

Many other contributors, feedback providers, and a sources of inspiration.

  • Etienne Bacher, etc.
  • the ggplot2 team (Hadley, Thomas, Teun, etc.)

tinyplot

Learn more

References

Koncevičius, Karolis. 2022. R Base Plotting Without Wrappers.” http://karolis.koncevicius.lt/posts/r_base_plotting_without_wrappers/.
Mayakonda, Anand. 2022. Base Graphics in R.” https://poisonalien.github.io/basegraphics/.
Murrell, Paul. 2023. Updates to the R Graphics Engine: One Person’s Chart Junk is Another’s Chart Treasure.” The R Journal 15: 257–76. https://doi.org/10.32614/RJ-2023-072.
Viechtbauer, Wolfgang. 2025. Open Online R Stream: The tinyplot Package.” https://github.com/wviechtb/oor_stream/blob/master/2025_03_20_tinyplot_package/oor_stream_2025_03_20_code.r.

Bonus: Tinyplot pros

Concise

The formula API gives bang for buck

P.S. Thanks to Ryan for letting me use this screenshot.

Concise

The formula API gives bang for buck

Just focusing on the core plot components…

ggplot(
  simres,
  aes(
    x = true_effect, y = mean_loo_err,
    color = factor(asym)
    )
  ) +
  geom_point() +
  geom_line() +
  facet_wrap(~ exclude_ns)
plt(
  mean_loo_err ~ true_effect | factor(asym),
  data = simres,
  type = "o",
  facet = ~ exlude_ns
)

(That’s about 1/3 fewer characters.)

Concise

Concision is even starker vs. vanilla base plot

vs

Adapted from Viechtbauer (2025).

Lightweight

Base R only

tinyplot has zero third-party dependencies.

  • Compares favourably against lattice (0 deps), ggplot2 (24 deps), tidyplots (111 deps), etc.

We’ve also kept the size of the install tarball down to a minimum (<1 MB).

  • All “data heavy” artifacts are reserved for the tinyplot website (including a comprehensive test suite and set of vignettes).

Very fast to install and play with in webR / WebAssembly. (Try it!)

  • Great for teaching or quick demos with colleagues.

Back to main

Bonus: Tinyplot cons

Layering gotchas

Scaling is fixed by the first layer

plt(body_mass ~ bill_len | species, penguins)
plt_add(type = "lm")

This is a limitation of graphics “canvas” logic. (Workarounds: Change layer order, or use x/ylim.)

Layering gotchas

Can’t combine file with plt_add (yet)

This doesn’t work:

plt(..., file = "myplot.png")
plt_add(type = ...)

I’m hoping to provide a native solution in the future, but workarounds for now:

  • Use plt(..., file = "myplot.png", draw = ...)
  • Open/close the appropriate graphics device manually, e.g. png("myplot.png"); plt(...); plt_add(...); dev.off()

Missing features

I hope that I have convinced you that tinyplot covers a lot of ground.

  • The API should also be very stable. I expect few (if any) breaking changes from here on out.

Still, tinyplot is a relatively young project and there are some features and plot types that we don’t support (yet). Some things coming down the pike:

Back to main