compose

Arrange multiple plots into a grid or stack with a shared, hoisted legend.

compose is the multi-plot orchestrator: it takes deferred plots, probes each panel’s would-be guides, decides which legends are identical across every panel, lifts them into a single shared block on the requested side, and re-renders the panels with the hoisted aesthetics suppressed so each reclaims the margin its legend would have occupied. Inspired by patchwork::plot_layout(guides = "collect").

Every positional argument must be a deferred panel built withdefer (defer(plot, ...) or, for nesting, defer(compose, ...)); passing a rendered plot panics, because compose needs the spec to re-render. Panels omit their own width/height: compose sizes each cell.

Usage

compose(
  ..panels-positional,
  layout: "grid",
  columns: 2,
  direction: ttb,
  gutter: 0.5cm,
  widths: none,
  heights: none,
  width: auto,
  height: auto,
  collect: auto,
  guides: (:),
  labels: none,
  theme: none,
  tag-levels: none,
  tag-prefix: "",
  tag-suffix: "",
  tag-sep: "",
  tag-corner: "top-left",
  align-panels: false,
  alt: none,
  as-spec: false,
)

Parameters

Parameter Default Description
..panels-positional Two or more deferred panels built withdefer (defer(plot, ...), or defer(compose, ...) to nest a composition). Order is preserved by the layout (left-to-right, top-to-bottom for grids; per dir for stacks).
layout "grid" "grid" (default) lays panels into a Typst grid with columns columns; "stack" lays them into a Typst stack flowing in dir.
columns 2 Number of columns in "grid" layout. Ignored for "stack".
direction ttb Stack direction (ttb, btt, ltr, rtl) used by "stack" layout. Ignored for "grid".
gutter 0.5cm Spacing between panels and between the panel block and the shared legend.
widths none Relative column widths (grid) or panel widths along a horizontal stack, as an array of weights (e.g., (2, 1)). They set the relative cell proportions; panels always fill their cells. Length must match the column count.
heights none Relative row heights (grid) or panel heights along a vertical stack. Same rules as widths; length must match the row count.
width auto Total composition width. auto (default) fills the available width of a bounded container (resolved through Typst layout); when the container is unbounded, it falls back to 16cm. Panels fill the cells carved from this width.
height auto Total composition height. Same semantics as width, with a 12cm fallback when the container is unbounded.
collect auto Which aesthetics to hoist into the shared legend. - auto (default) hoists every aesthetic whose guide is identical across all panels (kind, title, levels/domain, breaks, labels, aesthetic mix). Aesthetics that disagree on any of those fields stay per-plot, so a mismatched panel never silently borrows another panel’s legend. - none disables hoisting entirely; each plot keeps its own legends. - An array of aesthetic names (e.g., ("colour", "fill")) restricts hoisting to the listed aesthetics. Listed aesthetics that aren’t mergeable across panels still stay per-plot; non-listed aesthetics are never hoisted regardless of agreement. The merge predicate ignores per-panel placement and grid shape (nrow / ncol); compose forces a single shared side and grid layout for the hoisted block. Custom guides (guide-custom) never hoist.
guides (:) Per-aesthetic guide overrides applied to the collected legend, built withguides, exactly as forplot. The collected legend’s side comes from here: set it per aesthetic via guide-legend(position: ...) or for all at once via guides(default: guide-legend(position: ...)). All collected guides must resolve to one side, otherwise compose panics. Defaults to the guides’ natural side ("right").
labels none Composition-level labels built withlabels; only title, subtitle, and caption apply (panel-level labels stay on each@plot). They reuse the same chrome as a single plot, so a composition reads like one figure.
theme none Theme object such astheme-grey,theme-minimal, or theme-classic, controlling the composition’s non-data ink: its labels, the hoisted shared legend, and panel tags. When set, it also propagates into panels that declare no theme of their own (a panel with its own theme keeps it, and a nested composition inherits it recursively); otherwise the theme shared by every panel is used, falling back to the global theme set withtheme-set, else the default.
tag-levels none Per-panel tag numbering. A single code numbers this composition’s panels in layout order; an array of codes assigns one code per nesting depth: top-level panels take the first code (A, B, …) and a nested compose panel’s own panels continue with the next code, joined by tag-sep. So with ("A", "1") a top-level leaf is A while a sibling nested compose (the second panel, B) tags its panels B.1, B.2. Each code is "A" / "a" (latin), "1" (arabic), or "I" / "i" (roman); none (default) draws no tags. When set, this composition’s tag-levels drives any nested compose and overrides its own.
tag-prefix "" String placed before each generated tag symbol (e.g. "(").
tag-suffix "" String placed after each generated tag symbol (e.g. ")" or ".").
tag-sep "" Separator inserted between nested tag levels (e.g., "." gives A.1). Ignored for a single-level tag-levels.
tag-corner "top-left" Corner of each panel where its tag sits: "top-left" (default), "top-right", "bottom-left", or "bottom-right". Styled by the theme’s plot-tag element.
align-panels false When true, share plot margins grid-wise so plot areas line up: panels in the same column share their left and right margins (the per-column maximum), panels in the same row share their top and bottom margins (the per-row maximum), like patchwork/cowplot panel alignment. Defaults to false, where each panel sizes its own margins from its axis labels and titles. Nested compose panels already grid-align internally and are left untouched.
alt none Alt text for the whole composition. When set, the result is wrapped in a figure (kind "gribouille-plot") carrying this PDF alternative text, exactly asplot does.
as-spec false Internal switch driven bydefer: when true, return a compose spec dict instead of content so this composition can be passed as a panel to another compose. Use defer(compose, ...) rather than setting this directly. Guide collection stays per level (a nested compose draws its own collected legend); only tag numbering descends across nesting.

