typstage

0.2.0

Animated HTML presentations from a single Typst file, and a PDF handout from the same source. Typst typesets, the browser moves: magic-move morphing, step-by-step reveals, slide transitions, media and a speaker view in the same file.

API reference#

Generated from the comments in the source files: presentation and slides first, then the building blocks, then media and the bridge, and last the measurements and colours.

The presentation#

bundle#

bundle(
  body,
  html: "talk.html",
  slides: "slides.pdf",
  handout: none,
  per-sheet: 3,
  ..args,
)

All outputs in one run.

Since 0.15 Typst can write several files from one compilation. That fits this package, since everything sits in one source anyway: the talk, the slide deck and the handout differ only in target and in one setting. Instead of compiling three times, once:

typst compile --features bundle,html --format bundle talk.typ output
#bundle(
  theme: themes.lesson,
  title: [Completing the Square],
  handout: "handout.pdf",
)[
  = A section
  == A slide
  Text.
]

html, slides and handout are file names; none leaves out that output. per-sheet is the number of slides per handout page. Everything else goes to presentation unchanged.

Two things worth knowing. The bundle is explicitly experimental in Typst. And a file that uses bundle can only be compiled with --format
bundle
: typst compile talk.typ talk.pdf aborts with “constructing a document is only supported in the bundle target”. Anyone who wants both writes the body into a #let and calls presentation by hand.

What the package looks up stays in its own output, even though Typst runs introspection across the whole bundle. The counters start over for each output – slides, figures, equations, headings and a deck’s own counter –, and a link such as an entry of contents() leads into its own file. Measured on all seventeen example decks as bundles, with a page per slide and step by step, with and without the HTML: every page, every link and the HTML byte for byte as when that output is built alone. Two things Typst keeps across the whole bundle, out of the package’s reach. A deck’s own state has no value to start over from and carries on from one output into the next, so a running number belongs in a counter. And a label stands once in every output: @fig stops the bundle with “label occurs multiple times in the document” where the deck alone compiles, and an outline(target: figure) lists the figures of all outputs, six entries for two figures.

With pages: "step" there is one limit more. Where a reveal sits in a version of alternatives or a stage of build and another slide follows – #alternatives([A], [#anim[x]]) –, the bundle warns that it does not converge, and in the HTML the x never comes. Such a deck builds its HTML on its own, with html: none in the bundle.

NameVorgabe
body–
html"talk.​html"
slides"slides.​pdf"
handoutnone
per-sheet3
..args–

presentation#

presentation(
  ..slides,
  title: none,
  subtitle: [],
  author: [],
  date: none,
  assets: "inline",
  theme: themes.default,
  palette: (:),
  transition: "slide",
  speaker-view: (:),
  room: (:),
  morph: (:),
  transition-duration: 420,
  duration: 520,
  style: it => it,
  width: auto,
  height: auto,
  margin: auto,
  handout: false,
  overflow: "none",
  drift: "error",
  slide-level: 2,
  section-numbering: none,
  section-back: auto,
  pages: "slide",
)

Build the deck.

Two notations, the same output. Either the slides as arguments:

#presentation(title-slide(title: [Title]), section[Part], slide([First])[…])

… or as a show rule, and then the headings separate the slides:

#show: presentation.with(title: [Title], transition: "slide")
= A section
== A slide
Content …

Two targets, one source:

typst compile deck.typ deck.html --format html --features html
typst compile deck.typ deck.pdf

slide-level: is where the deck is cut. A heading above it becomes a section slide, a heading at it or below it becomes a slide. The default is 2, and that is the rule this package always had: = opens a section, == a slide. A heading written as a call counts by its level the same way: heading(level: 3)[…] as ===, heading[…] as =.

section-numbering: puts a prefix on section slide titles. It takes a numbering pattern such as "1.", a function that receives the section number, or none.

none is the default, and deliberately: a deck that says nothing about numbering keeps the titles it had. Switching it on by default would have renumbered every deck already written – measured on gliedern, “Where we are going” became “1. Where we are going” without anyone asking.

section-back: is the link back to the contents at the foot of a section slide. auto is the word in the deck’s language, none leaves it out everywhere, content or a string words it differently, and a function gets one dictionary – location of the contents slide, word, contents with its printed slide number, and section with number, title, depth and parents – and returns content, or none to drop the link on that one slide. The theme keeps the place and the link; the value is the body, so a text of your own inside wins over the accent.

#show: presentation.with(section-back: [Back to the agenda])
#show: presentation.with(title: [Analysis], slide-level: 3)
= Part I
== Sequences
=== What a sequence is
A map from the naturals.

= Part I and == Sequences each become a section slide, === becomes the slide. Both transition slides come for free: a section heading is the transition slide here, so there is nothing to switch on and no hook to write. slide-level: 1 makes every heading a slide and leaves the deck without any structure level.

A deeper level is drawn more quietly by all five bundled themes, smaller and with the titles it hangs under set above it. A theme of its own reads s.depth and s.parents off the section record and may ignore both, and then every level looks alike.

What the deck knows about its structure is in info(): section is unchanged and always means the level directly above the slide, levels has one entry per structure level, and outline is the whole thing.

theme: determines the whole look: colors, typeface, title bar, footer, progress, title and section slide. Bundled are themes.default (the default), themes.lesson, themes.night, themes.plain and themes.editorial; each of them can be varied with themes.night + (accent: blue). The style hook stays untouched by this and sits further inside: whatever is set there overrides the theme.

palette: changes the colors and leaves the design alone. It is a dictionary over the eight color entries and it overwrites partially, so palette: (accent: blue) moves the accent and nothing else. Five are bundled, palettes.light, palettes.mono, palettes.textbook, palettes.parchment and palettes.dark, and each of them composes with each theme:

#show: presentation.with(theme: themes.lesson, palette: palettes.dark)

Two colors of a theme are not palette entries: title-fill and rule-fill. All five bundled themes let them follow, either as a function of the palette or as none, which means the accent and follows with it. A theme of your own that names a fixed color there keeps it under every palette, which is deliberate.

Both changed type with this: reading themes.X.title-fill used to give a color and now gives a function, and rule-fill gives none where it gave the accent. Writing them, themes.X + (title-fill: red), is unchanged.

speaker-view says what the presenter view shows. Everything is on unless switched off, so a deck that says nothing gets the whole thing:

#show: presentation.with(speaker-view: (
  clock: false,                       // no class clock
  target: false,                      // no planned length
  pen: (colors: (red, green, blue)),  // the drawing bar's colours
))

A tile that is switched off takes its keys with it: with clock: false, t and ⇧t do nothing and no longer stand in the key bar. A view that advertises a key which does nothing is worse than one that is missing it. tools: false removes the drawing bar the same way. shortcuts: false starts with the keyboard bar hidden. h or its ? button toggles it during a talk; the choice is remembered for this session. Media controls show play/pause and a timeline. k toggles playback, j/l seek ten seconds; Shift+L switches the presenter’s light theme.

morph holds what magic move needs to know about this deck. It knows glyph-limit: up to how many glyphs match: "auto" pairs one by one before it moves the whole thing as one block, 120 by default. A pin travels above that limit as well.

#show: presentation.with(morph: (glyph-limit: 400))

room is the counterpart: what reaches the hall, as opposed to what only the speaker sees. It knows clock (how coarsely the class clock reads, and whether the digit keys start it), sounds (a key, a sound file), bell (the time the lesson begins, which video(ends-at: auto) counts towards) and pointer (the dot the speaker view guides across the slide):

#show: presentation.with(room: (
  clock: (step: 5),                            // the clock reads in fives
  pointer: (color: rgb("#00c853"), size: 4%),  // colour, share of the width
))

pointer: false takes the dot away again; embedded frames stay operable either way, they are the pointer mode’s other half. The dot defaults to the accent at 2.2% of the slide width, and size is a ratio between 0.8% and 6%. Its colour need not contrast with anything: a light ring and a dark one around the core carry that, whichever ground it lands on.

The PDF is a handout: one page per slide, every tracked element in its final state. What belongs only to the motion, the notes, the slide transitions, the bridge jobs, are state updates without output and fall away by themselves.

overflow is a checking pass over the deck, off by default. It measures every slide body against the room the theme gives it and names the ones that do not fit, with the earliest step on which the overrun can be on the screen. Title and section slides are not measured: the theme draws them with place and they have no body block. Nor is the PDF under pages: "step": 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. Nor is a handout that is given pages: "step", which is what bundle() does: it measured against the step pages beside it. The HTML still measures, and names the step too.

  • "none": nothing is measured. The default.
  • "error": the whole deck is built, and it then stops with every place at once rather than the first.
  • "record": it carries on and files a record per finding instead, for a tool to read. The deck has to be on "record" for this; on "error" the command below stops with the error too:
