| name | mosaic |
| description | Complete tutorial and workflow for creating slide decks with the Mosaic package for Typst. Use when creating, editing, styling, debugging, or verifying Typst presentations built with Mosaic, or when the user mentions Mosaic slides, Typst slides, slide grids, named cells, themes, incremental reveals, speaker notes, handouts, or Mosaic compilation errors. Also covers common slide types: title, section divider, bulleted content, two-column, figure, image-beside-text, full-bleed photograph, big-number, and table slides. |
Mosaic for Typst
Mosaic builds slide decks as ordinary Typst documents. A deck is a sequence of slides; each slide is a grid of named cells sandwiched between a full-slide background plane and a full-slide foreground plane. Every cell and plane is a native Typst layer carrying a label (<mosaic-cell-ID>, <mosaic-background>, <mosaic-foreground>), so styling is ordinary set and show rules, not a framework DSL.
Prefer the smallest authoring surface that fits the request: headings for ordinary slides, named layouts for title and section slides, and low-level grids only for genuinely custom composition.
1. Establish the environment
-
Inspect the existing deck and preserve its import spelling and theme. Do not replace a themed facade with the root facade unless asked.
-
Check that Typst is available with typst --version.
-
The released package, #import "@preview/mosaic:0.0.1", downloads from Typst Universe on first compile and needs no installation step. The development version, #import "@local/mosaic:0.0.2", must be installed before it resolves — either as a snapshot of the repository via the one-liner below, or with make install from a clone to use the working tree:
curl -fsSL https://raw.githubusercontent.com/vincentarelbundock/mosaic/main/install.sh | sh
-
Compile from the project root when the deck uses repository-relative assets:
typst compile --root . path/to/deck.typ
Do not claim success until Typst has compiled the edited deck.
2. Tutorial: a first deck
Import Mosaic and apply its setup rule. After #show: m.setup, headings create slides automatically:
#import "@local/mosaic:0.0.2" as m
#show: m.setup.with(title: [A short title])
#m.slide(layout: "title")
= First section
== Main result
Write ordinary Typst content.
== Evidence
- First point
- Second point
- A level-one heading (
=) creates an unnumbered section slide with a larger, centered title and advances the section counter. Content written between = and the next == becomes that slide's subtitle, so a section tagline costs no explicit slide and the heading keeps its outline entry.
- A level-two heading (
==) creates a numbered content slide: the heading fills the header cell and the following content fills the body cell, until the next = or ==.
- Slide bodies are ordinary Typst: lists, math, figures, native
grid(), packages, everything works.
Use explicit m.slide only when a heading slide is insufficient. Import Mosaic under a short alias (as m) so its constructors stay namespaced and native Typst columns() remains available.
Vocabulary used throughout: slide (one unit), deck (sequence of slides), cell (named area holding content), grid (cells arranged with splits), split (horizontal or vertical division), inset (space between a cell's edge and its content), background/foreground (full-slide planes behind/over the grid), layout (ready-made slide arrangement), theme (coordinated colors, text styles, and layouts).
3. Layouts
layout selects both a configured design and its slide semantics:
#m.slide() // configured content layout (the default)
#m.slide(layout: "content")
#m.slide(layout: "title")
#m.slide(layout: "section")
#m.slide(layout: "image", image: path("fig/chart.png"))
The layout selection is also accepted as the leading positional argument, so #m.slide("content", variant: "body")[...] is the same slide as #m.slide(layout: "content", variant: "body")[...].
The selectable names are content, title, section, and image. The first three are also configurable: they have defaults, headings create them automatically, and setup(layouts:) or a theme can replace them. image is selectable but not configurable, since nothing creates an image slide automatically; supply its fields at the call site. Note that layout: "content" resolves to the configured content layout, whose stock value is m.layouts.content(variant: "header-body"), whereas the m.layouts.content() factory defaults to variant: "header-body-footer". The two spellings are not interchangeable; a theme or a setup layouts: override changes the former only.
Content slides are numbered by default; title and section slides are not. Section slides advance the section counter. numbered: explicitly overrides the numbering default.
Any named argument slide does not recognize is a field of the selected layout, overlaid on the configured layout so the theme's other fields survive:
#m.slide(layout: "title", variant: "academic")
#m.slide(layout: "section", number: [03])[Methods]
#m.slide(columns: 2)[Left column][Right column]
Field names are validated against the selected layout, so a name that layout does not have is a compile error. Fields require a layout selected by name (or auto); they cannot accompany an explicit m.layouts.* value, which replaces rather than refines the configured layout. Pass the fields to that constructor instead.
Use a layout factory (m.layouts.*) when building a layout from scratch rather than refining the configured one:
#m.slide(
layout: m.layouts.title(
title: [Reliable systems],
subtitle: [One source of truth],
variant: "kicker",
),
)
#m.slide(
layout: m.layouts.content(variant: "header-body", columns: 2),
cells: (
header: [Comparison],
body-1: [First column],
body-2: [Second column],
),
)
#m.slide(
layout: m.layouts.section(subtitle: [What changes]),
)[Methods]
Variants:
- Title:
ruled (the default: heading stack over a full-width accent rule with details beneath, flush left at the vertical center, the beamer-metropolis title page), centered (mass at slide center, metadata at the bottom edge), bordered (centered stack inside one thin border), kicker (magazine masthead: strong opening rule, subtitle as a tracked-caps eyebrow in the accent color, details at the bottom edge), panel (a vertical ink panel carries the details knocked out in canvas under a short accent rule; the title takes the main field), academic (conference-poster arrangement with superscript affiliations), and image with position: "left"/"right"/"top"/"bottom" (default left). Every variant renders every author field: names with ORCID icons and the corresponding asterisk, affiliations, contact addresses, and the date. Structural marks default to the text color (the bordered border, the kicker opening rule) or the semantic accent (the ruled rule); an explicit accent: recolors them. There is no plate variant: invert any slide with m.slide(invert: true).
- Section:
plain, the designed text variants rule (heavy full-width rule over a flush-left title), numeral (giant ghost number bleeding off the top-right), baseline (title and number sharing one baseline over a full-width hairline), toc (all sections listed, the current one alive), and the same five image variants. The designed text variants read the automatic section counter when number: is omitted. Section numbers never take the accent color: the accent: field defaults to the muted color and an explicit color is an override. The themed facades restyle the section cell, so under editorial, manifesto, or metropolis these variants render at that theme's own scale.
- Content:
body, header-body, body-footer, header-body-footer.
- Image:
figure (the default), full, left, right, top, bottom.
m.layouts.title() inherits title, subtitle, authors, and date from setup, so m.slide(layout: "title") needs no body. An explicit layout argument wins; pass none (or () for authors) to suppress an inherited field on one slide. For image variants, scrim: on the image spec quiets the photograph, as in image: (path: "cover.webp", scrim: black.transparentize(55%)). A full-slide photographic title is not a variant: give the slide a background: plane (background: m.components.image(path("cover.webp"), scrim: ..)), and compose the type yourself with m.info(), a contextual reader returning the title, subtitle, resolved authors records, and date declared on setup, plus two position records: slide (number, total, numbered) and section (number, total, title), which are what hand-built footlines and headlines are made of and what m.components.progress() itself reads. Pair a photographic background with a scoped text-color rule on the <mosaic-cell-title> or <mosaic-cell-section> label for contrast. One such rule recolors the whole stack: the subtitle and details are muted only while the cell carries the deck's ordinary text color, and follow any override, so light-on-dark titles need no hand-built grid. A size rule on the same label works the same way, scaling the stack as a unit (show label("mosaic-cell-title"): set text(size: 0.45em) for quiet type in a corner), because the display line carries its own <mosaic-title-display> label where the theme states the display size. There is no scale: argument, and a hand-built grid is never the way to resize a title.
Mosaic has no automatic shrink-to-fit for body content, by design: a deck whose type size is decided slide by slide loses the typographic scale that holds it together. An overflow means cut a bullet, split the slide, or pick a layout with more room. When one indivisible block is oversized (a wide table, a chart, a generated list), scale that block alone with m.fit(my-table), which measures it against its region and scales geometrically. m.fit(grow: true)[42%] goes the other way, filling a cell with display type. A fitted block never overflows and so stops appearing in the overflow records, which is why it is for indivisible blocks rather than crowded slides. It also cannot contain m.steps.pause, m.steps, or m.note: measuring hides the body from the runtime's walk, so those would be lost. m.fit raises an error rather than dropping them; keep them outside the fitted block.
A direct m.layouts.content/title/section(...) value retains its semantic layout name. A raw custom grid uses ordinary content-slide semantics.
To replace a named layout deck-wide, configure it once in setup; to reuse one for selected slides, bind it with .with:
#show: m.setup.with(layouts: (
section: m.layouts.section(variant: "image-background", image: "chapter.jpg"),
))
#let myslide = m.slide.with(layout: m.layouts.content(variant: "header-body"))
#myslide(cells: (header: [== Slide title], body: [Slide content]))
4. Common slide types
Most decks are built from a handful of recurring shapes. Reach for the named form below before composing a grid by hand; each one is one line and stays correct when the deck's font size, paper, or theme changes.
Title slide. #m.slide(layout: "title") with nothing else: it inherits title, subtitle, authors, and date from setup. Choose a stock variant when the preset suits the deck. When the deck wants type much quieter or more idiosyncratic than any preset, use the image layout's full variant as the escape hatch and write the title as ordinary text:
#m.slide(layout: "image", variant: "full", image: path("fig/cover.jpg"))[
#set text(fill: white)
#text(size: 1.5em)[Économie et Politique]
POL1025
Vincent Arel-Bundock
]
Section divider. A bare = Section name is enough. Text written between the = and the next heading becomes the section slide's subtitle, so a tagline costs no extra slide. Use setup(layouts: (section: ...)) when every divider should share one designed variant (for example m.layouts.section(variant: "baseline")) or carry the same photograph. A one-off design is a field overlay: #m.slide(layout: "section", variant: "toc")[Methods].
Bulleted content slide. A bare == Slide title followed by a list. Do not write m.slide for this.
Two-column slide. columns: 2 on the content layout, then three positional blocks: header, left, right.
#m.slide(layout: "content", columns: 2)[== Comparison][
Left column
][
Right column
]
Use it for side-by-side figures too, wrapping each in m.components.figure(..), which contains the picture and sizes it to its cell. Add tracks: (2fr, 1fr) for an uneven split. Under a theme whose configured content layout is header-body-footer (Metropolis, and the stock m.layouts.content() factory), pass variant: "header-body" alongside columns: 2 when you supply header + two bodies; otherwise the third positional block lands in the footer cell and renders small at the bottom edge.
Figure slide. The image layout's default variant: a header above a contained picture, with an optional caption beneath.
#m.slide(layout: "image", image: path("fig/gdp.png"))[== Growth since 1950]
#m.slide(layout: "image", image: path("fig/pie.png"), caption: [Teaching, research, admin])[== My job]
figure defaults to fit: "contain", which is what a chart needs: never crop data. caption: composes a native Typst figure, so it takes the deck's own show figure.caption rules and figure numbering; switch numbering off with set figure(numbering: none). caption: is rejected by every other variant. The figure variant has header, image, and caption cells but no body, so a single standing line of commentary belongs in caption:.
Picture beside text. The directional variants left, right, top, and bottom pair a full-bleed picture with a header and body region, filled by two positional blocks:
#m.slide(layout: "image", variant: "right", image: path("fig/book.jpg"))[== Readings][
- Almost every week
- PDFs on the course site
]
Pass [] as the second block when the picture needs a title but no body. tracks: sizes the picture region and is side-independent, so tracks: 40% means the same thing under left and right and the two stay mirror images. These variants default to fit: "cover"; pass fit: "contain" for a chart, a screenshot, or any picture whose edges carry meaning.
Full-bleed photograph with text over it. The full variant puts the picture behind a single body cell:
#m.slide(
layout: "image",
variant: "full",
image: (path: path("fig/auditorium.png"), scrim: black.transparentize(55%)),
)[
#set text(fill: white)
== Who are you?
]
full inherits the deck's ordinary text color, so it needs both halves of the contrast pair: a scrim: on the image spec to quiet the photograph, and a text fill override in the body. Omit the body entirely (#m.slide(layout: "image", variant: "full", image: ...)) for a bare picture slide with no text at all. Prefer full over image-background on a title or section layout when the composition is free-form rather than a titled preset.
One big number or phrase. Scope a centering rule around a single content slide, and let m.fit(grow: true) size the type to the cell instead of picking an em value that breaks when the number gains a digit:
#[
#show label("mosaic-cell-body"): set align(center + horizon)
#m.slide(cells: (body: m.fit(grow: true, strong[15 000 000])))
]
Table slide. Native Typst table inside an ordinary == slide, wrapped in align(center + horizon). Mosaic adds nothing here; use stroke: (x, y) => ... for booktabs-style rules.
Continuation slide. Repeating a title verbatim collides in the outline and in link targets. Repeat the heading and give it a distinct label: == Appeals #metadata(none) <appeals-2>.
One recurring choice worth making once per deck:
path(), not a bare string. Image paths inside layout and component arguments cross the package boundary, so a bare "fig/x.png" is searched for inside the installed Mosaic package and fails with file not found (searched at .../packages/preview/mosaic/...). Wrap every asset path in Typst's path().
5. Configure the deck once
Put deck identity, semantic colors, layout overrides, recurring cell defaults, and page planes in m.setup:
#show: m.setup.with(
title: [Reliable systems],
subtitle: [One source of truth],
authors: ([Ada Lovelace], [Grace Hopper]),
date: [2026],
colors: (accent: rgb("#007f73")),
cells: (
footer: [Mosaic · Engineering],
),
foreground: [#place(bottom + right)[#m.components.progress()]],
)
#m.slide(layout: "title")
Key setup arguments:
title, subtitle, authors, date: deck identity. authors takes a bare name the way title does, an array of names for several, or records mixed in among them. An author record is built by m.layouts.author(name, ...) and accepts affiliations (an array of institutions, as content or strings, deduplicated by value so authors sharing one institution share its legend number), corresponding, email, kind, and orcid; the academic title variant requires at least one author. Feeds title layouts, Typst document metadata, and the queryable <mosaic-deck-metadata> record. Setup does not insert a title slide automatically; each m.slide(layout: "title") chooses where one appears.
colors:: partial overrides of the semantic palette. It is one flat dictionary: canvas, surface, accent, text, muted, and line are the deck's own chrome, and warning and error are the status colors components paint with. Unknown names and non-color values are errors; omitted names keep the active theme's defaults.
layouts:: a dictionary overriding only content, title, or section. Both explicit slides and automatic == slides use the configured content layout.
cells:: recurring cell defaults (such as footer). They apply only when the resolved layout contains that cell ID; explicit slide content or none overrides them.
background: / foreground:: the deck's full-slide planes. Every slide inherits them unless it passes its own value, or none to suppress one.
paper:: "16-9" (default) or "4-3".
spacing:: for example (inset: 1.5em) to set the default cell inset.
handout: true: emit only the final frame of each logical slide.
Do not introduce a separate footer, logo, background, or foreground feature API: recurring named-cell content and the reserved content.background / content.foreground planes own those jobs.
6. Themes
Import one facade as m and keep the rest of the deck unchanged:
#import "@local/mosaic:0.0.2" as mosaic
#import mosaic.themes.metropolis as m
#show: m.setup
Bundled facades are default, editorial, metropolis, manifesto, and mono. The root package is exactly the bundled default facade. There is no dark theme: flip any theme to dark by passing the bundled dark palette through colors:, as in #show: m.setup.with(colors: mosaic.palettes.dark). Every theme adapts on its own, syntax highlighting included.
Every facade exports the same slide, note, pause, surface, grid, steps, components, and theme, so those parts of a deck are theme-portable. Each facade also exports its own definition, the dictionary its setup is bound to. Each theme also exports m.layouts: the base layout constructors, rebound with that theme's defaults (for example, Editorial's content defaults to variant: "header-body"). Every base argument remains available under every theme; for example, Metropolis binds title to variant: "ruled" and an explicit variant overrides it.
To develop a theme, start locally: define the complete semantic palette and bind the dictionary directly:
#let theme = (
colors: (
canvas: white,
surface: rgb("#f5f5f5"),
text: rgb("#17243a"),
muted: rgb("#52657f"),
line: rgb("#aeb9c8"),
accent: rgb("#a23b72"),
),
apply: (body, colors: (:), options: (:)) => {
set text(font: "Inter", size: 20pt)
show list.where(tight: true): it => list(tight: false, ..it.children)
body
},
)
#show: m.themes.setup(theme)
Only colors is required; name, defaults, options, layouts, and apply are optional. Mosaic's engine emits no set or show rules of its own, so apply states the theme's whole look: base typography, headings, captions, list rhythm, and the canonical <mosaic-cell-*> rules the layouts compose against. Copy light/definition.typ for the complete set in its plainest form. Themes that want compact markup lists to read at presentation distance loosen them with a plain show rule (show list.where(tight: true): it => list(tight: false, ..it.children), and the same for enum). To start from an existing theme, merge its exported definition: base.definition + (name: .., colors: base.definition.colors + (accent: ..)). The merge replaces apply outright, so to extend inherited rules rather than drop them, run the base callback first with show: (base.definition.apply).with(colors: colors, options: options). A reusable packaged facade keeps three core files: theme.typ (binds and exports the public API), definition.typ (passive design decisions), and layouts.typ (the callable layout namespace), bound once with mosaic.themes.setup(definition). Copy Light's files and change only design values. Theme definitions are passive data consumed by Mosaic's engine; never call setup internals from a theme or forward Mosaic's setup arguments.
7. Custom grids
Build custom structure only when headings and layout factories are insufficient. A Mosaic grid is a transparent tree of named cells:
#let composition = m.grids.columns(
m.grids.track(2fr, "main"),
m.grids.track(1fr, m.grids.rows(
m.grids.track(2fr, "notes"),
m.grids.track(1fr, "source"),
)),
)
#m.slide(
layout: composition,
cells: (
main: [The main argument],
notes: [Two parts notes],
source: [One part source],
),
)
m.grids.columns(...) places children side by side; m.grids.rows(...) stacks them. Each string is a cell ID. Children may be nested grids. Both accept gutter: (a native track size between adjacent children, default 0pt) and stroke: (a stroke drawn along each interior boundary, centered in the gutter, default none).
- Every direct child gets a
1fr track by default. Wrap a child in m.grids.track(size, child) for another size; tracks accept auto, fixed lengths, percentages, and fr values.
m.grids.cell(id, ...) creates an explicitly configured cell: inset: (padding, affects layout measurement), fixed content: (an image or logo owned by the grid, needing no body or cells: entry). Cells do not fit or scale their content: they are structural only.
- Read a grid from the outside inward: largest split first, then replace children with nested splits. Keep descriptive IDs and indentation.
Cell insets provide slide margins (setup uses a zero page margin). Adjacent cells each contribute their own inset; a grid gutter separates cell surfaces and defaults to 0pt. Use a layout factory instead of rebuilding a standard title, section, header/body, or footer structure as a raw grid.
8. Fill cells with content
A slide accepts cell content in two distinct forms. Do not mix them in one slide.
Positional bodies for short grids with obvious traversal order — matched in source order, left to right within columns, top to bottom within rows, recursively through nesting:
#m.slide(layout: m.grids.columns("a", "b", "c"))[a][b][c]
Named cells: for anything larger or reusable — assignment stays independent of traversal order:
#m.slide(layout: composition, cells: (main: [...], notes: [...], source: [...]))
The cell ID connects all three layers: m.grids.cell("body") defines the cell, cells: (body: [...]) fills it, and label("mosaic-cell-body") styles it. Every content-bearing cell must be supplied; unknown IDs are errors, except id: none, which means "suppress" and is a no-op when the layout has no such cell. That makes cells: (body: [...], footer: none) safe to write across slides whose layouts do not all carry a footer.
Useful content values:
m.components.image(path("photo.webp"), alt: "..."): like native image() but defaults width/height to 100% and fit to "cover". scrim: paints a layer over the picture and takes any Typst paint, so scrim: black.transparentize(55%) darkens the whole picture and a gradient.linear(..) darkens only the band the text occupies. The same key is accepted in the image dictionaries the title, section, and image layouts take. Use Typst's path() so the location anchors to the calling document across the package boundary. For a full-bleed image, set the cell's inset: 0pt.
m.components.figure(path("chart.png"), caption: [..]): the in-cell figure. Defaults fit to "contain" so nothing is cropped, centres the picture, and with the default height: auto hands it the cell's height less whatever the caption consumes. Two of them in adjacent cells caption on one baseline whatever their aspect ratios, so no matching height has to be found by hand. auto reads the cell, not the space left inside it, so a figure that follows prose in the same cell needs an explicit height: and then captions directly beneath itself. Use m.components.image instead for a background plane or a deliberately cropped cell.
The body can be content rather than a source: m.components.figure(my-table, caption: [..], kind: table) captions a table or a code-drawn diagram, keeps its natural size, and scales it as a whole only when it is too large for the cell (never magnifies it). State kind: table because scaling costs the automatic detection; further named arguments go to the native figure for a content body and to the native image for a picture source. fit: and scrim: are picture-only and are rejected for content.
m.components.card() (clipped semantic panel), callout() (side stripe with optional title), badge() (compact inline badge), quote() (attribution treatment), divider() (horizontal rule), progress() (position indicator). All return ordinary content for any cell or plane. A component's names one color in the theme palette: , , or , plus for the deck's own surface. The component paints its border and rails in that color and its panel in that color tinted into the deck canvas. Every component also takes flat , , , , , , and overrides, each defaulting to (take it from the role), so a one-off color is rather than a new role.
9. Style with native Typst rules
Two kinds of rules cover a cell, split by what they touch:
- Content rules (text, alignment, paragraphs, lists) pass through the label as ordinary
set rules.
- Block rules (the cell's own fill, stroke, corner radius) are
set block rules on the same label. Mosaic confines them to the cell's own block, so blocks inside the content keep their own paint:
#show label("mosaic-cell-copy"): set align(left + horizon)
#show label("mosaic-cell-copy"): set text(fill: black, size: 1.1em)
#show label("mosaic-cell-copy"): set block(fill: white)
There is no height to manage: the engine sizes the cell's block to its track, so a 1fr or fixed-track cell paints edge to edge and an auto-track cell paints as tall as its content. The planes carry <mosaic-background> and <mosaic-foreground> and take the same rules. Because these are set rules, a rule scoped inside a block overrides a deck-wide one for exactly the slides in that block.
One boundary is native Typst, not Mosaic: properties an element pins on itself (heading weight and size, raw font, figure.caption color) resist label rules, because the element's own rule sits closer to the glyphs. A label rule's unpinned properties (a text fill, say) still flow in — only the pinned ones are overridden, which is why half a rule can work. Restyle pinned properties with an element rule after setup:
#show heading.where(depth: 2): set text(weight: "extrabold")
Rules after #show: m.setup apply deck-wide. Scope a rule and slide inside a block to change only that slide:
#[
#show label("mosaic-cell-body"): set align(center + horizon)
#m.slide[Centered for this slide only]
]
Bundle repeated rules in a transformer and apply it once with #show::
#let styled(body) = {
show label("mosaic-cell-banner"): set text(size: 1.4em, weight: "bold")
body
}
#show: styled
Typography is native rules after setup:
#set text(font: "EB Garamond", size: 26pt)
#show heading.where(depth: 1): set text(font: "Inter", weight: "black")
A semantic heading feeds outlines and bookmarks; use text(...) directly for display type that should not appear in navigation, or heading(outlined: false, bookmarked: false)[...]. Do not invent a separate Mosaic styling API or put decorative styling into the grid tree. Use m.components when their documented semantic treatments fit; otherwise use native Typst.
10. Furniture: footers, planes, navigation
Keep three concerns distinct:
- Cells (
header, body, footer) participate in the resolved grid. A recurring footer is a setup cells: default for the real footer cell, applied whenever the resolved layout contains that cell. An explicit slide value overrides it; none suppresses it on one slide. Title slides have no footer cell, so they are unaffected.
- Planes are the reserved
background and foreground content entries. They cover the full usable slide area without changing grid measurements. A slide inherits the setup plane by default, replaces it with its own entry, or suppresses it with none.
- Runtime state supplies logical slide, section, and frame counters, read by components such as
m.components.progress().
A logo is ordinary setup foreground content: place(top + right, dx: .., dy: ..)[#image(..)] inside m.setup(foreground: ...). A photographic background is a slide-sized m.components.image (with scrim: for contrast) passed to background:.
m.components.progress() follows the logical slide counter (all frames of one slide share a number). Its variant: is "1/1" (default), "1", "circle", or "line". count: selects the "slides" (default) or "sections" counter. The indicator quiets itself on pages where its counter has no meaningful reading — the slides count on unnumbered slides (titles and sections), the sections count before the first section slide — so a bare progress() in a deck foreground is already correct page-number chrome, with no guard to write. quiet: overrides the rule: false always displays, true silences every unnumbered page even for the sections count. Hand-built footlines that read m.info() directly still guard with slide.numbered themselves. Appearance goes through role: (default "accent"), accent: (the completed portion), fill: (the inactive remainder), width: (line length or circle diameter), and thickness:.
Navigation stays native because headings stay native:
- Table of contents:
outline(depth: 2); every entry links to its heading.
- Breadcrumbs: contextual
query with a selector ending at here() to find the active section and slide headings.
- Section links:
query(heading.where(level: 1, outlined: true)); each result gives a label via body and a target via location().
- Slide links: label a content slide (
== Results <results>) and #link(<results>)[...]. For an explicit slide, put #metadata(none) <id> at the start of its content.
11. Incremental reveals
Write one logical slide; Mosaic adds frames until the last timed command has run and discovers the frame count. Hidden content keeps its space by default so the slide stays still; use before: "removed" when surrounding content should expand into that space. Choose the smallest command:
m.steps.pause: advances subsequent source-order content to the next frame. Scoped to its containing content stream, so it also works inside blocks, fixed cells, and planes. Empty leading, trailing, or consecutive pauses never create blank frames.
m.steps.on(range)[content]: shows content over an exact step range. Ranges are integers, open ("3-"), or closed ("2-4"). before: and after: control the surrounding steps and each take "visible", "hidden" (the default, keeps the space), "dimmed", or "removed" (releases the space).
m.steps.reveal[...]: accumulates a list or sequence one item at a time.
m.steps.replace[first][second]: swaps alternatives in one stable slot sized by the largest alternative.
m.steps.drawing: connects the same timing model to custom structures (CeTZ canvases, Fletcher diagrams); preserve hidden bounds so later additions do not shift the drawing.
#m.slide[
== Findings
- The estimate is positive.
#m.steps.pause
- The interval excludes zero.
]
Constraints and options:
- A heading cannot be placed inside an incremental grid node (
m.steps.reveal, m.steps.replace, and related step commands); keep headings structurally stable across frames.
setup(handout: true) emits only the final frame of each logical slide, including timed planes.
- Native counters and states advance once per physical frame by default. List them in
setup(frozen-counters: (...), frozen-states: (...)) to advance once per logical slide instead.
- Reveal one part of an equation at a time by replacing plain terms with colored underbraces via steps commands; the layout stays fixed.
12. Speaker notes and outputs
Attach notes with m.note[...]. Notes never render in the default output: "slides" and never add frames. A note outside timing commands applies to every frame; a note after m.steps.pause or inside a steps command follows that command's frame assignment. Multiple applicable notes accumulate in source order.
#show: m.setup.with(output: "speaker") // A4: frame thumbnail + notes
#show: m.setup.with(output: "notes") // A4: notes only
#show: m.setup.with(output: "split") // double-width: slide | notes
Every companion output writes one page per emitted frame and fails with an explicit overflow diagnostic when notes do not fit. Every frame carries <mosaic-speaker-notes> metadata (logical-slide, frame, notes) queryable with Typst's query.
"split" is the presenter-console build: each page is exactly twice the slide wide with no page margin, the slide unscaled on the left half and its notes on the right, which is the layout pympress splits automatically and pdfpc splits with --notes=right. The notes half stays black on white whatever polarity the deck carries.
Independently of output:, a deck that holds any note also embeds those notes in the PDF as speaker-notes.pdfpc, the pdfpc interchange format, keyed to the physical page each note is visible on. This needs no argument: an ordinary output: "slides" deck looks unchanged and carries its notes as data for any console that reads them. Notes reach the attachment as text, so words survive and layout does not; a note whose shape matters belongs in the split build. Recover the conventional sidecar with pdfdetach -savefile speaker-notes.pdfpc -o talk.pdfpc talk.pdf.
13. Recipes
-
Reuse a slide: define it as a function and call it wherever it should appear; each call is a new logical slide with the same incremental sequence.
-
One-off dark slide: scope set text(fill: white) and a background block inside #[ ... ] around one slide.
-
Aspect ratio: setup(paper: "4-3"); default is "16-9".
-
Inspect overflowing cells: set overflow: "record" on setup first, since observation is off by default. Mosaic then emits non-fatal metadata when a rendered cell exceeds its allocation:
typst eval 'query(<mosaic-overflow-warning>).map(it => it.value)' --in slides.typ
The reported logical-slide is not the page number once the deck uses m.steps.pause or m.steps. Ask for both when hunting a specific slide down, then render just those pages to look at them:
typst eval 'query(<mosaic-overflow-warning>).map(it => (it.value.logical-slide, it.location().page(), it.value.cell))' --in slides.typ
typst compile --root . slides.typ /tmp/p-{n}.png --pages 20,23 --ppi 55
Overflow is nearly always a body cell holding more prose than its band. On a stacked image variant (top/bottom) the usual fix is a smaller tracks: so the text band grows; when the body is a full bullet list plus a picture, abandon the image layout and use an ordinary content slide with m.components.figure(.., height: N%), which is predictable.
-
Convert a whole deck set: when porting many sibling decks, keep an identical m.setup block in each so they stay a series, and drive the build from one pattern rule. Watch for output collisions before running that rule: quarto render + pagedown::chrome_print print <deck>.pdf from the same basename a Typst deck compiles to, so a first make can overwrite an existing set of PDFs. Whether that is wanted (the Typst decks are replacing them) or not (both sets must survive, so Typst output belongs in its own directory) is the author's call — ask rather than assume, and check whether the directory is under version control before finding out the hard way.
-
Inspect deck metadata:
14. Diagnose before changing structure
When compilation fails:
- Recompile the smallest affected deck and capture the exact diagnostic.
- Check for an unknown layout name, a missing named cell, mixed positional and named content, or an invalid layout variant.
unexpected argument: variant on m.layouts.title(..) under Metropolis means that theme's title() computes the variant itself and takes only title, subtitle, authors, and date.
unexpected argument: number at src/incremental/transform.typ means an enum item carries an explicit number field, which Mosaic's incremental transform cannot rebuild. Two sources, and the second is easy to miss: literal 3. / 4. markers used to continue a numbered list (write #enum(start: 3)[..][..] instead), and any wrapped prose line that happens to begin with a number and a period — a line starting 1942. is parsed by Typst as an enum item numbered 1942. Grep the deck with grep -nE '^\s*[0-9]+\.' and reword so no line begins that way.
mosaic: content appears before the first heading handled by setup means something after #show: m.setup emits content before the first =/== or m.slide — often a tool-setup call (e.g. calepin.setup(..)) that looks inert but produces content. Move it inside the first slide's body.
failed to decode image (Format error decoding Jpeg ...) or unknown image format means the file's extension lies about its bytes. Typst chooses its decoder from the extension, so a PNG or WebP named .jpg fails. Check with file -b, and prefer adding a correctly-named copy over renaming when other documents reference the original name. Typst has no AVIF decoder at all — convert those.
- Confirm that dictionary-held functions are called with parentheses when needed, for example
(theme.layouts.section)().
- Confirm that
layouts: passed to setup is a dictionary and only overrides content, title, or section.
- Confirm that image variants receive an image and valid tracks.
- Change the narrowest source responsible, then recompile the original deck.
Do not add compatibility aliases for removed APIs. In particular, slides use layout:, not a separate semantic selector.
15. Verify the result
For a deck change:
typst compile --root . path/to/deck.typ /tmp/deck.pdf
pdfinfo /tmp/deck.pdf
Inspect representative rendered pages when structure, spacing, themes, images, or incremental frames changed. Check title, section, ordinary content, and any custom grid separately.
For changes inside the Mosaic repository:
make install
make check
make website
Completion requires:
- every edited deck compiles from current sources;
- page count and incremental frames are plausible;
- no content is clipped, overlapped, or assigned to the wrong cell;
- automatic and explicit slides use the intended layout;
- documentation examples use the same public API as the package;
- removed APIs were not reintroduced as aliases.
16. Reflect and improve this skill
After completing the task, reflect on the session and update this SKILL.md if something generalizable was learned.
- Reflect:
- Did any instruction here mislead, confuse, or get ignored during the task?
- Did the user correct an approach, state a preference, or surface a Mosaic edge case not yet covered?
- Did a compilation failure reveal a missing diagnostic step, or did the Mosaic API drift from what this file documents?
- Decide:
- Generalizable lesson about how this skill should work → update this SKILL.md with the Edit tool.
- One-off or user-specific lesson → do not update; consider auto memory instead.
- Nothing notable → say so explicitly and skip the edit.
- Edit: make small, surgical changes — refine a sentence, add a bullet or example. Do not rewrite sections wholesale. Never remove this reflection step itself.
- Report: tell the user in one or two sentences what changed (or that nothing changed) and why.