Making it your own#
The aim: a deck that looks like yours and not like the package.
Choosing a theme#
Five ship with the package, made for different occasions rather than one slide in five colours. The title sits in a bar, free, or under a line; the progress indicator grows, or is missing.
| Theme | Made for |
|---|---|
themes.default | A conference talk. Title in a coloured band, bar of progress. |
themes.lesson | A lesson. White paper, a running head, tinted panels with the caption inside, no progress bar. |
themes.night | A darkened room. Dark ground, one signal colour. |
themes.plain | Getting out of the way. White, black, one grey. |
themes.editorial | Reading rather than presenting. A serif face, a quiet rule. |
Changing one#
A theme is a plain dictionary, so + is all it takes:
#show: presentation.with(theme: themes.lesson + (accent: blue))theme(...) builds one from scratch. Its eight colour entries are the same eight a palette carries, listed in the next section. Four keys take one word each:
header"band","plain"or"run"footer"fraction","number","center"or"none"progress"bar","top","tick"or"none"box"bar"or"label"
A typo in one of those four is an error, not a silent default: the message names the values it accepts. title-slide and section are functions instead – those two are whole pictures, not variations on one theme. The typographic keys and the measures are in the API reference.
Colour, separately: palettes#
A theme says how a slide is built; a palette says what colour it is. The two vary separately, which is why they are separate arguments. A palette overwrites partially, only the entries written down:
#show: presentation.with(theme: themes.lesson, palette: (accent: blue))
#show: presentation.with(theme: themes.lesson, palette: palettes.dark)The eight entries are exactly a theme’s colour entries: paper the ground of the slide, ink the body text, strong the carrying dark colour, accent the signal colour, muted the secondary matter, surface the ground of a card, border its edge, and inverted, whether light text stands on a dark ground. An entry that does not exist is refused: palette: (acent: blue) stops with a message.
Five ship with the package, and each composes with each of the five themes:
| Palette | Where it comes from |
|---|---|
palettes.light | The colours of themes.default, so this one changes nothing. |
palettes.mono | The greys of themes.plain, two moved so it passes the contract below. |
palettes.textbook | The colours of themes.lesson, one grey moved. |
palettes.parchment | The laid paper of themes.editorial, two tones moved. |
palettes.dark | The dark ground of themes.night, with a deeper accent. |
That is why the dark room needs no theme of its own: darkness is a palette rather than a design. themes.lesson under palettes.dark is still the lesson design, only dark.
themes.night stays a theme all the same. Its cyan glows on night’s own ground but all but vanishes on the ground an inverted slide lays behind it, so palettes.dark takes a deeper blue that holds on both while the theme keeps the cyan it was designed around.
title-fill and rule-fill. Whether they follow is up to the theme. All five bundled ones let them follow – either as a function of the palette, title-fill: p => p.strong, or as none, which means the accent. A theme of your own that names a fixed colour there keeps it under every palette: a colour someone named out loud is not swapped behind their back.The colours of a theme#
The five bundled themes fill those eight roles differently:
#import "@preview/typstage:0.2.0": themes
#themes.night.accent // the theme's signal colour, as a colourcard and callout take their colours from the running theme, so a change of theme recolours them. Where one card is to look different, it takes color: and fill:.
card(color: …), callout(color: …), ggb-style(color: …).Independently of the theme the package hands out four colour constants — dark, accent, paper and muted — the default look. They are handy where a slide needs a shade and the theme is not being changed; whoever swaps the theme is better served by its entries.
Inverting one slide#
For the slide that carries a single number there is invert. The ground becomes the palette’s text colour and the text becomes its ground; muted, border and surface are mixed from those two, strong and accent carry over unchanged. Running head, footer, slide number, progress bar, card and callout follow.
In the heading notation it is a marker in the slide body, like #pause:
== Reached by 2026
#invert
#statement[74 %]In the argument notation it is an argument of slide:
#slide([Reached by 2026], invert: true)[#statement[74 %]]Only a regular slide inverts. Title and section slides are whole pictures the theme draws itself, and neither takes the argument.
The #invert marker is found wherever the body can be walked: nested in a block, an align, a table cell or a grid, in the slide’s own heading, and behind #set and #show rules. It is not found where the content is handed to a closure. Measured, that is nine: context, fit, anim, card, callout, tiles, cue, stagger and alternatives. There the slide is left as it is, without a word. Where you need one of those, write slide(invert: true), which never depends on the walk.
The contrast contract#
The bundled palettes are measured before they ship, against the WCAG 2 contrast ratio. Seven pairs are checked:
| Pair | At least | What for |
|---|---|---|
ink on paper | 4.5 | body text on the slide |
ink on surface | 4.5 | body text in a card |
muted on paper | 4.5 | footer, subtitle, running head |
accent on paper | 3.0 | rules, progress bar, marker |
accent on ink | 3.0 | the same on an inverted slide |
accent on black | 3.0 | the overtime of the full-screen clock |
border on paper | 1.2 | hairlines |
The second to last has no palette role as its ground: the full-screen clock is black from edge to edge whatever the deck’s palette says, and its overtime digits are set in the accent.
All five bundled palettes are checked automatically, upright and inverted. A colour moved there that breaks the contract stops the build and names the number it missed.
The contract holds only the bundled palettes. A palette of your own faces no gate: it is neither warned about nor recoloured. palette-report(…) hands the same measurement back as a list:
#for f in palette-report((paper: white, ink: black, surface: white,
muted: luma(55%), accent: blue, border: luma(86%))) [
#f.pair: #calc.round(f.ratio, digits: 2) (wants #f.min) #f.ok \
]contrast(a, b) is the arithmetic itself and takes any two colours.
And the five themes do not all pass it. They were measured before the palettes existed, and the result stands here rather than being quietly coloured away:
| Theme | What falls short |
|---|---|
themes.default | nothing, all seven pairs hold |
themes.lesson | muted on paper |
themes.night | accent on ink |
themes.plain | muted on paper, and accent on ink |
themes.editorial | muted on paper, and accent on paper |
None of those colours was changed: moving them would have changed every deck already written, and what muted carries is secondary matter – slide number, subtitle, running head. Anyone who wants the numbers met lays the matching palette over the theme:
#show: presentation.with(theme: themes.editorial, palette: palettes.parchment)The text colour is never inferred from the fill. A muted sage such as #aebdb3 reads as “light” to a luminance rule, yet white on it measures 1.96 to 1, far under the 4.5 that body text wants. So the package measures with contrast and recolours nothing on its own.
The one exception lives in the theme, not the palette. Where a theme uses strong as text – the heading in themes.lesson, the section title in themes.plain – it picks between strong and ink by contrast against the ground, because one colour cannot serve as both a dark band and text on a dark ground.
The canvas#
width, height and margin on presentation set the canvas. The default is 16:9 on an A4 width; 4:3 is width: 800pt, height: 600pt. Everything the theme draws scales along.
Typography#
style is a show rule applied to the slides and to the moving parts:
#show: presentation.with(
style: it => { set text(font: "Libertinus Serif"); set par(justify: false); it },
)A tracked element is typeset a second time in a frame of its own, and that frame never sees a #set rule from the document. Shared typography therefore has to go here: a #set text after the show rule reaches the slides but not the flying pieces, and the difference only shows up mid-flight. One exception typstage carries across itself, the numbering of figure, math.equation and heading: a #set math.equation(numbering: "(1)") in the document numbers an equation in a flying piece as it does on paper. A #show rule that numbers or counts does not reach it, though, and then the numbers on the slides after it go wrong in the browser as well: with #show math.equation.where(block: true): set math.equation(numbering: "(1)") in the document, an equation two slides after an anim and an alternatives read (2) in the browser and (5) on paper. Inside style it reads (5).
For the shapes typstage draws itself there is a second route, label rules before #show: presentation. They reach more, including the header, the footer and the title slide. See /Labels: reaching every shape the package builds/ below.
Right to left#
A deck in Arabic, Hebrew, Persian or Urdu needs one line, and it is Typst’s own:
#set text(lang: "fa")
#show: presentation.with(title: [چهار مثلث در یک مربع])lang is enough for a language Typst reads from the right; #set text(dir: says it outright for any other. Typst turns the paragraphs, lists and columns around by itself. typstage turns around what it draws by hand: the title in its band, the bar beside a
rtl)callout, the number in the footer, the progress bar, the title and section slides. alternatives, build and tiles anchor at start, which is the right edge in such a deck, and a callout without title: reads its caption in Arabic, Persian or Hebrew.
The rule goes before the show rule. After it, it still reaches every slide body and every moving part, but not the title slide, the section slides and the footer: those are drawn outside the body and would keep reading from the left.
Not mirrored: the transitions. A "slide" still comes in from the right; from: "left" turns it around where that reads better.
Building blocks for the body#
Six functions for the body of a slide: card, callout, side-by-side, tiles, statement and fit. The coloured ones take the running theme’s colours unless told otherwise.
card: the named box#
#card(title: [Power function])[$f(x) = x^n$ with $n in NN$.]number: puts a numbered disc in front, for the running order where the number belongs to the matter – card(number: 2, title: [Second step])[…]. color: tints the bar, fill: the panel.
callout: the one that has to stick#
#callout[The exponent decides the symmetry.]title: changes the caption (it follows the document language by default), color: its colour, and title: none leaves it off.
side-by-side: two columns#
The usual case for a slide with something to look at: the drawing or the applet on the left, the words on the right.
#side-by-side(
card(title: [Even exponent])[Symmetric about the $y$ axis.],
stagger[
- $f(-x) = f(x)$
- Range $W = [0; oo[$
],
)split: takes the column widths; the default gives the first column a little more, because that is usually where the picture goes. More than two columns are allowed — they then share the width equally, unless split: names as many values.
equal: true makes every column the height of the tallest; without it each box stands as tall as its own text, and two cards side by side look differently weighted. The row is measured once and its height handed on as a length, which is why equal reaches card and callout rather than arbitrary content. To reveal one of them, put the anim inside the box, card[#anim[…]], not the box into an anim: in the browser a revealed box stands only as tall as its own text.
tiles: the grid that numbers its own reveals#
Each tile appears one step after the one before, without an anim per tile and a number counted up.
#tiles(
card(title: [one])[Observe],
card(title: [two])[Conjecture],
card(title: [three])[Justify],
)columns: sets how many there are (up to three by default). stride: 0 puts them all on the same step and staggers only through stagger, in milliseconds — a wave runs through the grid then, instead of a sequence of keypresses. A tile that reveals something of its own moves the tiles after it back, as the pieces of a stagger do:
#tiles(stride: 0, stagger: 90, [A], [B], [C], [D])duration: and easing: are those of anim and apply to every tile alike: a grid moves as one thing. Given neither, the presentation’s duration and the house curve apply.
statement: the large claim#
#statement[$ a^2 + b^2 = c^2 $]statement asks for the full width explicitly and centres within it. A bare align(center, …) inside a tracked element cannot: the element is only as wide as its content.
fit: working content into the room it has#
For the one piece whose size is not written in the deck: the wide table out of the analysis, the generated chart, the list from a data file. Left alone, such a block runs over the edge of the slide – visibly in the PDF, cut away in the browser, where the slide sits in a frame of fixed size.
== Regression results
#fit(wrap: false, my-table)fit measures the block against the place it stands in and scales it geometrically, so the proportions are kept and no factor is given by hand. The result is the same in the HTML and in the PDF.
Width first, then smaller. The block is offered the full width before it is measured. A paragraph or a list then wraps into the space instead of shrinking, and only what is still too tall afterwards is scaled. A block that already fits is left untouched.
wrap: false for anything that lays itself out in columns. A table, a chart or a drawing does not wrap when it is offered a narrower width, it rearranges itself – under the default wrap: true the columns are squeezed, the digits overlap, and nothing is scaled at all. wrap: false measures such a block exactly as it stands. It is the one setting worth knowing before the first use.
It only shrinks. grow: true also blows up what is smaller than its place, for the one large number meant to fill the slide. shrink: false takes the shrinking away and leaves only the growing.
#fit(grow: true)[42%]width and height take auto, a length or a ratio. On height: auto the block takes what is left over below the rest of the slide, so a fit under two bullet points reckons with them. That backfires inside a card: the box becomes slide-tall, is cut off at the bottom, and whatever follows falls off the slide. Give height: explicitly inside a card.
No reveal inside a fit. Two things do not survive being measured. A pause is found by walking the slide body, and a fitted block is a closure that walk cannot enter, so its steps fall away silently. And a measured block gets no bounded height to reckon against, so a tracked element inside one cannot reserve the room for its marker.
fit therefore stops with a message that names the thing, for pause, anim, stagger, alternatives, morph, tiles, video, embed, flipbook, build, scene, camera and cue – in both outputs, and also when the fit sits inside another fit. Put the fit inside the reveal rather than around it:
#anim(fit(wrap: false, my-table)) // yes
#fit(anim(my-table)) // nospeaker-note and bridge-job are allowed inside a fit. The other direction is not: a note made only of a fit carries no text, and speaker-note refuses it with a message.
The arithmetic is taken from mosaic, which took it from Touying 0.7.4; Touying credits the work on it to Andreas Kröpelin (Polylux PR 91) and to ntjess.
overflow: the checking pass before the talk#
fit answers the one block whose size you already suspect. overflow answers the question you cannot ask slide by slide: does anything in this deck run over the room it has? It measures every slide body and names the ones that do not fit.
#show: presentation.with(overflow: "error")It is off by default and meant to be switched on for a run, not left on while writing. A build script can raise it from the command line instead of editing the deck, which is how the seventeen example decks of this package are measured on every push:
typst compile --features html --format html \
--input typstage-overflow=error deck.typ deck.htmlThe input raises, it never lowers: of the two settings the stricter one wins, "none" < "record" < "error", so no run can quietly switch a check off in passing.
A deck needs this more than a document does: an overrun on a page stands past the margin where the eye catches it, while a slide goes into a frame of fixed size and what sticks out is cut away.
"none"- nothing is measured. The default.
"error"- the whole deck is built, and it then stops with every place at once rather than with the first. One run, the whole list.
"record"- it carries on and files a queryable record per finding instead, for a tool or a build script. Typst gives a package no warning channel, so
"record"prints nothing by itself.
The message names the slide, the step and the amount (shortened here):
error: assertion failed: typstage: 2 slides run over the room the body has. …
slide 2, from step 1 at the earliest: 311.14pt too tall, 675.76pt of content in 364.61pt of room
slide 3, from step 2 at the earliest: 296.49pt too tall, 661.1pt of content in 364.61pt of room
Shorten the slide, split it, or put the block that does not fit into fit(). …Why the step says “at the earliest”. A slide is the same height on every step – only what is drawn changes, not the room reserved for it. The step is therefore a lower bound, exact only where the thing that overruns is itself a reveal. The slide is named correctly either way, and that is the part to act on. On paper no step is named, because every step stands on the page at once; in the records that shows as step: 0.
The records are read with typst eval, and for that the deck has to be on overflow: "record" – on "error" this command stops with the error instead:
typst eval --target html --features html --in deck.typ \
'query(<typstage-overflow>).map(e => e.value)'which gives one entry per finding:
[{"slide":2,"step":1,"height":675.76,"room":364.61,"over":311.14},
{"slide":3,"step":2,"height":661.1,"room":364.61,"over":296.49}]What the check does not see. Only the height is measured, so a body that is too wide goes unnoticed; fit is the answer to that case. A height: 100% in the body measures 0 and a 1fr collapses. Anything drawing outside its own layout box – scale, move, place with an offset – is invisible to a measurement. Title and section slides are never measured: they have no body block to overrun.
And one thing is reported where nothing shows: trailing spacing, a v() at the end of a body, takes room in the measurement and draws nothing.
In HTML the pass costs up to half again as long per deck; on paper it costs next to nothing.
With pages: "step" the PDF is not measured: every step page sets the same body as the one page per slide, and measuring it there cost convergence for decks that look something up in their body. The same goes for a handout that is given pages: "step", which is what bundle() does. The HTML still measures, and names the step as well.
drift: the check for scenes that travel#
overflow asks whether a slide fits its room. drift asks the other question one cannot check slide by slide: does a scene stand still while the talk pages through it?
A drawing is as large as what it holds, a CeTZ canvas above all. Change the content across the stops of a scene and every frame comes out a different size, so the drawing sits somewhere else in its box each time: paging moves the whole picture although only one point was meant to move. Every scene measures its frames, and drift says what happens with the findings.
"error"- the whole deck is built, and it then stops with every scene at once. The default.
"record"- it carries on and files a queryable record per finding.
"none"- nothing is measured at all.
#show: presentation.with(drift: "record")The message names the slide, the step and the numbers (shortened here):
error: assertion failed: typstage: 1 scene draws frames of different sizes. …
slide 4, from step 1: 28 frames in 19 different sizes, up to 28.35pt apart across and 53.86pt downThe records are read exactly as the overflow ones, with query(<typstage-drift>), and for that the deck has to be on drift: "record".
Why this check is on where overflow is not. Only decks that use scene pay for it, while overflow measures every body of every deck. And what this one finds is invisible while writing: every frame on its own looks right, and only paging shows the drawing travelling. Only the browser branch measures; on paper a single still image stands there, and a still image does not travel.
100% measures the same on every frame and drops out of the check, rightly so: it already has a fixed frame. And a drawing that only grows to the right and downwards is reported although its ink does not move – steady: false on that scene takes it out of the check.Slides without a title#
A bare == leaves the title band off; the body moves up to the top margin and gets the height the band would have taken. A running header such as the one of themes.lesson – slide number, section, hairline – goes with it, and its height is not held back either. Footer and progress stand as on every slide. This is the slide for the one large formula, and the target of a morph that is to fly into the middle:
==
#place(center + horizon, morph(<derivative>, text(size: 2.4em)[
$f'(x) = lim_(h -> 0) (f(x+h) - f(x)) / h$
]))In the argument form all three spellings are allowed: slide[body] without a title, slide(none)[body] explicitly without, slide([Title])[body] with. A slide is without a title when its heading draws nothing: ==, slide(none), also == #h(0pt). A heading that carries only a formula or a picture is a title and gets its band and its running header.
bleed: to the edge of the canvas#
bleed lays its body over the whole canvas: its origin is the slide’s top left corner, its room the slide’s full width and height, whatever the margins, the title and the running header take. It lies right above the slide’s ground and below everything else; the title and the body are drawn on top.
==
#bleed[
#image("harbour.png", width: 100%, height: 100%, fit: "cover")
#place(dx: 480pt, dy: 300pt, morph(<sign>, card[How far is it?]))
]A place inside bleed counts from the corner of the canvas, without an anchor too and behind a picture of full height too. It is the corner where the text begins: in a deck that reads from the right it is the top right one, and a positive dx leads from there off the slide to the right. That is how Typst’s place counts everywhere, in the slide body as well and without this package. To count from the left, spell the anchor out – #place(top + left, dx: 40pt, lands at exactly (40, 300) in a Persian deck too –, and a paragraph inside a block placed that way then aligns to the left until an
dy: 300pt, …)align(start) around the content puts it back.
anim, cue and morph inside work as anywhere else: the sign flies in from the slide before and on to the next one. The style hook wraps the body of bleed as it wraps the slide body; a style that indents the body with pad indents the picture as well. And on a slide with bleed the hook runs twice, because picture and body are laid out separately: a hook that counts something on the side, or writes a state, does so twice there.
A slide with bleed draws no chrome: no running header, no slide number, no footer line, no progress bar – on paper, in the browser and in its print view. It still counts, and on the next slide the bar is back with that slide’s reading. The handout is a PDF as well: there the bleeding slide is the one slide without its number. To be able to name it in a conversation, put the number into the bleed yourself – there it lies on the canvas and comes along. The overflow check measures the body alone; what stands in bleed never overruns.
bleed stands at the top of a regular slide’s body, before other content and before the first #pause, once per slide. #set and #show rules, #invert, #transition, #speaker-note and #class-clock may come first. It is laid out before the body; written further down, the steps, the footnote numbers and the stacking would follow another order than the source. So the build stops instead of reordering without a word:
==
How far is it?
#bleed(rect(width: 100%, height: 100%, fill: blue))The same for bleed in a title, in a note, outside the deck, and inside a block, a grid, a list, align, context, anim, fit, card or alternatives – also below a rule #show: it => block(it), which lays the rest of the slide into a block. What is to appear later stands inside bleed in an anim.
What lies on top in the browser. Tracked elements – anim, cue, morph – are drawn by the browser on a layer of their own above the slide. So a picture in bleed is best left untracked; wrapped in an anim itself, it would lie over the body’s text in the browser and under it on paper.
What a picture costs. In the HTML every slide carries its picture embedded on its own. Two slides with the same photograph carry it twice; a photograph for a projector rarely needs more than 1920 pixels across.
Labels: reaching every shape the package builds#
Every shape typstage draws itself carries a fixed Typst label: the ground, the header band, the slide title, the footer, the progress indicator, the card, the callout, the statement, the title and section slides, the box that stands in for a video. An ordinary show rule reaches it – no theme key, no fork.
#import "@preview/typstage:0.2.0": *
#show label("ts-slide-header-band"): set rect(fill: rgb("#4c1d95"))
#show label("ts-slide-title"): set text(fill: rgb("#fde047"), style: "italic")
#show label("ts-card"): set block(fill: rgb("#eef2ff"))
#show label("ts-statement"): set text(fill: rgb("#be123c"), weight: "bold")
#show: presentation.with(theme: themes.default)Two kinds of rule cover all of it. The surfaces – grounds, bands, hairlines, bars, boxes – take set rect(..), set block(..), set circle(..) or set line(..). The type takes set text(..). Both apply identically in HTML and PDF, with two exceptions: the six labels under /Media and handout/ are drawn only in the PDF, because the browser puts the real <video> or <iframe> in their place; and ts-slide-progress under progress: "bar" and "top". In the browser the runtime draws that bar itself, so that it can grow on a slide change, in the theme’s colour and height: no rule on the label or on rect reaches it there. Under progress: "tick" such a rule reaches both outputs.
For the surfaces the short form works and the long one does not:
#show label("ts-slide-progress"): set rect(fill: green) // yes
#show label("ts-slide-progress"): it => { set rect(fill: green); it } // noThe short form puts the style rule around the element it matched, the long one puts it inside – and inside the rectangle there is no second rectangle for it to reach.
For the 19 type labels the two spellings are equivalent: what sits inside the matched element there is the text, and a rule reaches that from within.
Where the rule has to stand#
Before #show: presentation. That one place reaches everything: the slide background, the chrome layer with header, footer and progress, the title slide and every moving piece.
The style hook does not. It is wrapped around the slide body, and header, footer, progress and the two whole-picture slides are built beside it. Measured, all 42 rules one by one: from style exactly the 13 that stand in the body take effect – ts-card…, ts-callout…, ts-statement and the three ts-media-… surfaces. The other 29 stay silent, without a warning.
A show rule written after #show: presentation does not reach a tracked element (anim, morph), for the reason given under Typography: in the browser every moving piece is typeset a second time in a frame of its own, and that frame never sees a #show rule from the document body.
#show: presentation.with(theme: themes.default)
#show label("ts-statement"): set text(fill: green) // too late
== A slide
#statement[still]
#anim(statement[moving])Here still comes out green and moving black. With the same rule one line further up, both look alike. The PDF does not show the difference, because nothing is typeset twice there.
This holds for every #show rule, not only for label rules.
What a label rule changes and what it does not#
Reachable is whatever the package does not set explicitly: for type everything, for surfaces fill, stroke and radius.
width stands as an argument everywhere and is therefore nowhere reachable. height has three exceptions: ts-card, ts-card-bar and ts-callout get their height as auto, and auto cannot beat a rule.
#show label("ts-card"): set block(height: 150pt) // works
#show label("ts-card"): set block(width: 30%) // does notThe first line blows the card up to 150 pt and pushes the callout under it off the slide. On the chrome surfaces and the handout frame neither line does anything; what a width rule seems to change there are the blocks inside the content, see the next box.
The slide’s arrangement is not reachable either. How tall the header builds, how far the rule sits under the title, where the bar goes – no show rule reaches into that. The theme keys are there for it: head-gap, band-height, rule-size and the rest. What a rule can do is move a finished piece as a whole: move on ts-slide-footer shifts the number, see “Moving the built-in number”.
A rule on block or rect reaches inwards: it holds for the labelled surface and for every block inside it. For fill, stroke and radius that is caught – the card puts the document’s own setting back inside. For the spacings it is not, and then a label rule moves the slide:
#show label("ts-card"): set block(below: 60pt)
== A slide
#card(title: [Card])[Body]
#callout(title: [Note])[Remember this]The callout then moves down, and everything below it with it – by the spacing given minus the block spacing already there, per edge. Setting above and below at once gives twice the shift.
That is not a promise but a side effect of Typst’s style rules. Labels are meant for type and surface; for spacings, use the building blocks’ own arguments or the theme keys.
The complete inventory#
What stands here exists; what exists stands here. The names follow one scheme: ts-, then the place, then the part. Places are slide (the ordinary slide), title-slide, section-slide, card, callout, statement, media and handout.
The mnemonic: slide in front means the ordinary slide; slide behind title or section means that kind of slide. So ts-slide-title is the title of an ordinary slide and ts-title-slide-title the title of the title slide. Reaching for the wrong one of such a pair does nothing at all, silently.
A label the current theme does not draw – a header band under header: "run", say – is not on that slide, and a rule on it does nothing.
The ordinary slide
| Label | What it is | Rule |
|---|---|---|
ts-slide-ground | The slide’s ground | rect |
ts-slide-header-band | The header band, only under header: "band" | rect |
ts-slide-header-text | The running header of number and section, only under header: "run" | text |
ts-slide-header-rule | The hairline under it, only under header: "run" | rect |
ts-slide-title | The slide title, under all three header styles | text |
ts-slide-title-rule | The rule under the title, only when rule-size > 0pt | rect |
ts-slide-notes | The slide’s footnotes, only where it has any | text |
ts-slide-notes-rule | The short rule above them | rect |
ts-slide-footer | The footer line | text |
ts-slide-number | The slide number in it | text |
ts-slide-footer-rule | The hairline above it, only when footer-rule > 0pt | rect |
ts-slide-progress | The progress bar, or under progress: "tick" the marker that travels | rect |
ts-slide-progress-track | The track it travels along, only under progress: "tick" | rect |
The title slide
| Label | What it is | Rule |
|---|---|---|
ts-title-slide-ground | Its ground | rect |
ts-title-slide-band | The band along the top edge, only in themes.lesson | rect |
ts-title-slide-title | Its title | text |
ts-title-slide-subtitle | Its subtitle | text |
ts-title-slide-rule | The accent stroke; themes.editorial has two, themes.plain none | rect |
ts-title-slide-byline | The line of author and date | text |
The section slide
| Label | What it is | Rule |
|---|---|---|
ts-section-slide-ground | Its ground | rect |
ts-section-slide-bar | The bar along the left edge, only in themes.lesson | rect |
ts-section-slide-title | Its title | text |
ts-section-slide-rule | The accent stroke; themes.night has two, themes.lesson none | rect |
ts-section-slide-parent | The line above it naming the sections this one hangs under. Only from the second structure level on, so never at slide-level: 2 | text |
ts-section-slide-back | The link back to the contents, at the end of the line, bottom – on the right in a deck that reads from the left, on the left in one that reads from the right. Its word comes from section-back on presentation and by default follows text.lang. It appears only when the deck has a contents() and that contents does not stand on the section slide itself – and it is the one label here that the theme still draws when the theme brings its own section function | text |
A section slide has no subtitle in typstage, so the list names none.
The building blocks in the body
| Label | What it is | Rule |
|---|---|---|
ts-card | The card: surface, border, rounding and all of its contents | block |
ts-card-bar | The coloured tab above it, only under box: "bar" | block |
ts-card-title | Its caption | text |
ts-card-disc | The disc of the number, only with number: | circle |
ts-card-number | The numeral in it | text |
ts-card-body | Its body | text |
ts-callout | The callout: surface, bar, rounding. The bar on the left is not a label of its own, it is this one’s left stroke – set block(stroke: (left: 4pt + red)) recolours it | block |
ts-callout-title | Its caption | text |
ts-callout-body | Its body | text |
ts-statement | The large statement. size acts as a factor on it, because statement measures in em | text |
Media and handout
| Label | What it is | Rule |
|---|---|---|
ts-media-fallback | The box that stands in for a moving element in the PDF. A container only, so a radius rule on it is not visible while a fill rule is | block |
ts-media-fallback-empty | The grey box inside it when no fallback: was given. That one has a surface | block |
ts-media-poster | The grey area of a video without a poster: | rect |
ts-handout-frame | The framed box of one slide on the handout page | block |
ts-handout-lines | The writing lines beside or below it | line |
ts-handout-note | The speaker note, where there is one | text |
ts-canvas-note | The margin note under a slide whose canvas is larger than the slide itself – on paper only | text |
A theme with its own title slide draws none of these labels. title-slide and section in a theme are functions and paint their picture themselves, so whoever brings their own loses the labels of that slide kind, and nothing warns about it. Which of the bundled themes draws what stands in the /What it is/ column.
The invisible markers carry none. Every moving element paints an invisible marker rectangle around itself, and pin does the same for a single glyph – machinery, not a shape, so neither carries a label.
Typst labels and the runtime’s CSS classes are two separate namespaces. .ts-slide in the stylesheet is a slide’s <section> in the browser, ts-slide-title is a Typst label – one hyphen apart and unrelated. Typst’s HTML export does put a data-typst-label attribute on some shapes and not on others. That is Typst’s own by-product, not a promise of this package: do not build CSS on it.
info(): what the deck knows about itself#
Labels say how a shape looks, not what stands in it: the slide number, the fraction, the chapter in the running header. info() hands those out:
#context {
let deck = info()
[#deck.section.title #h(1fr) #deck.slide.number / #deck.slide.total]
}Every number the package prints on a slide comes out of this dictionary, so a hand-built footer and the built-in one cannot disagree. What comes back:
| Field | What is in it |
|---|---|
title, subtitle | The deck’s title and subtitle, as presentation or a title-slide received them |
author, date | From the same place. date is whatever was passed, a datetime or content |
slide.number | This slide. Counted the way the footer counts, so title and section slides are not in it |
slide.total | How many slides are counted |
slide.numbered | Whether this slide is one of them. false on a title and on a section slide |
step.number | The step the calling content itself stands on |
step.total | How many steps this slide has |
section.number | Which section is running, 0 before the first |
section.total | How many sections the deck has |
section.title | Its title, or none before the first |
levels | One entry per structure level, outermost first. Empty at slide-level: 1 |
outline | The whole structure, one entry per section slide in the order they come |
section always means the level directly above the slide. At the default slide-level: 2 that is the only level there is, and section is then levels.last() without its depth. A deck with more than one level – see “More than two levels” – finds them in levels and in outline:
| Field of an entry | What is in it |
|---|---|
levels.at(i).depth | The heading level, 1 for =, 2 for == |
levels.at(i).title | The title, or none while no section of that level is running |
levels.at(i).number | Which section of that level it is in the whole deck. It never goes back, so it also reads as progress |
levels.at(i).total | How many sections that level has in the whole deck |
levels.at(i).index | Which one it is under the same parent, what Beamer prints as 1.2 |
levels.at(i).count | How many siblings it has there. index and count are 0 while no section of that level is running |
outline.at(j).depth | The same for an entry of the outline |
outline.at(j).title | Its title |
outline.at(j).number | The same count as levels.at(..).number. Comparing the two says whether the entry is past, running or still to come. An equal number is running only while that level’s index is not 0: after a new part, the last chapter of the part before keeps its number in levels and is past |
outline.at(j).here | Whether the slide being shown is that very entry. Only a section slide can be, and only a theme’s own section function can read it there: a section slide has no body for a deck to write into |
A progressive agenda therefore needs no second count:
#context {
let d = info()
stack(spacing: 0.6em, ..d.outline.map(e => {
let level = d.levels.at(e.depth - 1)
let running = e.number == level.number and level.index > 0
text(
weight: if running { "bold" } else { "regular" },
fill: if e.number <= level.number { black } else { luma(60%) },
[#h((e.depth - 1) * 1.4em)#e.title],
)
}))
}One number stands apart: the speaker view and the overview count every slide, title and section slides included, while info().slide.total counts the way the footer counts and leaves those out.
Two counts, not one#
A slide is one picture, a step is one press of the arrow key. The deck counts both, and this manual keeps the two words apart.
step.number is the step the calling content itself stands on: 1 in the body of a slide, and inside an anim, a stagger or an alternatives the step of that reveal – the first of them where the reveal covers several. So a display naming the current step has to sit inside the reveals; the browser typesets nothing anew:
#let where = context {
let d = info()
[Step #d.step.number of #d.step.total]
}
== Four versions
#alternatives(where, where, where, where)Paging through, that prints “Step 1 of 4” up to “Step 4 of 4”.
On paper there is no current step: the page shows the slide in its final state, everything at once, and step.number equals step.total.
step.total counts what the runtime in the browser counts – for every building block that consumes a step, and the PDF names the same number.Moving the built-in number#
The theme places the footer, and no rule reaches that place. What stands there can still be moved as a whole, with a rule on ts-slide-footer that wraps it in move. move shifts the drawing and leaves everything around it where it was, so the number moves by the same amount on paper and in the browser:
#show <ts-slide-footer>: move.with(dx: 14pt, dy: 8pt)
#show: presentationPositive values go right and down. Like every label rule it stands before #show: presentation. The running header of header: "run", where themes.lesson carries its number, moves the same way with a rule on ts-slide-header-text – the whole line, section title included, while the hairline under it stays where it is. presentation(margin: …) moves the number as well, and the body with it: the number keeps the side margin’s distance from the edge. A margin in em counts against the theme’s type size, in the PDF and in the browser alike.
page.width, page.height and page.margin report the document’s page there (Typst’s A4 sheet unless the deck sets one) instead of the slide, and here().position() reports (0, 0) – a move or a place computed from them lands somewhere else in the HTML than in the PDF. counter(page) counts pages, of which the HTML has none, so it stays at 1; the slide number is info().slide.number. And #set page(footer: …), numbering: or foreground: draw into the page margin or over the page: a slide has no margin, and Typst’s HTML export drops page rules altogether, so a number drawn that way shows in the PDF at most and never in the HTML.Where a hand-built footer goes#
typstage draws no footer on a title or a section slide, and nothing belongs in the counter slot there. slide.numbered says when that is the case:
#let footline = context {
let d = info()
let number = if d.slide.numbered [#d.slide.number / #d.slide.total] else []
place(bottom + right, text(size: 12pt, fill: muted, number))
}On an ordinary slide it goes into the body:
== A slide
#footline
The text of the slide.At the top of the body, that is: before the first #pause, and not inside anim, stagger, alternatives or any other building block that reveals. Behind a #pause the footer belongs to its run and stands level with the run’s last line instead of at the foot of the slide, in both outputs – as long as it is a place. Pushed down with #v(1fr) instead, it reaches the foot of the slide in the PDF and falls below the stage in the browser. Inside a reveal the browser sets the piece in a frame of its own that begins where the piece begins, and a place(bottom + …) in there lands lower in the HTML than in the PDF, down to below the stage. At the top it aligns against the body, and does so in both.
On the title and the section slides it has to go into the theme: both are functions, and a function wrapped around another adds to it instead of replacing it.
#let base = themes.default
#let with-foot(f) = (t, s, geo) => { f(t, s, geo); footline }
#show: presentation.with(
theme: base + (title-slide: with-foot(base.title-slide),
section: with-foot(base.section)),
)Not through style:. style: it => { footline; it } looks like the shortcut that puts the footer on every slide at once. But style is also the template each moving element is typeset with a second time, so whatever draws in there is drawn again inside every sprite: a deck with three reveals per slide showed the footer four times over. In the body it is drawn once.
style: is for typography – typeface, size, colour, leading – and for that it is exactly right: background and sprite need the same.
A footer placed in the body sits at the bottom of the body, not at the bottom of the slide; the theme’s foot-gap lies in between. A dy: on the place moves it where it belongs.
Being part of the body, it is part of the slide: a camera takes it along and it can leave the frame, while the built-in number stays put (see “What travels along and what stays put”).
info() reads the state of the slide being typeset and therefore needs a context around it. Before the presentation there is nothing to read, and it stops with a message rather than handing out zeros. After it there is: whoever passes the slides as arguments and writes an info() below the call still gets the last slide’s numbers. In the show-rule notation nothing comes after the deck anyway.deck-outline(): how the deck is cut#
info() says where you stand, not how the whole thing is divided. A navigation bar needs exactly that: which slides belong to which section. deck-outline() hands it over, one entry per section, in the order they come:
#context for a in deck-outline() [
- #a.number. #a.title -- slides #a.first to #a.last (#a.count)
]Each entry carries depth, number, title, target, first, last and count. target is the slide of the section itself – what you need to link to it, as contents() does. first, last and count are transitive: a depth-1 section counts the slides of its sub-sections too, so a bar does not show a zero for every top-level heading. A section with nothing under it has none for first and last, and 0 for count.
Only headings standing between slides count. A heading inside a slide, slide(none)[= Every map lies], is a slide title and opens no section; a deck written exclusively that way gets an empty list back. So put the = between the slides, not into them; examples/gliedern.typ shows how.
query, no second walk over the document, the same answer in both outputs.A foreign package looking for the structure through query(heading) finds nothing: the heading notation splits the body at its headings and copies depth and body out, dropping the element itself. That holds in both outputs. deck-outline() is the answer to it.
Two more traps wait one step further in, and both were found the hard way by a companion package. Every slide is set inside an html.frame, and in there a location collapses to page 1 at (0, 0) – so anything that groups by page sees one group for the whole deck, in the PDF as well. And target() reports "paged" inside that frame even while an HTML file is being written, so it cannot be used to tell the two apart either. info() and deck-outline() avoid both: they carry the structure themselves instead of reading it back out of the document. info().levels in particular answers “which section is this slide in” without a single query.
contents(): a linked agenda#
contents() turns the deck outline into links that work in both HTML and PDF. Put it on a regular slide, since a section slide has no body:
== Contents
#contents(layout: "1x2-fill")It lists the sections: the headings above slide-level between the slides, or the section calls where the slides are handed over as arguments. A deck without any – in heading notation at the default slide-level: 2, one without an = – gets an empty list, and no message says so.
layout: "1x1" is the default single-column list. layout: "1x2" creates balanced columns, while layout: "1x2-fill" fills the first column by available height before flowing into the second. For a long agenda, use the inclusive, one-based from: and to: range on multiple slides:
== Contents 1
#contents(layout: "1x2-fill", from: 1, to: 8)
== Contents 2
#contents(layout: "1x2-fill", from: 9, to: 16)A deck with more than one structure level indents the deeper ones. indent: takes a length of your own, or none to set every level flush:
== Contents
#contents(indent: none)highlight: true says where the talk stands: the running part and chapter keep the full ink, everything before and after steps back. That is the agenda between two parts, the one that shows the audience how far along they are.
== Where we are
#contents(highlight: true)number: replaces the complete number cell and receives one outline entry. title: replaces the complete linked title cell and receives the entry and its destination. Both renderers can therefore control their own typography. Every entry carries when, which is "past", "running" or "coming" – so a highlight of your own needs no arithmetic of its own:
== Contents
#contents(title: (entry, to) => link(to, text(
fill: if entry.when == "running" { blue } else { gray },
entry.title,
)))Use number: none to remove the number cell and let the title use the full item width. Section slide titles carry no number of their own. section-numbering: on presentation puts one in front – a numbering pattern such as "1.", or a function that receives the section number.
The link back, at the foot of a section slide
Every section slide carries a link back to the contents. section-back: on presentation says what it reads:
#show: presentation.with(section-back: [Back to the agenda])Four values. auto is the default and takes the word from the deck’s language. none leaves the link out on every section slide. Content or a string words it differently. And a function receives one dictionary and returns content – or none, and then that one slide goes without:
| Field | What it holds |
|---|---|
back.location | the location of the contents slide, ready for link() |
back.word | the default word in the deck’s language |
back.contents.number | the printed slide number of the contents |
back.section.number | the number of the section this link stands on |
back.section.title | its title, with the section-numbering prefix |
back.section.depth | its structure level |
back.section.parents | the titles above it, outermost first |
#show: presentation.with(
section-back: back => [#back.word (slide #back.contents.number)],
)The place stays with the theme, the body comes from the deck. Because the body sits inside the theme’s text, a text of your own within it wins – which is how the link gets another colour or size, on a ground where the accent reads too quietly:
#show: presentation.with(
theme: themes.editorial,
section-back: back => text(fill: white)[#back.word],
)Three things this will not do, and one you may do by accident. A link() of your own in the body beats the outer one – the link then leads there and no longer to the contents; that is the way to another destination, and the trap. info() inside the function names the slide before the section slide, the same way it does in a show rule; for the number of the contents take back.contents.number, which is there for exactly that. With no contents() in the deck nothing appears at all, and the function is not even called. And the parameter gives no other place: for that there is section-back: none and a section function of your own in the theme.
A show rule on ts-section-slide-back has the last word over the parameter.