typst eval --target html --features html --in deck.typ \
  'query(<typstage-overflow>).map(e => e.value)'

The same setting can be raised from the command line, so a build script can measure a deck without editing it:

typst compile --features html --format html \
  --input typstage-overflow=error deck.typ deck.html

The input raises, it never lowers. Of the two the stricter one wins, "none" < "record" < "error", so a run cannot switch off a check the deck asked for.

It is not meant to stay on while writing. Measured over the six example decks: in HTML it costs noticeably more time, between 1.2 and 1.5 times depending on the deck and on how the process start is accounted for. On paper it costs a few milliseconds per deck, small but repeatable: there the check runs without the step arithmetic.

Why a deck of slides needs this more than a document does: a slide goes into an SVG frame of fixed size and is scaled in the browser, so what sticks out is cut away or drawn beside the slide. A page one leafs through shows an overrun; a talk one clicks through shows it at the projector.

drift is the second check, and unlike overflow it is on. Every scene measures its frames, and a scene whose frames come out different sizes is named: a CeTZ canvas is as large as what it holds, so a wider frame puts the drawing somewhere else inside its box and paging through it the whole picture travels while only one point should move.

  • "error", the default: the deck is built and then stops with every scene at once. scene(steady: false) says the frames of that one scene are meant to differ and takes it out of the check.
  • "record": it carries on and leaves a record per finding, for a tool to read, the same way overflow: "record" does:
typst eval --target html --features html --in deck.typ \
  'query(<typstage-drift>).map(e => e.value)'
  • "none": the frames are not measured at all.

On by default where overflow is not, and for two reasons. Only decks that use scene pay for it at all – measured on a scene of 28 CeTZ frames, 434 ms without and 536 ms with, so about 100 ms for that scene – where overflow measures every slide of every deck and costs 1.2 to 1.5 times the whole compilation. And what it finds is invisible while writing: every frame on its own looks right, and only paging through shows the drawing travelling. Only the browser branch measures. On paper a scene is one still image, and a still image does not travel.

NameVorgabe
..slides–
titlenone
subtitle[]
author[]
datenone
assets"inline"
themethemes.​default
palette(​:​)
transition"slide"
speaker-view(​:​)
room(​:​)
morph(​:​)
transition-duration420
duration520
styleit => it
widthauto
heightauto
marginauto
handoutfalse
overflow"none"
drift"error"
slide-level2
section-numberingnone
section-backauto
pages"slide"

zaehler-klammer#

zaehler-klammer(nr, j)

Was vor und nach dem Rumpf einer Schrittseite steht, damit alle Seiten einer Folie dieselben Nummern tragen wie ihre erste.

Die erste Seite (j == 0) bekommt zwei Marken mit der Foliennummer. Jede weitere Seite stellt davor jeden Zähler, der zwischen den Marken bewegt wird, um den Abstand zwischen ihnen zurück, und holt danach den Schritt des Lesezeichens nach, das nur die erste Seite trägt.

Slides#

bleed#

bleed(body)

Content laid over the whole canvas, edge to edge.

==
#bleed[
  #image("harbour.jpg", width: 100%, height: 100%, fit: "cover")
  #place(dx: 480pt, dy: 300pt, morph("k", card[Next stop]))
]

The body gets the canvas: its origin is the corner the text begins at, its room the slide’s full width and height, whatever the margins, the title and the running header take. A place inside it counts from that corner, with or without an anchor and behind a picture of full height too. It lies right above the slide’s ground and below everything else: the title and the rest of the body are drawn on top.

In a deck that reads from the right that corner is the top right one, and a positive dx leads off the slide – the way Typst’s own place counts, here and in the slide body. Spell the anchor out to count from the left: #place(top + left, dx: 40pt, dy: 300pt, ..) lands at (40, 300) in a Persian deck too, and an align(start) around the content keeps the paragraphs inside it on their own side.

The style hook of the deck wraps the bleed as it wraps the slide body: a hook that indents the body with pad indents the picture as well, so it no longer reaches the edges. And it runs twice on a slide with bleed, once for the canvas and once for the body, so a hook that counts something on the side counts it twice.

A slide with bleed draws no chrome: no running header, no slide number, no footer line, no progress bar. It still counts. That holds for the handout too, where the bleeding slide is then the one without a number; put the number into the bleed yourself where it is wanted on paper.

#bleed(none) is the empty canvas, like #bleed[]: a slide without chrome and without a picture. A deck that sets its picture conditionally – #bleed(if cover != none { image(cover, ..) }) – needs that.

It stands at the top level of a regular slide’s body, before any other content – #set and #show rules, #invert, #transition, #speaker-note and #class-clock may come first – and before the first #pause. One per slide.

class-clock#

class-clock(minutes)

Plans a class clock for this slide: how many minutes the work on it is meant to take.

It starts nothing. Shift+T in the presenter view offers the number, the speaker confirms or changes it, and only then does the clock run – the deck knows how long the task was meant to take, the room decides how long it actually gets. A slide carries at most one; a second call replaces the first, like a second speaker-note.

contents#

contents(
  layout: "1x1",
  columns: (auto, 1fr),
  row-gutter: 20pt,
  column-gutter: 16pt,
  from: 1,
  to: auto,
  number: auto,
  title: auto,
  indent: auto,
  highlight: false,
)

A linked contents list for the deck.

The target is a location rather than a page dictionary, so PDF readers can change pages without changing the reader’s zoom. In HTML the runtime maps the generated internal target to the containing slide. 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 location. Both default to the built-in rendering. Set number: none to hide the number cell completely. Text styling is controlled by these two renderers as a whole. layout: "1x1" keeps one directory item per row; layout: "1x2" places directory items in two balanced columns; layout: "1x2-fill" fills the first column by available space before flowing into the second. from and to select an inclusive, one-based range of directory entries; to: auto selects through the final entry. highlight: true marks the section the talk is in and dims the rest. Where no listed entry is running – an opening agenda before the first section, or a from/to range the running section falls outside of – there is nothing to mark, and the list is then set the way it is without highlight rather than dimmed throughout. Every entry still carries when ("past", "running" or "coming") into number and title, so a renderer of your own can answer that case differently.

