vellum draws nothing until it knows exactly what it is drawing.
Text is measured, layout is solved and every mark’s position is resolved before anything reaches a canvas, in process, with no device open. Everything below follows from that: a scene you can interrogate, wrap text inside, move labels around in, lint, hash, diff and render three ways from a single solve.
It is a low-level graphics framework in the spirit of grid (units, viewports, grobs, layout), and the foundation layer a grammar of graphics builds on. That grammar is vellumplot.

Each of these needs the geometry to exist before the draw. In a device-driven stack it does not, which is why these are hard or impossible on top of grid.
Ask the scene where everything landed. One row per element (data key, mark, panel, device-pixel box), with no device open and nothing drawn.
plot <- vl_scene(4, 2, bg = "white") |>
push(vl_viewport(name = "panel", xscale = c(0, 10), yscale = c(0, 10))) |>
draw(points_grob(vl_unit(c(2, 5, 8), "native"), vl_unit(c(3, 7, 4), "native"),
key = paste0("site-", 1:3), name = "sites")) |>
pop()
scene_model(plot)$elements[, c("key", "mark", "panel", "x0", "y0", "x1", "y1")]
#> key mark panel x0 y0 x1 y1
#> 1 site-1 point panel 69.24094 126.84094 84.35906 141.95906
#> 2 site-2 point panel 184.44094 50.04094 199.55906 65.15906
#> 3 site-3 point panel 299.64094 107.64094 314.75906 122.75906
Fit text to a box. Wrap to a measure, justify it, and shrink the font until the block fits. Each probe re-wraps, because the line breaks depend on the size.
text_grob(caption, width = vl_unit(60, "mm"), height = vl_unit(20, "mm"),
align = "justify", fit = TRUE)
Move colliding labels apart, in the engine. Solved in device pixels and applied as an absolute offset, so faceted, polar and warped panels are all solved together, in one pass, with no second compile.
vl_repel(plot) # or vl_place(plot) for the answer alone
Lint a figure before anyone sees it. Static analysis for graphics:
the label too small to read at this size, the mark that will never be
painted, the text that will not contrast with what lands behind it, the
two palette colours a colour-blind reader sees as one.
vl_lint_assert() turns it into a CI gate and vl_lint_overlay() draws
the findings onto the figure.
vl_scene(4, 2, dpi = 96, bg = "white") |>
draw(text_grob("n = 120", x = 0.1, y = 0.9, gp = vl_gpar(fontsize = 2.5),
name = "n_label")) |>
draw(points_grob(2.4, 0.5, name = "stray_point")) |>
draw(text_grob("watermark", gp = vl_gpar(fontsize = 22, col = "#F2F2F2"),
name = "watermark")) |>
vl_lint()
#> 3 lint findings (3 warnings):
#> ✖ [low_contrast] watermark: contrast 1.1:1 against its backdrop - below 3:1
#> ✖ [offscreen] stray_point: drawn entirely outside the page - check the
#> coordinates or the scale
#> ✖ [tiny_text] n_label: 3.3 px tall - below the 7 px legibility floor
Treat a scene as a value. Hash it, diff it, nest one inside another. A structural diff is a far better regression test than an image diff, because it is immune to the font stack:
scene_hash(a) == scene_hash(b)
scene_diff(a, b) # what changed, not which pixels
Ship accessible output. Simulate colour-vision deficiency in the
render, encode redundantly with real hatch geometry, and emit a
tagged PDF whose structure tree a screen reader can navigate, built
from the same role/name metadata that drives the SVG.
render(plot, "check.png", cvd = "deuteranopia")
render(plot, "report.pdf") # tagged, if the marks carry role/name
And more that has no device-driven equivalent: text set along a curve, boolean path operations on real geometry, contours chained into polylines, SVG icon paths as crisp vector markers, stroke-to-outline expansion, animated SVG, multi-page PDF, and font pinning that makes “identical pixels everywhere” checkable rather than merely claimed.
Functionally: vl_scene() then a pipeline of push(), draw() and
pop() over an immutable tree, rendered with render(), which picks
the backend from the file extension.
library(vellum)
vl_scene(width = 5, height = 2.4, bg = "white") |>
draw(rect_grob(width = 0.94, height = 0.82,
gp = vl_gpar(fill = linear_gradient(c("#1b2a4a", "#3a7bd5")), col = NA))) |>
draw(circle_grob(x = 0.16, y = 0.5, r = 0.28,
gp = vl_gpar(fill = "#f7c948", col = NA))) |>
draw(text_grob("vellum", x = 0.62, y = 0.5,
gp = vl_gpar(fontsize = 64, col = "white", fontface = "bold"))) |>
render("hello.png")
These are the parts that make the above possible, or that you would expect of a graphics engine and would miss if they were absent.
render(scene, "plot.png" | "plot.svg" | "plot.pdf") agree on
geometry rather than each re-solving it against their own font
metrics. Output is byte-stable, so figures are snapshot-testable.
Where a backend cannot honour something it says so rather than
silently dropping it.tnum,
smcp, onum), haloed text, and Markdown-style rich labels (md()).
vl_strwidth() measures without opening a device.node_names() / get_node() /
edit_node() work much as grid.ls() / getGrob() / editGrob()
do. The difference is that the tree is a value you hold rather than
device state you recover, so an edit returns a new scene and nothing
has to be undrawn.datashade() bins millions of points
into a density raster in one pass, at a cost that scales with output
pixels rather than row count, and rasterises line segments with Wu
antialiasing rather than binning endpoints.vl_nearest()), because a diagonal’s
bounding box is a poor description of it."null"
tracks.as_vellum() / render_grid() render an existing
grid grob tree, including ggplot2 and lattice, through the
vellum backend.The engine (tiny-skia for
raster, an SVG writer, krilla for
PDF) is written in Rust because all of the above requires owning the
measuring and rasterising stack. It is not a performance claim. That is
also why installing from source needs cargo.
vellum compiles a Rust crate, so you need a Rust toolchain
(cargo/rustc) in addition to R. Then:
# install.packages("pak")
pak::pak("r-vellum/vellum")
The same caption wrapped to a fixed measure and auto-fitted to three box heights, and a label set along a curve. None of this is expressible without measuring text up front.


