Recipes
Common chart types and effects built by composing layers and native Typst, no dedicated geom required.
Gribouille is pure Typst, so a plot is ordinary content: it can be filtered, layered, placed, and arranged with the language you already have. Many chart types that ship as dedicated geoms in R extension packages are, underneath, a two-layer composition or a place() call. This page collects those compositions as ready-to-adapt recipes. Each one uses only the bundled datasets (mpg, penguins) and stable API.
Lollipop
A lollipop chart is a bar chart with the ink budget of a point: one geom-segment stem from zero plus one geom-point head. Summarise the data first with plain Typst array methods, and add a zero column so the stem has a start row to map. Because the segment layer maps y: "zero", the value 0 trains the y scale and the baseline is included automatically. Discrete scales sort their levels alphabetically, so passing the sorted classes as limits: is what puts the categories in value order.
#let mean-hwy = {
let classes = mpg.map(r => r.class).dedup()
let means = classes.map(c => {
let rows = mpg.filter(r => r.class == c)
(class: c, hwy: rows.map(r => r.hwy).sum() / rows.len(), zero: 0)
})
means.sorted(key: r => r.hwy)
}
#plot(
data: mean-hwy,
mapping: aes(x: "class", y: "hwy"),
layers: (
geom-segment(
mapping: aes(y: "zero", xend: "class", yend: "hwy"),
stroke: 1.5pt,
),
geom-point(size: 4pt, fill: okabe-ito.at(4)),
),
scales: scales(x: scale-discrete(limits: mean-hwy.map(r => r.class))),
labels: labels(
title: "Fuel economy by vehicle class",
x: "Vehicle class",
y: "Mean highway mpg",
),
width: 12cm,
height: 7cm,
)Dumbbell
A dumbbell chart shows two values per category joined by a connector: the same segment-plus-points idea with the segment spanning the two values instead of dropping to zero. Fixed colours identify the two ends, so no legend is needed; name them in the caption instead.
#let economy = {
let classes = mpg.map(r => r.class).dedup()
let means = classes.map(c => {
let rows = mpg.filter(r => r.class == c)
(
class: c,
cty: rows.map(r => r.cty).sum() / rows.len(),
hwy: rows.map(r => r.hwy).sum() / rows.len(),
)
})
means.sorted(key: r => r.hwy)
}
#plot(
data: economy,
mapping: aes(y: "class"),
layers: (
geom-segment(
mapping: aes(x: "cty", xend: "hwy", yend: "class"),
stroke: 1.5pt,
colour: luma(60%),
),
geom-point(mapping: aes(x: "cty"), size: 3.5pt, fill: okabe-ito.at(0)),
geom-point(mapping: aes(x: "hwy"), size: 3.5pt, fill: okabe-ito.at(4)),
),
scales: scales(y: scale-discrete(limits: economy.map(r => r.class))),
labels: labels(
title: "City vs. highway fuel economy",
caption: "City (orange) to highway (blue), mean mpg per class.",
x: "Mean fuel economy (mpg)",
y: "Vehicle class",
),
width: 12cm,
height: 7cm,
)Highlighting a group
To make one group pop, draw everything in a muted grey first, then redraw the group of interest on top. A layer’s data: accepts a function applied to the plot data, so the highlight layer filters inline without preparing a second dataset.
#plot(
data: penguins,
mapping: aes(x: "flipper-len", y: "body-mass"),
layers: (
geom-point(colour: luma(70%), size: 2pt),
geom-point(
data: rows => rows.filter(r => r.species == "Chinstrap"),
colour: okabe-ito.at(5),
size: 2.5pt,
),
annotate(
"text",
x: 196,
y: 4950,
label: "Chinstrap",
colour: okabe-ito.at(5),
),
),
labels: labels(
title: "Chinstrap sits between the other two species",
x: "Flipper length (mm)",
y: "Body mass (g)",
),
width: 12cm,
height: 7.5cm,
)The base layer keeps every point in the scales, so the highlighted group sits in its true context rather than on a rescaled panel.
Zoom inset
To magnify a crowded region, render the same plot twice: once in full with an annotate rectangle marking the window, and once with scale limits cut to that window. The inset is ordinary content, so native place() pins it in a corner; a plain box gives it a backing card (swap white for your page colour on tinted paper).
#let window = (x: (188, 200), y: (3200, 4300))
#let inset = plot(
data: penguins,
mapping: aes(x: "flipper-len", y: "body-mass", colour: "species"),
layers: (geom-point(size: 2.5pt),),
scales: scales(
x: scale-continuous(limits: window.x),
y: scale-continuous(limits: window.y),
),
guides: guides(colour: none),
labels: labels(x: "", y: ""),
theme: theme-minimal(),
width: 4.6cm,
height: 3.4cm,
)
#box({
plot(
data: penguins,
mapping: aes(x: "flipper-len", y: "body-mass", colour: "species"),
layers: (
geom-point(size: 2pt),
annotate(
"rect",
xmin: window.x.at(0),
xmax: window.x.at(1),
ymin: window.y.at(0),
ymax: window.y.at(1),
fill: none,
stroke: 0.8pt + luma(40%),
),
),
labels: labels(
title: "Where Adelie and Chinstrap overlap",
x: "Flipper length (mm)",
y: "Body mass (g)",
),
width: 13cm,
height: 9cm,
)
place(
top + left,
dx: 1.9cm,
dy: 1.4cm,
box(
fill: white,
stroke: 0.5pt + luma(60%),
inset: 2pt,
inset,
),
)
})The default out-of-range handling drops rows outside the limits, which is exactly the zoom semantics the inset wants. To pin content at a data position instead of a page corner, use annotate("typst", x: …, y: …, label: …), which accepts any content block, including a whole plot.
Marginal distribution
A marginal panel is a compose stack: a short density panel above the scatter, with align-panels: true so the two x axes line up and heights setting the panel proportions. Both panels share the same explicit x limits so their axes cover the same range. The top panel hides its legend, and the scatter moves its legend to the bottom so a right-hand legend does not narrow one row against the other.
#let shared-x = scale-continuous(limits: (170, 235))
#let top = defer(
plot,
data: penguins,
mapping: aes(x: "flipper-len", colour: "species"),
layers: (geom-density(),),
scales: scales(x: shared-x),
guides: guides(colour: none),
labels: labels(x: "", y: ""),
theme: theme-minimal(axis-text-y: element-blank()),
)
#let scatter = defer(
plot,
data: penguins,
mapping: aes(x: "flipper-len", y: "body-mass", colour: "species"),
layers: (geom-point(size: 2pt),),
scales: scales(x: shared-x),
guides: guides(colour: guide-legend(position: "bottom")),
labels: labels(x: "Flipper length (mm)", y: "Body mass (g)"),
)
#box(
width: 14cm,
height: 10cm,
compose(
top,
scatter,
columns: 1,
heights: (1, 3),
align-panels: true,
),
)The same pattern gives a right-hand margin: put a geom-violin panel with a constant x beside the scatter in a two-column compose.
Irregular layouts
compose earns its keep when panels share legends or need aligned axes. For everything else, plots are content and native grid() already does irregular layouts, spanning cells, and empty spacer cells.
#let wide = plot(
data: mpg,
mapping: aes(x: "displ", y: "hwy"),
layers: (geom-point(size: 2pt),),
labels: labels(x: "Engine displacement (L)", y: "Highway mpg"),
width: 13.5cm,
height: 5cm,
)
#let left = plot(
data: mpg,
mapping: aes(x: "hwy"),
layers: (geom-histogram(bins: 15),),
labels: labels(x: "Highway mpg", y: "Count"),
width: 6.5cm,
height: 4.5cm,
)
#let right = plot(
data: mpg,
mapping: aes(x: as-factor("cyl"), y: "hwy"),
layers: (geom-boxplot(),),
labels: labels(x: "Cylinders", y: "Highway mpg"),
width: 6.5cm,
height: 4.5cm,
)
#grid(
columns: (auto, auto),
gutter: 0.5cm,
grid.cell(colspan: 2, wide),
left,
right,
)Line casing
A trend line crossing a dense scatter reads better with a casing: the same line drawn first in the paper colour at a heavier weight, so it carries a thin halo that separates it from the points underneath. from-theme keeps the casing correct on any background.
#plot(
data: penguins,
mapping: aes(x: "flipper-len", y: "body-mass"),
layers: (
geom-point(size: 2pt, alpha: 0.6),
geom-smooth(
method: "loess",
se: false,
stroke: 4pt,
mapping: aes(colour: from-theme("paper")),
),
geom-smooth(method: "loess", se: false, stroke: 1.5pt, colour: okabe-ito.at(4)),
),
labels: labels(
title: "Body mass rises with flipper length",
x: "Flipper length (mm)",
y: "Body mass (g)",
),
width: 12cm,
height: 8cm,
)The same two-layer trick de-emphasises context: draw background layers with a low alpha or a muted fixed colour, and reserve full saturation for the layer that carries the message.
See also
composeanddeferfor shared legends, tags, and aligned panels.annotatefor one-off marks, includingclip: falseoverlays that may leave the panel.geom-typstfor arbitrary Typst content pinned at data coordinates.- Theming for the
theme()keys used to mute or blank chrome in these recipes.