#contents(
  number: entry => [#strong[#entry.number.]],
  title: (entry, destination) => link(destination)[#entry.title],
)
NameVorgabe
layout"1x1"
columns(​auto,​ 1fr)
row-gutter20pt
column-gutter16pt
from1
toauto
numberauto
titleauto
indentauto
highlightfalse

deck-outline#

deck-outline()

How the deck is cut, section by section.

info() says where you are; this says how the whole thing is divided. One entry per section, in the order they come, each with the slides beneath it:

#context for a in deck-outline() {
  [#a.number. #a.title -- slides #a.first to #a.last (#a.count)]
}

first, last and count are transitive: a depth-1 section counts the slides of its sub-sections too. A section with no slides under it has none for first and last, and 0 for count.

Read straight off the state every slide already carries. No query, no second walk over the document, and the same answer in both outputs.

info#

info()

What the deck knows about itself, read from inside a slide.

#context {
  let deck = info()
  [#deck.section.title #h(1fr) #deck.slide.number/#deck.slide.total]
}

It is the same reading the built-in chrome does. Every number the package prints on a slide, the footer, the fraction, the length of the progress bar and the running header, comes out of this function and out of no second count, so a hand-built footer and the built-in one cannot disagree.

What comes back, as a dictionary:

  • title, subtitle, author, date: the deck’s own particulars, as presentation or a title-slide received them.
  • slide.number, slide.total: this slide and how many there are. Counted the way the footer counts, so title and section slides are not in it.
  • slide.numbered: whether this slide is one of the counted ones. It is false on a title slide and on a section slide, and number then holds the last slide counted before it, 0 on a cover that opens the deck. A footer can therefore leave its counter slot clear instead of printing a zero into it.
  • step.number, step.total: this deck counts in steps as well as in slides, which no footer can guess at. 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, its first one where it covers several. On paper a slide is one page in its final state, so number is total there.
  • section.number, section.total, section.title: which section the slide belongs to, how many the deck has, and its title. The section is always the level directly above the slide, so at the default slide-level: 2 this is the = heading and nothing about it has changed. Before the first such heading, number is 0 and title is none. A deck at slide-level: 1 has no structure level at all, and then all three read 0, 0 and none.
  • levels: one entry per structure level, from the outermost inwards, so levels.last() is the same section as above and levels.first() the outermost part. Empty at slide-level: 1. Each entry carries depth (1 for =, 2 for ==), title (none while no section of that level is running), number and total (counted across the whole deck, the way section counts), and index and count (counted among the siblings under the same parent, which is what Beamer prints as 1.2). number never goes back, so it also reads as progress; title, index and count clear when the level above them moves on.
  • outline: the whole structure of the deck, one entry per section slide in the order they come, each with depth, title, number (the same count as in levels) and here, which is true only on that section slide itself. Comparing an entry’s number with levels.at(entry.depth - 1).number says whether it is past, running or still to come, and that is how a progressive agenda is built. 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 there and is past.

Only in a context. Before any presentation has run there is nothing to read and this stops with a message rather than handing out zeros. After one it does not: whoever passes the slides as arguments and writes an info() below the call still gets the last slide’s numbers. Clearing the deck’s own record at the end would close that, and it was measured: a slide carrying one reveal beside a tiles went from no layout warning to three “did not converge” ones. A corner nobody stands in is not worth that, and in the show-rule notation nothing comes after the deck anyway.

section#

section(title, depth: 1, transition: none)

A section slide.

depth is the level in the heading hierarchy the section stands on: 1 for =, 2 for == and so on, up to one below the deck’s slide-level. In the heading notation it comes from the heading; here it is written out. The five bundled themes draw a deeper section more quietly, and a theme of your own reads it from s.depth.

NameVorgabe
title–
depth1
transitionnone

slide#

slide(
  title: none,
  note: none,
  transition: none,
  invert: false,
  ..rest,
)

A regular slide.

title: none, or a bare == in heading form, leaves out the title bar; the body then gets the whole area.

invert: true sets this one slide in the palette turned around, for the slide that carries a single number. The ground becomes the palette’s text color and the text becomes its ground; muted, border and surface are mixed from those two; strong and accent carry over unchanged. The chrome follows, so the running header, the footer and the progress bar are set in the same colors as the slide under them.

Only a regular slide inverts. A title slide and a section slide are whole pictures the theme draws itself, and three of the five bundled themes build them from colors an inversion would not reach; neither takes the argument.

NameVorgabe
titlenone
notenone
transitionnone
invertfalse
..rest–

speaker-note#

speaker-note(body)

A note for the presenter view, which n opens in a second window.

The note has to carry text: the presenter view transports it as a string, so a note made purely of layout would arrive nowhere. That is refused with a message rather than silently dropped.

title-slide#

title-slide(title: [], subtitle: [], author: [], date: none)
The title slide.
NameVorgabe
title[]
subtitle[]
author[]
datenone

transition#

transition(kind, ..spec)

How this slide comes in, otherwise the presentation’s setting applies.

  • "none": hard cut.
  • "fade": cross-fade, nothing moves.
  • "slide", "push", "cover", "uncover": sliding; from says where the new slide comes from: "right" (default), "left", "top", "bottom". push shoves the old one out, cover lies down on top, uncover pulls the old one away.
  • "zoom": direction: "in" (default) grows the new one towards you, "out" lets it step back from the front.
  • "blur": blurred across.
  • "iris", "wipe": an aperture. direction: "open" (default) opens the new slide out, "close" shuts the old one over it. For the wipe, from additionally says which edge it starts at.
  • "flip", "cube": rotation in space; axis: "y" (default) turns about the vertical, "x" about the horizontal.

Backwards each one runs as a true reversal. If a morph meets the slide it cross-fades regardless: the movement is then carried by the morph.

Under prefers-reduced-motion: reduce every kind but "none" becomes the cross-fade, over the same duration. See the manual.

invert#

The same thing in the heading notation, written into the slide body.

== Reached in 2026
#invert
#statement[74 %]

A marker, like #pause, because a heading carries no arguments. Unlike #pause it is only looked for, never split on, so the walk goes all the way down: it is found in a block, an align, a table cell, a grid, however deeply nested, in the heading itself, and behind #set and #show. It is not found where the content is handed to a closure – in context, fit, anim, card or alternatives – and there nothing happens and nothing is said. Measured, those five are the whole of it; slide(invert: true) is the form that never depends on the walk.

It prints nothing, so it may stand anywhere in the body; it inverts the whole slide either way, not the part after it.

Revealing, moving, staggering#

alternatives#

alternatives(
  ..variants,
  start: auto,
  align: top + std.start,
  enter: "fade",
  duration: auto,
  easing: auto,
  inline: false,
  morph: false,
)

Several versions of the same thing, each replacing the one before.

#alternatives(
  $ (a + b)^2 $,
  $ (a + b)(a + b) $,
  $ a^2 + 2 a b + b^2 $,
)

inline: true keeps the whole thing in the running line, for versions of a single word or formula.

They all stand in the same place, in a box as large as the largest of them, so nothing around them jumps as they change. Each takes one step; the last one stays for the rest of the slide. A version that reveals something of its own stays until that is done, and the next one comes after it.

That waiting needs start: auto. A start written out puts every version on exactly its own step, and a chain inside a version other than the last comes only after that version has gone, so it is never seen.

morph: true lets the versions fly into one another instead of replacing one another. They all stand in the same place, so the flight is no distance at all and what you see is the glyphs rearranging themselves where they stand – which is what a rewritten formula does:

#alternatives(morph: true,
  $ (a + b)^2 $,
  $ (a + b)(a + b) $,
  $ a^2 + 2 a b + b^2 $,
)

It works because the versions carry one name and step ranges that do not overlap, and a morph flies from step to step as readily as from slide to slide. A name of your own instead of true is allowed and is only needed where the flight has to continue onto the next slide.

A morph has no entrance and no easing curve, so enter: and easing: are refused rather than quietly dropped. duration: is read and is the time of the flight.

With morph a version other than the last does not wait in the browser for what it reveals itself: the next version comes on the step after it, and the reveal is never seen there, while the paper shows it.

On paper only the last one is set, in the same box, so the page keeps the spacing of the slide. Printing all of them would pile them on top of one another.

NameVorgabe
..variants–
startauto
aligntop + std.​start
enter"fade"
durationauto
easingauto
inlinefalse
morphfalse

anim#

anim(
  body,
  at: auto,
  enter: "fade-up",
  exit: "fade",
  after: "hidden",
  duration: auto,
  delay: 0,
  easing: auto,
)

Reveal content on particular steps.

at is a step selector. auto, the default, takes the next free step, so consecutive anims reveal one after another without any numbering. Otherwise: 2 (from step two on), "1-2", (2, 4), "3". An explicit number also moves the cursor along, so a following auto carries on after it instead of starting over.

enter applies in both directions: paging back plays the same effect in reverse, taking the entrance back. exit only concerns a real departure, when an element falls out of its range while moving forward.

after says what the element does once its range is behind it, and it has two values.

  • "hidden", the default and what an anim has always done: it goes, playing exit, and keeps the room it had.
  • "dimmed": it stays and is drawn muted, so a point remains legible after the talk has moved on. Nothing moves and nothing is recoloured; the element settles to 65 percent opacity, and paging back brings it up again. That number is measured, and the manual says against what.

On paper after does nothing at all. A page shows every step at once, and a point that is only quiet because the talk has moved past it has no “past” on a handout. This is the same rule that already holds for "hidden": what leaves its range in the browser is still printed.

after needs a range that ends. at: auto and at: 3 run to the end of the slide, and an element that never leaves has no after; the package says so instead of doing nothing. at: "3" is that one step, at: "2-3" a range.

Under prefers-reduced-motion: reduce every effect keeps its opacity and loses its travel, so enter and exit become a plain cross-fade of the same length. after: "dimmed" is unaffected: it changes opacity and nothing else. See the manual.

easing is the curve the element moves on – for the entrance, the departure and the dimming alike. auto is the package’s own curve; "out-back" overshoots and swings back, "linear" arrives at an even pace. A name that does not exist is an error at compile time and not a silent default.

NameVorgabe
body–
atauto
enter"fade-up"
exit"fade"
after"hidden"
durationauto
delay0
easingauto

build#

build(
  draw,
  steps: auto,
  start: auto,
  at: auto,
  enter: "fade",
  duration: auto,
  easing: auto,
)

A drawing or a diagram that comes into being step by step.

A CeTZ canvas and a lilaq diagram are one piece, not many: Typst hands out the finished setting, and what was a line and what was a data series in it cannot be reached from outside any more. So there is no anim around a part of a drawing. What there is, is the drawing itself, as often as one wants it.

draw is called once per step and is handed a question. The examples call it from, because it says exactly what at: says elsewhere; the name is the deck’s own, since it is the lambda’s parameter.

  • from(k, value) gives value back once the k-th piece is due, and otherwise the same thing made of air: a colour with alpha 0, a stroke with a transparent brush, a text in hide. The piece is therefore never really missing, and every stage measures the same to the point.
  • from(k) says the same as a boolean, for everything that cannot be recoloured. In CeTZ that is where hide(…, bounds: true) belongs.