vl_repel() solves collisions over the resolved geometry, every panel
at once, in one pass. Left: as authored. Right: repelled.


A palette checked against colour-vision deficiency in the render, and the same categories re-encoded with hatch geometry, which stays crisp at any zoom and survives both print and greyscale.



As authored · as a deuteranopic reader sees it · re-encoded with hatching.
Gradients, a tiling pattern, and a mask, composed with viewports:
tile <- list(rect_grob(gp = vl_gpar(fill = "#ecf0f1", col = NA)),
circle_grob(r = 0.32, gp = vl_gpar(fill = "#e74c3c", col = NA)))
vl_scene(6, 2.1, bg = "white") |>
# radial gradient
push(vl_viewport(x = 1/6, width = 1/3)) |>
draw(circle_grob(r = 0.42, gp = vl_gpar(fill = radial_gradient(c("#f6d365", "#fda085")), col = NA))) |>
pop() |>
# tiling pattern
push(vl_viewport(x = 3/6, width = 1/3)) |>
draw(rect_grob(width = 0.84, height = 0.84,
gp = vl_gpar(fill = vl_pattern(tile, width = 0.22, height = 0.22), col = NA))) |>
pop() |>
# a linear gradient, clipped to a circular mask
push(vl_viewport(x = 5/6, width = 1/3,
mask = as_mask(circle_grob(r = 0.42, gp = vl_gpar(fill = "white", col = NA))))) |>
draw(rect_grob(gp = vl_gpar(fill = linear_gradient(c("#7f53ac", "#647dee")), col = NA))) |>
pop() |>
render("man/figures/README-paint.png")

vellum is the layer a grammar builds on, so plots are assembled from
primitives in a viewport with its own xscale / yscale ("native"
units):
set.seed(1)
x <- runif(60, 0, 10)
y <- 1.8 * x + rnorm(60, 0, 4)
vl_scene(4.5, 3, bg = "white") |>
push(vl_viewport(x = 0.57, y = 0.57, width = 0.82, height = 0.82,
xscale = c(0, 10), yscale = range(pretty(y)))) |>
draw(rect_grob(gp = vl_gpar(fill = "#f4f6f8", col = "#cfd8dc"))) |>
draw(points_grob(vl_unit(x, "native"), vl_unit(y, "native"), size = vl_unit(3.2, "mm"),
gp = vl_gpar(fill = "#3a7bd5", col = "#1b2a4a", lwd = 1))) |>
pop() |>
render("man/figures/README-scatter.png")

datashade()
datashade() aggregates the points into a grid the size of the output
raster, counts how many land in each cell, and colours each cell by that
count. Because the work scales with the number of pixels rather than the
number of points, it draws millions of points quickly, shows true
density instead of the solid blob that overplotting produces, and keeps
the output file small.
set.seed(2)
n <- 1e6
xx <- rnorm(n)
yy <- xx * 0.55 + rnorm(n)
vl_scene(4.5, 3, bg = "white") |>
draw(datashade(xx, yy, width = 450, height = 300, colors = c("#fde0dd", "#7a0177"))) |>
render("man/figures/README-datashade.png")

s <- vl_scene(4, 3) |>
draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "tomato", col = NA)))
render(s, "out.png") # raster (tiny-skia)
render(s, "out.svg") # vector (hand-rolled SVG)
render(s, "out.pdf") # vector (krilla)
vellum sits at grid’s level of the stack: units, viewports, grobs,
layout, and rendering. What it adds is device-independent measurement
(so layout is solved once, not once per output format), geometry
read-back (scene_model(), hit_test()), a modern paint model, and
built-in aggregation for large data. It is not a grammar of graphics
(no scales, stats, geoms, or facets); that is
vellumplot, which compiles a
plot spec into a vellum scene. To render existing grid / ggplot2 /
lattice output through the vellum backend, use as_vellum() /
render_grid().
Four packages sharing one scene model. The seam between them is the scene itself: a value each layer can pass on, query, or annotate.
scene_model()’s geometry, giving hover, brush, lasso, linked zoom
and crosstalk with no server round trip.vellumwidget is a good illustration of what the read-back buys: it
adds no drawing code at all. It renders the scene’s SVG, reads the
element table, and attaches behaviour.
vellum wires an R package to a Rust crate via extendr (crates are vendored for offline/CRAN builds).
rextendr::document() # compile the Rust backend + regenerate R wrappers
devtools::test() # run the test suite
Most changes should not move the rendered output. tools/byte-gate.R proves it:
it builds the working tree and a baseline ref side by side, renders a fixed set
of scenes to PNG/SVG/PDF plus a raw raster, and compares them byte for byte.
Rscript tools/byte-gate.R # working tree vs main; non-zero exit on a diff