Returns

Typst content block: the panel layout with the shared legend and any composition labels, or the bare panel layout when no aesthetic ends up hoisted; wrapped in a figure when alt is set. Returns a spec dict when as-spec: true.

Examples

Auto-collect: identical colour legend hoisted to the right.

#let panel(map) = defer(plot,
  data: mpg, mapping: map,
  layers: (geom-point(size: 3pt),),
)
#compose(
  panel(aes(x: "displ", y: "hwy", colour: as-factor("cyl"))),
  panel(aes(x: "displ", y: "cty", colour: as-factor("cyl"))),
  layout: "grid", columns: (auto, auto),
)

Two side-by-side mpg scatter panels sharing a single colour legend by cylinder count hoisted to the right of the panel grid.

Two side-by-side mpg scatter panels sharing a single colour legend by cylinder count hoisted to the right of the panel grid.

Restrict hoisting: shared colour only, per-plot size ladders stay in each panel.

#let panel(map) = defer(plot,
  data: mpg, mapping: map,
  layers: (geom-point(),),
)
#compose(
  panel(aes(x: "displ", y: "hwy", colour: as-factor("cyl"), size: "cty")),
  panel(aes(x: "displ", y: "cty", colour: as-factor("cyl"), size: "hwy")),
  layout: "grid", columns: (auto, auto),
  collect: ("colour",),
)

Two mpg scatter panels sharing a single colour-by-cylinder legend on the right while each panel keeps its own size legend bound to a different column.

Two mpg scatter panels sharing a single colour-by-cylinder legend on the right while each panel keeps its own size legend bound to a different column.

Place the shared legend below the panels.

#let panel(map) = defer(plot,
  data: mpg, mapping: map,
  layers: (geom-point(size: 3pt),),
)
#compose(
  panel(aes(x: "displ", y: "hwy", colour: as-factor("cyl"))),
  panel(aes(x: "displ", y: "cty", colour: as-factor("cyl"))),
  layout: "grid", columns: (auto, auto),
  guides: guides(default: guide-legend(position: "bottom")),
)