Whatever carries no number stands there from the start.

#build(from => cetz.canvas({
  import cetz.draw: *
  line((0,0), (4,0))                        // there from the start
  line((4,0), (4,3), stroke: from(2, black))  // from step 2
  content((2,3.4), from(3, [hypotenuse]))     // from step 3
}), steps: 3)

steps is the number of stages and hence the number of steps the drawing takes on the slide. It is said and not guessed: what draw does with its question is nobody’s business from outside.

start is auto: the drawing begins on the next free step and pushes the cursor along by steps, the way stagger and alternatives do. A number sets the first step itself.

at is for a drawing whose stages do not come one click after another. It names, per stage, the step it first stands on, and each stage then holds until the next one is due:

#build(from => diagram(from), at: (1, 9))

Two stages, the second from step 9 on. Whatever happens on the slide in between – a camera move, a verdict, a second diagram – costs nothing here. Without at the same picture needs steps: 9, and stages 1 to 8 are pixel for pixel the same drawing and are all typeset regardless. Measured on a slide carrying three diagrams that are discussed one after another: 22 sprites against 10, and the file 3.45 MB against 2.98 MB.

The list’s length is the number of stages, so steps and start have nothing left to say and are refused rather than quietly ignored.

from keeps counting stages and not steps: from(2, …) is “from the second picture on”, and where that picture stands is said by at alone. It could not be otherwise: under start: auto nobody knows while writing which step the drawing will land on.

Exactly one stage is drawn at a time, and that is not a saving but the only arrangement that yields the picture that would stand there if the drawing were set once. Ink adds up: three layers of the same lilaq diagram against one, and 3.7 percent of the pixels differ by more than 8 of 255, the largest deviation 99 – axes, labels and the half-transparent box of the legend get painted three times and grow fatter by it.

On paper only the last stage is set, in a block of the same size: a page shows every step at once, and stacked stages would be overprint. The cursor still runs there, so that info().step.total names the same number in both outputs.

Under prefers-reduced-motion: reduce nothing changes: the stages fade, they do not travel, and what would fall away is a motion that is not there.

NameVorgabe
draw–
stepsauto
startauto
atauto
enter"fade"
durationauto
easingauto

camera#

camera(
  target,
  at: auto,
  margin: 16pt,
  duration: 700,
  easing: auto,
)

Move in on one detail of the slide, and back out again.

#pin(<detail>, card[The measuring head])
…
#camera(<detail>)

The camera aims at a pin, and at nothing else. That is the package’s word for a named piece of a slide, its marker is exactly the rectangle the runtime already measures, and it sits wherever content sits: around a card, around a cell of a table, around one subterm of a formula. Nothing has to be given in coordinates, and nothing has to be counted.

at is a step selector, as everywhere else, and the slide is seen through the camera for exactly as long as it is active. That answers the way back out with the notation that is already there: at: "3" moves in on step three and back out on step four, at: "3-5" holds the crop across three steps, at: 3 keeps it to the end of the slide.

auto, the default, is the next free step closed: in on it, out on the one after – and never step one, because step one is the slide as it is entered, and a camera there would mean nobody ever saw the slide whole. That differs from anim, where auto runs to the end of the slide, and it differs on purpose. An entrance has no natural end – what has appeared stays. A camera move has one: one always comes back out, and coming back out is a keypress like any other, so it is counted like one and shows up in info().step.total.

margin is how much of the slide stays around the detail, measured in the unzoomed slide. The camera fits the detail plus that margin into the frame; the smaller of the two directions decides, so the whole of it is seen.

A detail that is already as large as the slide gives nothing to travel to, and then the slide stays whole.

Two pins of the same name on one slide are framed together, and the camera shows the box around both.

If two camera moves are active on the same step, the later one in the source wins.

On paper there is no camera. The slide is set whole, exactly as it would be without one – but the steps are counted there too, so the handout’s footer names the same number as the talk.

Under prefers-reduced-motion: reduce the camera jumps to the crop instead of travelling to it. The package’s rule everywhere else too: what stays is the destination, what goes is the travel.

NameVorgabe
target–
atauto
margin16pt
duration700
easingauto

cue#

cue(name, ..items, start: auto, spacing: 0.65em, nr: auto)

Reveal one after another: a list or several blocks.

Two notations, the same function:

#stagger[
  - this first
  - then this
]
#stagger(card[left], card[right])

For a list, the bullet marks are set here rather than left to list: only this way does the mark belong to the tracked element. If it stayed with the list, it would sit in the background and be there before its point appears.

start is auto: the sequence continues where the slide left off. stride: 0 makes everything appear on the same step and staggers only through stagger, in milliseconds.

dim: true turns the sequence into a walk: the point being discussed stands there, the ones before it stay legible but muted. Every point then holds exactly its own step instead of the rest of the slide, and rests at anim’s after: "dimmed" from the next step on. Paging back brings each one up again.

Two things follow from that and are worth knowing before reaching for it. The last point dims too as soon as the slide has a further step after it, because then the walk has moved on from it as well. And stride: 0, which puts every point on one step, makes them all dim together on the next. A group that is revealed in whatever order it is called out.

For points that have no order of their own: what the class names gets shown, in the order it comes rather than the order it stands in. The digits 1 to 9 choose; the speaker view shows which digit belongs to which point.

#cue("ablesen", start: 2)[
  - positive und negative Werte
  - tiefster und höchster Wert
  - Abnahme und Zunahme
]

The group owns as many steps as it has points, and the order changes nothing about that. Everything that hangs on the step count – the progress bar, info().step.total, the overflow check, the handout – is therefore untouched.

Set, the list keeps its reading order: a point not yet named holds its place, so nothing jumps when it arrives later.

NameVorgabe
name–
..items–
startauto
spacing0.​65em
nrauto

cue-layer#

cue-layer(name, number, body, enter: "fade")

Something that appears together with one point of an adaptive group.

A drawing layer, a picture, a sentence beside it: it shares the step with its point and therefore travels with it, without having to be linked.

#cue-layer("ablesen", 1, schicht-vorzeichen)

The group has to stand before its layers in the source, because a layer looks up which step its point was given. Standing after them, the package says so rather than quietly doing nothing.

NameVorgabe
name–
number–
body–
enter"fade"

morph#

morph(
  name,
  body,
  at: "1-",
  duration: 900,
  match: "auto",
  inline: true,
)

Magic move: the same name twice, and the thing flies across.

Twice on two adjacent slides, or twice on one slide with at: selectors that do not overlap. The runtime pairs the flights step by step as well as slide by slide.

The name is a string or a label: morph(<pythagoras>, …).

duration is 900 ms, not the duration of the presentation: a flight across the slide takes more time than a simple fade-in, and in real decks the default was overridden in 161 of 165 cases. auto falls back to the presentation’s duration.

match is "auto", "glyph" (always per glyph) or "block" (always as one rectangle).

Two names may be equal on the target slide. The runtime looks the source up by name but iterates over the targets, so two targets sharing a name both start from the same place and the glyph visibly splits in two. at is almost always right as it is. A morph is present from the first step on: a flight target must already be there when the slide is entered. Because paging back swaps the roles, that holds for both ends.

Delaying is only worthwhile for the first link in a chain, for example when the formula should appear together with its tile. It is allowed exactly when the preceding slide carries no morph of the same name; the package checks this at compile time and speaks up when it does not hold.

Under prefers-reduced-motion: reduce nothing flies. The slide changes the way it would change without a morph. See the manual.

NameVorgabe
name–
body–
at"1-"
duration900
match"auto"
inlinetrue

pin#

pin(name, body)

A named piece inside a morph.

Shape matching pairs glyphs by their outline and, where that is not enough, by proximity. Most of the time that is right. Where it is not, because the 3 in 3x^4 is meant to become the 3 in 4 dot 3x^3 and another 3 sits in between, the piece gets a name, and the pairing follows that instead.

#morph(<term>)[$#pin(<faktor>)[3] x^#pin(<hoch>)[4]$]
… and on the next slide …
#morph(<term>)[$#pin(<hoch>)[4] dot #pin(<faktor>)[3] x^3$]

Matching names find each other before the shape is consulted; everything else works as before. A pin with no counterpart on the other slide falls back to shape matching without complaint.

A pin may hold several glyphs: #pin(<s>, $sum_(i=1)^n$) takes the sigma and its limits along together, each finding its counterpart inside the group on the other side.

And a name always travels. Above the deck’s glyph limit – 120 by default, presentation(morph: (glyph-limit: …)) sets it – a morph no longer pairs everything one by one, but the named pieces still fly while the rest changes in place.

The name is a string or a label.

scene#

scene(
  ..parts,
  stops: (),
  tween: 8,
  start: auto,
  width: 100%,
  height: 190pt,
  duration: auto,
  enter: "fade",
  still: auto,
  steady: auto,
)

A drawing as a function of a value, with stops for the talk.

#scene(
  x => tangent-at(f, x),
  stops: (-3, 0, 1.5, 3),   // four stops, three steps
  tween: 8,                 // frames between two stops
)

This is manim’s ValueTracker turned around. There a number changes while the film runs and the picture follows it; here Typst draws at compile time and a number can only change at a step. So the deck writes a function from a value to a picture and says at which values the talk stops. Typst renders every stop and the frames in between, and a keypress pulls the picture from one stop to the next.

stops are the values themselves, not 0.0 to 1.0 – that is the whole difference to flipbook. The scene takes stops.len() - 1 steps: the first stop is there as soon as the scene appears, every further one costs a keypress.

A stop may be a tuple, and then the drawing function takes that many arguments: (a, b) => … with stops: ((1, 1), (1, 3), (2, 3)). What is lost against manim is that there several trackers may move independently; here everything travels from stop to stop together.

tween is the number of frames between two stops. With tween: 0 the scene jumps from stop to stop and shows nothing in between.

duration is the time one pull from stop to stop takes, not the time of the entrance – the same separation morph draws, and for the same reason: one is a journey, the other a fade.

The scene stands in a box of a fixed size and every frame is clipped to it, on paper as in the browser. Unlike build the frames are not laid out on top of one another: they are drawings of different values and may legitimately come out different sizes, so one shared frame is the only arrangement in which the box itself does not jump.

The frames are measured all the same, and steady says what that measurement is for. A CeTZ canvas is as large as what it holds, so a frame wider than its neighbour puts the drawing somewhere else inside the box, and paging through it the whole picture travels while only one point should move. The package can see that and cannot correct it: measure answers with a size, never with where the ink lies inside it.

  • auto, the default: the frames are measured and a finding is filed as a record. presentation(drift: …) decides what happens with the records – "error", the default, stops at the end of the deck with all of them at once.
  • false: the frames are meant to differ – a rectangle that grows, a number that counts up – and this scene is taken out of the check. It is not measured at all.
  • true: this scene has to stand still, and it stops where it stands if it does not, whatever the deck says.

Measuring costs one more layout per frame, and a frame is a whole layout. Measured on a scene of 28 frames – four stops, eight frames between each pair, a CeTZ drawing of axes with ticks, a parabola of 61 points, a tangent, a dashed slope triangle and two labels: 434 ms without the measuring and 536 ms with it, so about 100 ms for the scene and 3.6 ms per frame. Only the browser branch pays it. On paper a scene is one still image, and a still image does not travel.

On paper the last stop is set, as with alternatives; still overrides that. The step cursor still runs there, so info().step.total names the same number in both outputs.

Under prefers-reduced-motion: reduce the frames in between fall away and the scene jumps from stop to stop. That is the package’s rule everywhere else too: what stays is the destination, what goes is the travel.

NameVorgabe
..parts–
stops(​)
tween8
startauto
width100%
height190pt
durationauto
enter"fade"
stillauto
steadyauto

scene-layer#

scene-layer(name, nr, body, enter: "fade")

Something that belongs to one particular stop of a scene.

A sentence beside it, a formula, a second drawing: it shares the step with its stop and therefore travels with it, without having to be linked.

#scene("derivative", x => tangent-at(f, x), stops: (-3, 0, 3))

#scene-layer("derivative", 2)[At the vertex the slope is zero.]

The scene has to stand before its layers in the source, because a layer looks up which step its stop was given. Standing after them, the package says so rather than quietly doing nothing.

A layer stays from its stop to the end of the slide, as cue-layer does: what was said at a stop goes on holding afterwards.

NameVorgabe
name–
nr–
body–
enter"fade"

stagger#

stagger(
  ..items,
  start: auto,
  stride: 1,
  enter: "fade-up",
  duration: auto,
  easing: auto,
  stagger: 60,
  spacing: 0.65em,
  dim: false,
  morph: false,
  name: none,
)

Several things, one after another, one step apart.

#stagger[
  - The first point
  - The second
  - And the third
]

A bullet list is taken apart at its items; anything else is taken as it comes, one piece per argument. Where a list would be wrong – three cards side by side, say – hand the pieces over instead:

#stagger(card[One], card[Two], card[Three])

start is the step the first piece stands on, auto the next free one. stride is how many steps lie between two pieces: 2 leaves one out, and 0 puts them all on the same step, staggered only by stagger, which is the delay in milliseconds between one piece and the next. The two belong together – with stride: 0 and stagger: 60 a list arrives as a wave rather than as a sequence of keypresses.

dim leaves the pieces already shown standing, dimmed, instead of at full strength. spacing is the gap between them, enter, duration and easing are handed on to every piece unchanged.

morph: true lets each piece fly out of the one before it. Every piece stays where it is once it has arrived, so at a step change the piece set last is the source and the new one the target: the new line grows out of the line above while the line above stays put. That is a chain of transformations, line by line, on a single slide:

#stagger(morph: true, spacing: 14pt,
  $ x^2 + 6 x + 2 = 0 $,
  $ (x + 3)^2 - 7 = 0 $,
  $ x = -3 plus.minus sqrt(7) $,
)

A morph has no entrance, no easing curve and no dimmed rest, so enter:, easing: and dim: are refused rather than quietly dropped. duration: is read and is the time of the flight.

NameVorgabe
..items–
startauto
stride1
enter"fade-up"
durationauto
easingauto
stagger60
spacing0.​65em
dimfalse
morphfalse
namenone

stagger-layer#

stagger-layer(name, number, body, enter: "fade")

Something that belongs to one particular piece of a stagger.

A note in the margin, an annotation on a line of a calculation, a second drawing: it shares the step with its piece and therefore travels with it, without having to be counted.

#stagger(morph: "rewrite", ..lines)

#stagger-layer("rewrite", 2)[$| -2$]

The group needs a name for that – name: says it, and a morph: written as a name says it too, because then it is already there.

The stagger has to stand before its layers in the source, because a layer looks up which step its piece was given. Standing after them, the package says so rather than quietly doing nothing. And it has to stand on the same slide: a group belongs to one slide, as a cue group does.

A layer stays from its piece to the end of the slide, as cue-layer and scene-layer do. And it stays out of a morph: true flight: the layer is its own element and carries no morph name, so what flies is the piece and the annotation merely appears beside it – which is what an annotation should do.

NameVorgabe
name–
number–
body–
enter"fade"

pause#

Everything after this appears a step later.

The short form for slides that simply unfold: no anim around anything, no step numbers.

== A slide
First this.
#pause
Then that.

It is read at the top level of the slide body, #set and #show rules included. Inside a grid cell, a table or a figure it is not seen: there the content is a field of an element, not part of the body. Reach for anim in those places instead.

Layouts#

callout#

callout(
  body,
  title: auto,
  color: auto,
  radius: 7pt,
  inset: (x: 14pt, y: 11pt),
  width: 100%,
)

A highlighted key sentence: Beamer’s alertblock.

The bar on the edge the writing starts at marks it on every slide at a glance as “the thing to remember”, without it looking like a second box.

Labelled <ts-callout>, <ts-callout-title> and <ts-callout-body>. As in card, the surface goes through a set rule, so a label rule reaches fill, stroke and radius.

NameVorgabe
body–
titleauto
colorauto
radius7pt
inset(​x:​ 14pt,​ y:​ 11pt)
width100%

card#

card(
  body,
  title: none,
  number: none,
  color: auto,
  fill: auto,
  stroke: auto,
  radius: auto,
  inset: (x: 12pt, y: 10pt),
  width: 100%,
)

A named box: Beamer’s block.

title sits in a colored bar above it, number additionally places a numbered disc in front. Without either, a plain box remains.

color, fill and stroke take the theme’s values on auto: its primary color, its card background and its border.

No clip: true: Typst derives a clip path’s identifier from the content, and the same box twice on a slide would produce the same identifier. The corners are therefore rounded by the bar itself.

Six labels for the six parts, so a deck can restyle them without touching the theme. <ts-card> is the box itself, and because its surface travels through a set rule rather than an argument, set block(fill: ..) on that label reaches it. <ts-card-bar> is the coloured tab, <ts-card-title> the caption, <ts-card-disc> the numbered disc, <ts-card-number> the numeral in it, <ts-card-body> the body.

NameVorgabe
body–
titlenone
numbernone
colorauto
fillauto
strokeauto
radiusauto
inset(​x:​ 12pt,​ y:​ 10pt)
width100%

fit#

fit(
  body,
  width: auto,
  height: auto,
  wrap: true,
  grow: false,
  shrink: true,
)