Two side-by-side mpg scatter panels sharing a single colour-by-cylinder legend placed horizontally below the panel grid.

Two side-by-side mpg scatter panels sharing a single colour-by-cylinder legend placed horizontally below the panel grid.

Size the composition to a bounded box and split the two panels 2:1 with widths.

#let panel(map) = defer(plot,
  data: mpg, mapping: map,
  layers: (geom-point(size: 2pt),),
)
#box(width: 16cm, height: 6cm, compose(
  panel(aes(x: "displ", y: "hwy", colour: as-factor("cyl"))),
  panel(aes(x: "displ", y: "cty", colour: as-factor("cyl"))),
  columns: 2, widths: (2, 1),
))

Two mpg scatter panels in a 16 by 6 centimetre canvas where the left panel is twice the width of the right, sharing a colour-by-cylinder legend on the right.

Two mpg scatter panels in a 16 by 6 centimetre canvas where the left panel is twice the width of the right, sharing a colour-by-cylinder legend on the right.

Give the composition its own title and caption with labels.

#let panel(map) = defer(plot,
  data: mpg, mapping: map,
  layers: (geom-point(size: 2pt),),
)
#box(width: 15cm, height: 7cm, compose(
  panel(aes(x: "displ", y: "hwy", colour: as-factor("cyl"))),
  panel(aes(x: "displ", y: "cty", colour: as-factor("cyl"))),
  columns: 2,
  labels: labels(title: "Fuel economy", caption: "Source: mpg"),
))

Two mpg scatter panels under a shared title 'Fuel economy' and a source caption, with a colour-by-cylinder legend on the right.

Two mpg scatter panels under a shared title 'Fuel economy' and a source caption, with a colour-by-cylinder legend on the right.

Number the panels (A), (B), … in the top-left corner with a tag pattern.

#let panel(map) = defer(plot,
  data: mpg, mapping: map,
  layers: (geom-point(size: 2pt),),
)
#box(width: 15cm, height: 5cm, compose(
  panel(aes(x: "displ", y: "hwy", colour: as-factor("cyl"))),
  panel(aes(x: "displ", y: "cty", colour: as-factor("cyl"))),
  columns: 2,
  tag-levels: "A", tag-prefix: "(", tag-suffix: ")",
))

Two mpg scatter panels each tagged (A) and (B) in the top-left corner, sharing a colour-by-cylinder legend on the right.

Two mpg scatter panels each tagged (A) and (B) in the top-left corner, sharing a colour-by-cylinder legend on the right.

Nest a deferred compose as a panel; tag-levels: ("A", "1") numbers the left leaf panel A and the nested column B.1, B.2.

#let p(map) = defer(plot,
  data: mpg, mapping: map,
  layers: (geom-point(size: 2pt),),
)
#let inner = defer(compose,
  p(aes(x: "displ", y: "hwy")),
  p(aes(x: "displ", y: "cty")),
  columns: 1,
)
#box(width: 14cm, height: 7cm, compose(
  p(aes(x: "displ", y: "hwy")),
  inner,
  columns: 2,
  tag-levels: ("A", "1"), tag-sep: ".",
))

A left scatter panel tagged A beside a nested column of two scatter panels tagged B.1 and B.2.

A left scatter panel tagged A beside a nested column of two scatter panels tagged B.1 and B.2.

Align the panels: align-panels: true forces a shared margin so the y axes line up even when the panels’ label widths differ.

#let p(map) = defer(plot,
  data: mpg, mapping: map,
  layers: (geom-point(size: 2pt),),
)
#compose(
  p(aes(x: "displ", y: "hwy")),
  p(aes(x: "displ", y: "displ")),
  columns: 1, align-panels: true,
)

Two stacked mpg scatter panels whose y axes are aligned to a common left margin so the plot areas start at the same horizontal position.

Two stacked mpg scatter panels whose y axes are aligned to a common left margin so the plot areas start at the same horizontal position.

See also

defer, plot, aes, guides, labels.

Back to top