Scale one block down to the room it has.

For the thing whose size the deck does not control: a wide table, a generated diagram, a list that came out of a data file. Without it such a block runs over the edge of the slide. In the PDF it is still to be seen standing there; in the browser the slide sits in a frame of fixed size and whatever reaches past it is cut away.

#slide[
  == Regression results
  #fit(wrap: false, my-table)
]

wrap: false because the block is a table. Everything that lays itself out in columns has to be measured as it stands; see below.

The block is measured against the place it stands in and scaled geometrically, so it keeps its proportions and what stands around it counts with its new size. No factor is given by hand.

Width first, then smaller. The block is offered the full width before it is measured, so a paragraph or a list breaks into the space instead of shrinking, and only what is still too tall afterwards is scaled. A table, a chart or a drawing would rearrange its own columns instead, which changes the picture rather than its size; wrap: false measures such a block exactly as it stands.

It only shrinks. grow: true also blows a block up that is smaller than its place, for the one large number that is meant to fill the slide. shrink: false takes the shrinking away and leaves only the growing.

width and height take auto, a length or a ratio. auto is the whole place. On height: auto the block takes what is left over below whatever stands above it on the slide, so a fit under two bullet points reckons with the bullet points. That has a flip side wherever something encloses the fit: inside a card the box becomes slide-tall, is cut off at the bottom, and whatever follows the card falls off the slide – measured in both outputs. It is the 1fr doing that, not the scaling: a card around a bare block(height: 1fr) behaves the same. Give height: explicitly inside a card, and the fit reckons with that instead.

No reveal inside. 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: measured on a slide carrying two pauses, the step count fell from three to one and nothing said so. And a measured block has no height to reckon against, which is the axis on which a tracked element resolves its size and reserves the room for its marker: measured, an anim inside a fit was not scaled at all and ran off the bottom of the slide. fit stops with a message instead, for pause, anim, stagger, alternatives, morph, tiles, video, embed, flipbook, build, scene, camera and cue, in both outputs. Fit what stands inside the reveal rather than fitting around it: anim(fit(my-table)) works, fit(anim(my-table)) is the error.

speaker-note and bridge-job are allowed inside a fit. They settle no geometry, and a measure commits no state, so both were measured to arrive exactly once. The other direction is the one that does not work: a note made only of a fit carries no text, and speaker-note refuses it.

The geometry is adapted from mosaic, which adapted Touying 0.7.4, which credits it to Andreas Kröpelin (Polylux PR 91) and to ntjess.

NameVorgabe
body–
widthauto
heightauto
wraptrue
growfalse
shrinktrue

side-by-side#

side-by-side(
  ..parts,
  split: (1.25fr, 1fr),
  gutter: 18pt,
  align: horizon,
  equal: false,
)

Two or more columns side by side.

The name comes from Touying and Polylux, which chose it independently of each other.

split takes the column widths; the default gives the first column a bit more, because that is usually where the illustration sits and the text on the right.

NameVorgabe
..parts–
split(​1.​25fr,​ 1fr)
gutter18pt
alignhorizon
equalfalse

statement#

statement(
  body,
  size: 1.6em,
  color: none,
  above: 0.6em,
  below: 0.6em,
)

A large statement in the middle: the formula that matters.

Explicitly demands the full width: a tracked element becomes as wide as its content, and a bare align(center, …) inside it would have no room to center in and would sit unchanged on the left.

Labelled <ts-statement>. size is measured in em, so a set text(size: …) from a label rule multiplies rather than replaces it.

NameVorgabe
body–
size1.​6em
colornone
above0.​6em
below0.​6em

tiles#

tiles(
  ..items,
  columns: auto,
  gutter: 14pt,
  row-gutter: auto,
  at: auto,
  stride: 1,
  stagger: 0,
  enter: "fade-up",
  duration: auto,
  easing: auto,
  ,
  align: top + std.start,
)

A tile grid that staggers itself.

Each tile appears one step after the previous one: that is the reason this function exists. By hand that means an anim per tile with an incremented number or delay.

at behaves as in anim: auto takes the next free step. stride: 0 makes all tiles appear on the same step and staggers only through stagger, in milliseconds; a wave then runs through the grid. A tile that reveals something of its own moves the tiles after it back, as a stagger piece does, whatever the stride.

duration and easing are those of anim and apply to every tile alike: one grid moves as one thing. auto is the presentation’s duration and the package’s own curve. Without them a deck that wanted a slower tile or a curve with a swing had to leave the grid and write the anims out by hand, which is the very work tiles exists to save.

NameVorgabe
..items–
columnsauto
gutter14pt
row-gutterauto
atauto
stride1
stagger0
enter"fade-up"
durationauto
easingauto
–
aligntop + std.​start

Themes#

theme#

theme(
  paper: rgb("#fafafa"),
  ink: black,
  strong: rgb("#23303f"),
  accent: rgb("#eb5e28"),
  muted: luma(45%),
  surface: white,
  border: luma(84%),
  inverted: false,
  font: none,
  title-font: none,
  size: 24pt,
  title-size: 31pt,
  weight: "bold",
  tracking: 0pt,
  header: "band",
  title-fill: p => lesbar(p.strong, white, p.paper, p.ink),
  rule-size: 0pt,
  rule-fill: none,
  head-gap: 20pt,
  foot-gap: 24pt,
  band-height: 66pt,
  footer: "fraction",
  footer-rule: 0pt,
  progress: "bar",
  box: "bar",
  title-slide: band-title-slide,
  section: band-section,
)

Builds a theme. Without an argument it produces the default appearance: themes.default is exactly that.

The entries in groups: colors (paper ink strong accent muted
surface border inverted
), typography (font title-font size
title-size weight tracking
), the ordinary slide (header title-fill
rule-size rule-fill head-gap foot-gap band-height box footer
footer-rule progress
) and the two whole pictures (title-slide, section).

The eight colors are also the entries a palette may carry, and that is how a theme is recolored without touching the rest of it. Two of the entries above are colors that are not palette entries, title-fill and rule-fill, and each may be written either as a color or as a function of the palette, p => p.strong. A color stays what it is under every palette; a function is asked again whenever one is applied, and that is what lets the title of a light theme follow into the dark. rule-fill:
none
means the accent and follows it.

Font sizes: measured against Beamer and Metropolis, where the body text takes up around 3.0% of the slide width and the title 3.9%. The earlier 19pt/23pt were at 2.3% and 2.7%: noticeably smaller than what you would want to read from the back row.

What the theme draws also carries labels, and those are the second way to reach it. Names follow one scheme, place first and part second. On an ordinary slide ts-slide-ground, ts-slide-header-band, ts-slide-header-text, ts-slide-header-rule, ts-slide-title, ts-slide-title-rule, ts-slide-footer, ts-slide-number, ts-slide-footer-rule, ts-slide-progress and ts-slide-progress-track; on the title slide ts-title-slide-ground, ts-title-slide-band, ts-title-slide-title, ts-title-slide-subtitle, ts-title-slide-rule and ts-title-slide-byline; on the section slide ts-section-slide-ground, ts-section-slide-bar, ts-section-slide-title, ts-section-slide-rule, ts-section-slide-parent and ts-section-slide-back. A show rule on one of them changes type or fill without a key having to exist for it; the keys below stay what they are and keep the arrangement.

ts-section-slide-back is the link back to the contents. It is the one label of the section slide that a custom section function does not draw and does not take away: the theme places it after that function has run. Its word follows text.lang. It appears only where there is a contents slide elsewhere in the deck – a deck with none, or one whose contents stands on the section slide itself, gets no link.

The word and the body are the deck’s, not the theme’s: section-back on presentation takes auto, none, content, a string or a function. The place is still the theme’s, at the end of the line along the bottom edge.

The last of those is the line naming the sections a deeper section hangs under. It exists only from the second structure level on, so a deck at the default slide-level: 2 never draws it.

A theme that brings its own title-slide or section function draws none of those labels, and nothing warns about it. Such a section function receives the section record, and there s.depth is the heading level and s.parents are the titles above it, outermost first. A function that reads neither draws every level alike; nothing breaks, the hierarchy is simply not shown.

Comments must NOT go into the parameter list: tidy splits it at the commas and expects a colon in every piece; the API reference breaks on that.

NameVorgabe
paperrgb(​"#fafafa")
inkblack
strongrgb(​"#23303f")
accentrgb(​"#eb5e28")
mutedluma(​45%)
surfacewhite
borderluma(​84%)
invertedfalse
fontnone
title-fontnone
size24pt
title-size31pt
weight"bold"
tracking0pt
header"band"
title-fillp => lesbar(​p.​strong,​ white,​ p.​paper,​ p.​ink)
rule-size0pt
rule-fillnone
head-gap20pt
foot-gap24pt
band-height66pt
footer"fraction"
footer-rule0pt
progress"bar"
box"bar"
title-slideband-title-slide
sectionband-section

themes#

The bundled themes: themes.default, themes.lesson, themes.night, themes.plain, themes.editorial.

They are made for different occasions, not the same slide in five colors: the title sits sometimes in a bar, sometimes free, sometimes under a line; the progress indicator grows, or is missing entirely.

Palettes#

contrast#

contrast(a, b)

The WCAG contrast ratio of two colours, a number from 1 to 21.

#contrast(black, white)                 // 21
#contrast(rgb("#767b84"), white)        // 4.2539

The reference numbers WCAG 2 names: 4.5 for body text, 3.0 for large text and for lines, bars and other shapes that are not text. The package uses them in that sense in palette-report.

Alpha is ignored. A translucent colour is measured as if it were opaque, which is not what it looks like on the slide.

palette-report#

palette-report(p)

Measure a palette against the contrast contract and report every pair.

#for f in palette-report(palettes.dark) [
  #f.pair: #calc.round(f.ratio, digits: 2) (wants #f.min) #f.ok \
]

One dictionary per pair, with pair, ratio, min, ok and role. It only measures and never changes a colour.

This is a report, not a gate. Only the five bundled palettes are actually held to the contract, by an assertion in this file; a palette written in a deck faces no such check, and none of the five bundled themes does either. Four of the five do not pass it, and that is deliberate rather than overlooked. See the manual.

palettes#

The bundled palettes.

Five, one per colour world the package already had, and each of them composes with every theme: themes.lesson in palettes.dark is still the lesson design, only dark.

  • light is exactly the default theme’s colours, so themes.default with it is a no-op.
  • mono is themes.plain’s greyscale, with two greys moved so it passes the contract.
  • textbook is themes.lesson’s measured textbook colours, with muted moved for the same reason.
  • parchment is themes.editorial’s laid paper, with accent and muted moved for the same reason.
  • dark is themes.night’s dark ground with a deeper accent, because night’s own cyan does not survive an inverted slide.

The six numbers that were moved, and why, are in the comments below.

Media and embeds#

audio#

audio(
  src,
  width: 240pt,
  height: 32pt,
  autoplay: false,
  loop: false,
  start: 0,
  end: none,
  muted: false,
  controls: true,
  at: "1-",
  enter: "fade",
)
HTML audio from a local file beside the HTML or a direct HTTP(S) media URL. Playback is controlled on stage or through the presenter; PDF shows a label.
NameVorgabe
src–
width240pt
height32pt
autoplayfalse
loopfalse
start0
endnone
mutedfalse
controlstrue
at"1-"
enter"fade"

embed#

embed(
  url: none,
  html: none,
  width: 100%,
  height: 200pt,
  at: "1-",
  enter: "fade",
  bridge: none,
  zoom: true,
  style: true,
  fallback: none,
  link: none,
  label: auto,
  start: none,
  end: none,
  loop: none,
)

Arbitrary web content in a sandboxed frame.

bridge names the element so step jobs can be sent to it: that is how geogebra drives its applet, and how a companion package of your own would drive anything else, without the core knowing what is inside.

fallback and link only take effect in paged output; in the browser the embedded document itself stands there.

style gives a document passed as html the deck’s basic style: it fills the frame, is transparent, and carries the running text size. Switched off, the frame is a blank browser page again.

NameVorgabe
urlnone
htmlnone
width100%
height200pt
at"1-"
enter"fade"
bridgenone
zoomtrue
styletrue
fallbacknone
linknone
labelauto
startnone
endnone
loopnone

flipbook#

flipbook(
  render,
  frames: 24,
  fps: 30,
  width: 200pt,
  height: 150pt,
  loop: true,
  pingpong: false,
  at: "1-",
  enter: "fade",
  still: auto,
)

Animation drawn by Typst, frame by frame.

render receives t running from 0.0 to 1.0. Every frame is rendered by Typst: CeTZ, Fletcher, equations, anything Typst can do. The frames sit in the file as SVG and stay sharp at any size.

Under prefers-reduced-motion: reduce it does not play. It stands on its last frame without loop and without pingpong, and on frame zero otherwise. See the manual.

NameVorgabe
render–
frames24
fps30
width200pt
height150pt
looptrue
pingpongfalse
at"1-"
enter"fade"
stillauto

video#

video(
  src,
  width: 100%,
  height: 200pt,
  poster: none,
  autoplay: true,
  loop: false,
  start: 0,
  end: none,
  muted: true,
  controls: false,
  radius: 0pt,
  at: "1-",
  enter: "fade",
  ends-at: none,
)

A real HTML5 video over the slide.

Without a poster: the placeholder on paper is labelled <ts-media-poster>.

ends-at makes the video end at a time of day rather than start at one: give it "08:15" and the runtime reads the video’s own length when the slide comes up and starts it far enough in that its last frame falls on that minute. A music video before the lesson thus ends as the lesson begins, whenever the room was opened.

  • Further away than the video is long: it waits on its first frame and starts by itself when its moment comes.
  • More than an hour away, or unreadable: the plan is dropped and the video plays from the start, exactly as before.
  • auto takes the time from room: (bell: "08:15"), so moving a lesson from the first period to the third is one line and not one per video.

The time is the room’s wall clock, not stage time: blacking out and coming back re-computes rather than resumes, since forty seconds of black would otherwise move the end by forty seconds.

NameVorgabe
src–
width100%
height200pt
posternone
autoplaytrue
loopfalse
start0
endnone
mutedtrue
controlsfalse
radius0pt
at"1-"
enter"fade"
ends-atnone

The bridge#

bridge-job#

bridge-job(target, payload, at: "1-")

Send a job to a bridged element.

  • target is the bridge name given to embed.
  • at is a step selector, resolved exactly as for anim: auto takes the next free step, an integer counts as “from this step on”. The default is "1-", because most jobs set the document up on slide entry.

    A job does not move the step cursor: an applet’s tween and the bullet explaining it usually belong on the same step, not one after the other.

  • payload is a dictionary. Its meaning is entirely up to the document on the other side. The core passes it through unread.

When paging backwards or entering a slide the runtime replays the whole run from its start with a reset flag, so jobs should be repeatable.

The document has to announce itself. Nothing is sent to a frame that has not said hello. The runtime marks a frame live only after receiving

parent.postMessage({ typstage: 1, ready: 1 }, "*");

from it. Both fields are needed: the runtime drops every message without typstage: 1 before it ever looks at ready. Miss this and the jobs simply never arrive. There is no error, the applet just sits there.

NameVorgabe
target–
payload–
at"1-"

bridge-targets#

bridge-targets()

The bridge names on the current slide, in the order they appear.

This is how a companion package can leave the applet unnamed: with exactly one bridged element on the slide there is nothing to choose between.

Must be called in a context. Duplicates are dropped, because a tracked element is laid out twice, once in the background and once as its sprite, so it announces itself twice.

GeoGebra#

geogebra#

geogebra(
  ..name,
  id: "ggb",
  material: none,
  app: "classic",
  perspective: "G",
  language: none,
  grid: auto,
  axes: auto,
  seamless: true,
  background: auto,
  animation-button: false,
  ,
  pan: false,
  font-size: 17,
  codebase: "https://www.geogebra.org/apps/",
  width: 100%,
  height: 330pt,
  at: "1-",
  fallback: none,
  link: none,
)

A GeoGebra applet in the slide.

  • seamless takes the frame off the applet and puts its drawing area in the slide’s colour, so it no longer looks like a window of its own.
  • fallback and link only take effect in the PDF: there is no applet on paper, so what stands there is either a drawing of your own or at least the way to the live one.

The id is only needed when a slide holds more than one applet — the commands then say which one they mean. A single applet needs no name.

A .ggb file cannot be embedded: Typst has no base64 encoding, and without it the file content never reaches the HTML. Build the construction with ggb-run or load it through material.

NameVorgabe
..name–
id"ggb"
materialnone
app"classic"
perspective"G"
languagenone
gridauto
axesauto
seamlesstrue
backgroundauto
animation-buttonfalse
–
panfalse
font-size17
codebase"https:​//www.​geogebra.​org/apps/"
width100%
height330pt
at"1-"
fallbacknone
linknone

ggb-animate#

ggb-animate(
  ..objects,
  target: auto,
  at: "1-",
  speed: none,
  playing: true,
  trace: (),
)
GeoGebra’s own animation — it runs back and forth without end.
NameVorgabe
..objects–
targetauto
at"1-"
speednone
playingtrue
trace(​)

ggb-hide#

ggb-hide(..objects, target: auto, at: "1-")
Hide objects — the counterpart to ggb-show.
NameVorgabe
..objects–
targetauto
at"1-"

ggb-run#

ggb-run(..commands, target: auto, at: "1-")

Run GeoGebra commands.

GeoGebra’s scripting commands — SetColor, SetValue and relatives — are not accepted by evalCommand and would come to nothing here. That is what ggb-style, ggb-set, ggb-show and ggb-hide are for: they reach for the JavaScript interface, which can do it.

NameVorgabe
..commands–
targetauto
at"1-"

ggb-set#

ggb-set(values, target: auto, at: "1-")
Set values in the applet.
NameVorgabe
values–
targetauto
at"1-"

ggb-show#

ggb-show(..objects, target: auto, at: "1-")
Reveal objects that were hidden before.
NameVorgabe
..objects–
targetauto
at"1-"

ggb-style#

ggb-style(
  ..objects,
  target: auto,
  at: "1-",
  color: none,
  thickness: none,
  line-style: none,
  filling: none,
  point-size: none,
  trace: none,
  label: none,
  label-mode: none,
  fixed: none,
  caption: none,
  layer: none,
  position: none,
)
Appearance — color takes a Typst colour, so the construction carries the colours of your slides instead of GeoGebra’s palette.
NameVorgabe
..objects–
targetauto
at"1-"
colornone
thicknessnone
line-stylenone
fillingnone
point-sizenone
tracenone
labelnone
label-modenone
fixednone
captionnone
layernone
positionnone

ggb-tween#

ggb-tween(
  ..name,
  target: auto,
  to: 1.0,
  from: none,
  at: 1,
  duration: 650,
  easing: "ease-in-out",
)

Once from A to B — the construction draws itself.

GeoGebra’s own animation runs back and forth for ever. Drawing needs the opposite — once from 0 to 1 and then stop — so here the browser counts the value up frame by frame. Build an object that depends on it and it grows along: a segment whose endpoint travels, an arc whose angle follows.

at points at one step — that is where it is drawn. On the steps after it the value simply sits at its target, so jumping back shows the finished drawing instead of the movement a second time.

NameVorgabe
..name–
targetauto
to1.​0
fromnone
at1
duration650
easing"ease-in-out"

ggb-view#

ggb-view(
  target: auto,
  at: "1-",
  x: none,
  y: none,
  grid: none,
  axes: none,
)
Viewport, grid, axes.
NameVorgabe
targetauto
at"1-"
xnone
ynone
gridnone
axesnone
desmos#
desmos(
  ..name,
  id: "dsm",
  api-key: none,
  version: "1.11",
  expressions: (:),
  bounds: none,
  ,
  expression-list: false,
  settings-menu: false,
  zoom-buttons: false,
  keypad: false,
  pan: false,
  grid: auto,
  axes: auto,
  axis-numbers: auto,
  seamless: true,
  background: auto,
  width: 100%,
  height: 330pt,
  at: "1-",
  fallback: none,
  link: none,
)

Ein Desmos-Graph auf der Folie.

  • api-key ist Pflicht. Desmos gibt sein Skript ohne Schlüssel nicht heraus – gemessen antwortet der Server dann mit 403. Zum Ausprobieren gibt es demo-key; wer damit vorträgt, trägt Desmos’ Konsolenwarnung mit sich herum und arbeitet außerhalb dessen, wofür der Schlüssel gedacht ist.
  • expressions ist das Startbild als Wörterbuch von id nach LaTeX. Ein Ausdruck mit = ist ein Regler, einer ohne eine Kurve; das entscheidet Desmos.
  • bounds ist der Ausschnitt als (links, rechts, unten, oben).
NameVorgabe
..name–
id"dsm"
api-keynone
version"1.​11"
expressions(​:​)
boundsnone
–
expression-listfalse
settings-menufalse
zoom-buttonsfalse
keypadfalse
panfalse
gridauto
axesauto
axis-numbersauto
seamlesstrue
backgroundauto
width100%
height330pt
at"1-"
fallbacknone
linknone
dsm-animate#
dsm-animate(
  ..ids,
  target: auto,
  at: "1-",
  playing: true,
  min: none,
  max: none,
  step: none,
)

Desmos’ eigene Regleranimation an- oder abschalten.

Sie läuft mit Desmos’ Geschwindigkeit und ohne Ziel. Wer von einer Zahl zu einer anderen will und dann stehenbleiben, nimmt dsm-tween.

NameVorgabe
..ids–
targetauto
at"1-"
playingtrue
minnone
maxnone
stepnone
dsm-expr#
dsm-expr(..objects, target: auto, at: "1-")
Ganze Ausdrucksobjekte durchreichen, für alles, was dsm-set nicht kann – Farbe, Reglergrenzen und Stil in einem Zug.
NameVorgabe
..objects–
targetauto
at"1-"
dsm-hide#
dsm-hide(..ids, target: auto, at: "1-")
Ausdrücke verbergen. Sie bleiben im Graphen und rechnen weiter mit.
NameVorgabe
..ids–
targetauto
at"1-"
dsm-remove#
dsm-remove(..ids, target: auto, at: "1-")
Ausdrücke entfernen.
NameVorgabe
..ids–
targetauto
at"1-"
dsm-set#
dsm-set(values, target: auto, at: "1-")

Ausdrücke setzen oder ändern – ein Wörterbuch von id nach LaTeX.

Dieselbe id noch einmal ersetzt den Ausdruck, statt einen zweiten anzulegen. So bewegt sich eine Kurve über die Schritte.

NameVorgabe
values–
targetauto
at"1-"
dsm-show#
dsm-show(..ids, target: auto, at: "1-")
Ausdrücke zeigen.
NameVorgabe
..ids–
targetauto
at"1-"
dsm-style#
dsm-style(
  ..ids,
  target: auto,
  at: "1-",
  color: none,
  line-style: none,
  line-width: none,
  line-opacity: none,
  point-style: none,
  point-size: none,
  fill: none,
  fill-opacity: none,
  label: none,
  show-label: none,
  drag-mode: none,
)
Aussehen eines Ausdrucks. Die Schlüssel sind Desmos’ eigene.
NameVorgabe
..ids–
targetauto
at"1-"
colornone
line-stylenone
line-widthnone
line-opacitynone
point-stylenone
point-sizenone
fillnone
fill-opacitynone
labelnone
show-labelnone
drag-modenone
dsm-tween#
dsm-tween(
  name,
  target: auto,
  to: 1.0,
  from: none,
  at: 1,
  duration: 600,
  easing: "ease",
)

Einen Regler von einer Zahl zur anderen ziehen, in einer gegebenen Zeit.

Zwei Aufträge, wie bei ggb-tween nebenan, und der zweite ist der wichtigere: Die Bewegung liegt auf genau diesem Schritt, und ab dem nächsten steht der Endwert einfach da. Ohne das liefe sie auf jedem weiteren Schritt der Folie noch einmal an – gemessen sprang der Regler beim Weiterblättern von 3 zurück auf 0,75 und wuchs erneut, weil ein ganzzahliges at zu “ab diesem Schritt” wird und nicht zu “auf diesem”.

Auf Papier und beim Zurückblättern steht das Ergebnis sofort da: eine Bewegung, die niemand sieht, ist keine.

NameVorgabe
name–
targetauto
to1.​0
fromnone
at1
duration600
easing"ease"
dsm-view#
dsm-view(
  target: auto,
  at: "1-",
  bounds: none,
  grid: none,
  axes: none,
  axis-numbers: none,
  degrees: none,
  polar: none,
)
Der Ausschnitt, als (links, rechts, unten, oben), und die Grapheinstellungen.
NameVorgabe
targetauto
at"1-"
boundsnone
gridnone
axesnone
axis-numbersnone
degreesnone
polarnone
demo-key#

Der Demo-Schlüssel aus Desmos’ eigener Dokumentation.

Er ist ausdrücklich zum Ausprobieren gedacht, nicht für den Betrieb. Das geladene Skript sagt es selbst in der Konsole: “This page is using the Desmos API with a trial key suitable for prototyping, not for commercial use.” Einen eigenen gibt es über https://www.desmos.com/my-api.

Measurements, colours, runtime files#

dark#

The default palette. Override it by wrapping the presentation in your own document template. See style on presentation.

runtime-files#

The runtime files, ready to be written next to the HTML.

Typst cannot create files. Whoever uses assets: "split" or a CDN writes them out once. The content comes from here so the copies cannot drift.

runtime-version#

Version of the runtime. It goes into the asset file names so a CDN can hold several releases side by side and no browser serves a stale one from cache.

slide-width#

Default slide geometry. 16:9 on an A4-width canvas, so a slide and a handout page carry text at the same physical size. presentation takes width, height and margin to override them.