What this package is#
typstage turns a single Typst file into an animated presentation for the browser, and a PDF from the same source. The sentence behind it is this: Typst typesets, the browser moves. Every slide is set by Typst as SVG and written into the HTML file as such, so the arrangement in the browser is the one on paper. Motion comes afterwards. Whatever is meant to stir is registered for it in the source, and a small runtime sets it going in the browser.
The rest follows from that. A slide is a slide and not a stack of intermediate states. The PDF has one page per slide, not one per step. And whatever belongs to the motion alone falls away on paper by itself.
Five words this manual uses#
The vocabulary matters more here than in a package that counts pages, because one of these words does not mean what it usually means.
- Slide
- One picture, typeset once by Typst. It is one page of the PDF and one
.ts-slidein the HTML. - Step
- One press of the arrow key. A slide can hold several of them. Paging forward inside a slide reveals more of it; at its end the next press moves to the next slide. The address bar counts steps, the footer counts slides.
- Element
- A piece of a slide that the runtime may touch. It is typeset in place, held back with
hide(), and painted over a marker that says where it belongs.anim,stagger,alternatives,morph,embed,videoandflipbookall produce one. - Morph
- The same named element on two slides. Between them it flies, glyph by glyph where it can.
- Speaker view
- The same file opened a second time with
#speakeron the address. It carries the note, the clock and the next step, and it draws on the slide the room sees.
Where it sits among the others#
Typst has good presentation packages, and most of them make PDF. touying and polylux are mature, have far more themes, and are on Universe. If you want a normal PDF talk, take one of those.
What this package does that they do not is level four of four: a named piece stands in one place on slide n and elsewhere on slide n+1 and flies there, ideally glyph by glyph, so an equation visibly rewrites itself. The other levels are a page that turns, a viewer that cross-fades between two pages, and whole slides pushed around by a script.
The other comparison group is reveal.js, Slidev and Quarto. They animate in the browser and do it well, but the layout is HTML’s, not Typst’s. Here the layout is Typst’s to the point, and the price is on the next page but one.
This manual is ordered by intent rather than by function:
- Your first presentation — from the empty file to a running HTML
- Revealing a slide step by step —
pause,stagger,anim,alternatives - Showing instead of claiming — an applet, a video, a flip book
- Developing a calculation — magic move across several slides
- Giving the talk — keys, touch, the overview, the speaker view
- Three outputs from one source — talk, slide deck, handout
- Making it your own — themes, colours, canvas, building blocks
- Handing it on — one file, assets, hosting
- What it cannot do — reach, accessibility, size
- When nothing happens — the traps, in the order they are usually hit
- API reference — every function, from the source
Your first presentation#
The aim of this chapter: a complete, presentable talk, in ten minutes and without detours.
One file is enough#
No more than this is needed. An import, a show rule, and headings. The following file is complete and can be typed out:
#import "@schule/typstage:0.1.0": *
#show: presentation.with(
title: [The Pythagorean Theorem],
subtitle: [A derivation in four steps],
author: [Mathematics · Year 9],
date: datetime.today(),
transition: "slide",
)
= What this is about
== The claim
#speaker-note[Show the dissection first, then the formula, not the other way round.]
In a right-angled triangle the two shorter sides together carry as much area
as the longest one.
#pause
And that is the formula: $a^2 + b^2 = c^2$A first-level heading is a section slide, a second-level heading is a slide, and the text below it is its body. That is the whole structure.
Two compilations#
The same file yields two outputs, and which one you get depends on the flags:
typst compile talk.typ talk.html --format html --features html
typst compile talk.typ talk.pdf--features html the HTML export is not available at all, and Typst says so in a way that is easy to mistake for a mistake of your own. The feature is experimental on Typst’s side, not on this package’s.Looking at it#
The HTML file is one file. Double-click it, and it runs: no server, no network, nothing loaded afterwards. That is deliberate and it is the reason several decisions later in this manual look stricter than they need to.
Arrow keys page. ? shows every key, o opens the overview, f goes full screen, and n opens the speaker view in a second window.
Revealing a slide step by step#
The aim of this chapter: a slide that unfolds in front of the room instead of standing there finished.
Which tool for what#
Four building blocks cover very nearly everything. They mix on one slide, and which one is right depends on how finely the slide needs to be steered.
| Tool | For what |
|---|---|
#pause | The slide unfolds from top to bottom without anything having to be wrapped. The shortest way and the commonest case. |
stagger[…] | A list point by point, bullet and text together. Also for several blocks in sequence. |
anim(…) | One particular piece on one particular step, with a motion of its own. The tool wherever #pause cannot reach: in grid cells, tables, boxes. |
alternatives(…) | Several versions of the same thing in the same place, each replacing the one before. |
Beside them stand tiles for a grid that staggers itself, and morph for things that fly between two slides.
The step cursor#
Every slide carries a step cursor. at is auto by default, and auto means “the next free step”. Consecutive reveals therefore number themselves, and as a rule a slide holds no number at all.
== Three things
#anim[first] // step 1
#anim[second] // step 2
#anim(at: 4)[late] // 4
#anim[after that] // 5The spellings of at:
| Written | Meaning |
|---|---|
auto | the next free step (the default) |
3 | from step three on, the same as "3-" |
(2, 5) | on step two and on step five |
"2-" | from step two on |
"1-2" | on steps one and two, not after that |
"2,4" | on step two and on step four |
"-2" | from the start until step two |
"3" | exactly on step three |
A bare number is an open end: what is there once stays until the end of the slide. That is the normal case. A closed spelling such as "1-2" or "3" lets the element disappear again, and then exit applies.
A slide without a single number#
#pause needs no counting at all. It cuts the body at the place where it stands, and everything after it arrives one step later:
== What we know
The two legs carry as much area as the hypotenuse.
#pause
$ a^2 + b^2 = c^2 $
#pause
And that is enough to compute the third side from two of them.#pause splits the body. That is why it works between blocks and not inside a grid cell or a table: there is nothing there to cut. anim is the tool for those places, and it can go anywhere content can go.A list point by point#
stagger takes a list and reveals it item by item, bullet and text together:
#stagger[
- What the room already knows
- What it is about to learn
- What it will be able to do afterwards
]stride: 2 puts two items on each step, stride: 0 puts all of them on the same one. start sets the first step, enter the motion, stagger the delay in milliseconds between neighbours, and spacing the distance between the items.
stagger also takes several blocks instead of one list. Then each block is one step, which is the way to reveal three paragraphs or three pictures in turn without writing three anim calls.One piece on a step of its own#
anim wraps exactly what should appear and says when:
#side-by-side(
card(title: [Before])[The old way.],
anim(at: 2, enter: "fade-left", card(title: [After])[The new one.]),
)Entrance and exit#
enter and exit name the motion. Ten of them exist:
| Written | What happens |
|---|---|
"fade" | opacity alone |
"fade-up" | from a little below, the default for an entrance |
"fade-down" | from a little above |
"fade-left", "fade-right" | from the side |
"scale" | grows into place |
"scale-down" | shrinks into place |
"blur" | out of the blur |
"rise" | from below and slightly smaller, the loudest of them |
"none" | it is simply there |
duration is in milliseconds and auto takes the presentation’s. delay holds the start back, which is what makes two elements on the same step arrive one after the other.
enter: "fdae-up" is only noticed by the motion being duller than it was meant to be.Several versions in the same place#
alternatives puts versions on top of one another. Each step shows exactly one, the next replaces it:
#alternatives(
$ (a + b)^2 $,
$ a^2 + 2 a b + b^2 $,
$ a^2 + 2 a b + b^2 = c^2 $,
)The box is as large as the largest version, so nothing around it jumps when the content grows. align decides where the smaller ones sit inside it, start on which step the first one appears, and inline: true puts the whole thing in a line of text instead of in a block of its own.
Three stumbling blocks#
Only reveals count. The cursor counts anim, stagger, alternatives and #pause, that is, everything that makes something appear. An applet, a video or a morph uses up no step and pushes nothing along. Such elements are there from the beginning. In a two-column slide that is decisive, because the bullets beside an applet should start at one and not behind its motions:
#side-by-side(
embed(url: "…", width: 100%, height: 220pt), // no step
stagger[
- first bullet // step 1
- second bullet // step 2
],
)A step is not inherited inwards. Every tracked element carries its own step. Where one sits inside another, the inner one still follows its own:
#anim(at: 3)[From step three, #morph(<m>, $x^2$) but from step one.]With morph that is right: the target of a flight has to be standing when the slide is entered, or the flight from the previous slide would arrive nowhere. With an anim inside an anim it is usually an oversight, and it is only noticed while paging, when the outer element is still invisible and the inner one already stands.
A morph stands from the first step. That is the default and it is usually right. It follows that a morph does not belong inside something that only appears later. Put it in a tile that arrives on step two and it hovers alone on step one, at the place where its container will only later turn up.
Showing instead of claiming#
The aim of this chapter: a slide that demonstrates something rather than asserting it. Three ways in, from the most involved to the simplest.
A document of your own on the slide#
embed puts arbitrary HTML into a sandboxed frame:
#embed(html: "<div id=lamp></div><script>…</script>",
width: 100%, height: 190pt)url: takes a foreign address instead. Both spellings put a frame on the slide; the difference is what may be reached later.
em. embed puts the deck’s basic style in front of the document, and inside a zoomed frame one CSS pixel is exactly one point of the slide. So the content grows with the slides. Written as 78px it would stay the size it has on a laptop even on a projector, which measured against the slide is about a third as wide. A page that reflows on its own wants zoom: false instead, and then it spans real screen pixels.style: false leaves out the basic style where the embedded document brings its own. fallback is what stands on paper in its place, and link is the address printed beneath it, so whoever holds the handout can still get there.
Sending it something on a step#
A frame with a bridge: has a name, and bridge-job sends it a dictionary when a step arrives:
#embed(html: lamp, bridge: "lamp", width: 100%, height: 190pt)
#bridge-job("lamp", (color: "#16a34a"), at: 2)
#bridge-job("lamp", (color: "#eb5e28"), at: 3)The package never reads what is in the job. What it means is known only to the document on the other side. That is exactly how typstage-geogebra drives its applets.
Three things about the bridge, and each of them has cost somebody an hour.
The document has to announce itself once with postMessage({typstage: 1, ready: 1}). Until it does, the runtime treats the frame as not yet alive and sends it nothing.
Paging back replays the whole run with a reset, so a job has to be repeatable. “Set the colour to green” survives that, “make it greener” does not.
bridge-targets() reports the names on the current slide. Two frames sharing a name both receive every job, and the runtime says so in the console rather than guessing.
Video#
#video("clip.mp4", width: 100%, height: 260pt, poster: image("still.png"))The file travels beside the HTML, it is not embedded. autoplay, loop, muted and controls are the usual switches; poster is what stands there before it runs and what the PDF shows in its place.
A flip book#
flipbook lets Typst render the motion itself, frame by frame:
#flipbook(t => cetz.canvas({ … }), frames: 30, fps: 30,
width: 220pt, height: 160pt)The function receives t running from 0 to 1 and is called once per frame. Every frame sits in the file as SVG and stays sharp at any size. That makes it the tool for motion that Typst can draw and CSS cannot: a curve being traced, a mechanism turning, a diagram assembling itself.
loop, pingpong and still decide how it plays and which frame stands on paper.
Developing a calculation#
The aim of this chapter: an equation that rewrites itself in front of the room instead of being replaced by the next one.
One name, two slides#
The same name on two slides, and the thing flies across:
== Step 1
#morph(<term>, $ (a + b)^2 $)
== Step 2
#morph(<term>, $ a^2 + 2 a b + b^2 $)The name is a string or a label. Nothing else is needed: the runtime finds both ends, pairs the glyphs, and moves each one from where it was to where it now belongs.
How the pairing works#
match: "auto" compares the outlines. Two glyphs of the same shape find each other, and where that is not enough, proximity decides. "glyph" forces it per glyph, "block" moves the whole thing as one rectangle.
"block" is the right answer more often than it looks. A whole picture or a table has no glyphs worth pairing, and per-glyph matching there produces a swarm rather than a movement.When the wrong signs fly#
Where the pairing goes astray, name the pieces. pin marks a piece inside a morph, and matching names find each other before the shape is consulted:
#morph(<term>)[$#pin(<factor>)[3] x^#pin(<power>)[4]$]
// and on the next slide
#morph(<term>)[$#pin(<power>)[4] dot #pin(<factor>)[3] x^3$]A pin without a counterpart on the other slide falls back to shape matching without complaint, so pinning one troublesome pair costs nothing elsewhere.
Duration and the first link#
duration is 900 ms rather than the presentation’s, because a flight across the slide takes longer than a fade-in. auto falls back to the presentation’s value.
A morph is present from the first step, and that holds at both ends of a chain, because paging back swaps the roles. The one exception is the first link: there no flight arrives, so it may be delayed. The package checks at compile time that the preceding slide really carries no morph of that name, and says so when it does.
Where the magic move stops#
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. That is occasionally what you want and more often a surprise.
#set rule written in the document. Shared typography belongs in style: on presentation, which reaches both. This is the single most common reason for a flying equation that suddenly has the wrong font.How the slide itself changes#
transition decides how a slide comes in. The presentation sets the default for all of them, and a single slide may differ:
#show: presentation.with(transition: "slide", transition-duration: 420)
== This one differently
#transition("cover", from: "bottom")
// or, in the argument form:
#slide([This one differently], transition: (kind: "cover", from: "bottom"))[…]| Kind | Takes | What happens |
|---|---|---|
"none" | – | A hard cut. |
"fade" | – | A cross-fade, nothing moves. |
"slide" | from | The new slide moves in a short way and fades up while doing it, the old one gives way in the other direction. |
"push" | from | The new one pushes the old one over the edge. |
"cover" | from | The new one lays itself over the old one, which stays. |
"uncover" | from | The old one moves away and frees the new one. |
"zoom" | direction | "in" grows the new one forward, "out" steps the old one back. |
"blur" | – | Out of focus and back. |
"iris" | direction | A round aperture: "open" opens the new slide, "close" closes over the old. |
"wipe" | direction, from | The same as a straight edge; from names the edge it starts at. |
"flip" | axis | Turning over in space, like a leaf. |
"cube" | axis | Like flip, but as two faces of a cube that keeps turning. |
from is "right" (the default), "left", "top" or "bottom". direction is "in"/"out" for "zoom" and "open"/"close" for "iris" and "wipe", the first value being the default in each case. axis is "y" (the default, turning about the vertical) or "x".
Three things about transitions are less obvious than they look.
The transition belongs to the boundary between two slides, not to the direction of travel. What counts is always the setting of the later of the two, the one that comes in when paging forward.
Backwards it runs as a real reversal. Not the same transition again, but mirrored: what was pushed out comes back from the same side, what closed over opens again.
Where a morph meets the slide, it cross-fades. As soon as something flies between two slides, the configured transition gives way to a plain cross-fade. Otherwise the slide would push away the very object flying across it. For a chain of transformations that means the transition does not have to be switched off by hand.
Giving the talk#
The aim of this chapter: everything that happens between opening the file and the last slide, including the second window.
The keys#
| Key | What it does |
|---|---|
→ space PageDown | one step forward |
← PageUp | one step back |
Home End | to the first or the last step |
o Esc | the overview, and a click there goes to that slide |
f | full screen |
s | the note of the current slide, in the bar |
? | every key |
p | |
n | open the speaker view, or bring the talk forward |
A click pages forward, a click in the left quarter pages back. The address bar carries the running step, #12 being the twelfth, so a reloaded window stands in the same place and a number typed by hand jumps there.
A frame that has the focus#
Click an embedded frame and it holds the focus. From then on every key lands inside it, the window around it hears nothing, and the talk stops paging.
The keys the talk uses are therefore handed back to it out of any frame this window may read into. Three conditions keep that honest: the embedded document must not have taken the key already, the key must be one the talk actually uses, and what was typed into must not be a text field, or an n typed into a form would open a second window.
preventDefault on none of them, and it changes nothing in the construction: without a toolbar and without an algebra input it has no use for the keyboard at all. A document that does want a key takes it in the ordinary way, by preventing the default, and then it keeps it.Everything outside that set stays with the frame. Delete is the example: it belongs to whatever is embedded, and the talk never sees it.
On a phone or a tablet#
A tap pages, in the same two halves as a click. A swipe pages in the natural direction: the finger pushes the slide out to the left, so the next one comes.
Vertical swipes and two fingers are left to the browser: one is scrolling and the other is zooming.
The speaker view#
n opens the same file a second time, with #speaker on the address, in a second window. One goes on the projector, the other on the machine in front of you. The two talk over postMessage, and that carries between two local files as well, so this needs a server as little as everything else here.
Visible are the running slide, large, beside it the next step, below it the note, together with the time of day, the elapsed time and, once a target duration is typed in, whether you are ahead of or behind plan.
Drawing#
You draw on the running slide there, and the strokes appear on the projected one. That direction is deliberate: the presenter has a trackpad in front of them and the canvas is across the room.
Strokes stick to their slide, so paging away and back brings them with you. x clears the current slide, z takes back the last stroke, c changes colour.
The pointer#
m switches the pointer between the pen and the embedded frame. In pointer mode the pen rests, and a press on an embedded frame lands in the talk window instead: the same spot, the same gesture, at whatever size that window happens to have. Press, drag and release travel as fractions of the stage, so a small laptop window and a large canvas hit the same point of the document.
Where the embedded document can mirror itself, as a GeoGebra applet does through typstage-geogebra, the live one in front of you is operated instead and the projected copy follows.
input type=range does not move, because a browser only drags its own slider for input it trusts. Whoever builds for this listens rather than relying on a native control.Blacking out and freezing#
b blacks the room out, e freezes the projected image while you page ahead in private. Both end by themselves if the speaker window goes away.
Steering works from either window, and either one may be reloaded: they find each other again, and the strokes come back.
Measured in Chrome, Firefox 154 and Safari 26: the six example decks run through in all three with the same numbers, and the speaker view opens in all three on one keypress. A real keypress is the condition, since window.open without a user gesture would fall to the popup blocker everywhere.
Three outputs from one source#
The aim of this chapter: the talk for the canvas, the deck to read afterwards, and the handout to write on, without a second version to keep in step.
The slide deck#
The PDF run without further arguments gives one page per slide, in the size of the canvas. Every element that moves in the browser stands there in its final state: what is revealed is there, and of several versions in one place the last one stands. What belongs to the motion alone, the notes, the transitions, the jobs for embedded elements, are state changes without output and fall away by themselves.
The handout#
One argument turns the deck into a handout on A4:
#show: presentation.with(handout: 3) // three slides per pagehandout takes true (two per page) or a number from 1 to 6 and applies only to the PDF. The slides are not typeset again, only made smaller, so a handout cannot differ from what stood on the canvas.
Beside or below each slide stands its note; where a slide has none, ruled lines take its place. Which of the two depends on the count. A 16:9 slide beside a column of notes is wide and low, and at up to two slides per page most of the portrait page would stay empty. Up to two the notes therefore stand below and the slide takes the full width; from three on they stand beside.
All three in one run#
Since Typst 0.15 one compilation can write several files. That suits this package, because talk, deck and handout differ only in their target and in one argument. bundle writes all three at once:
#bundle(
theme: themes.lesson,
title: [Completing the Square],
handout: "handout.pdf",
)[
= A section
== A slide
Text.
]typst compile --features bundle,html --format bundle talk.typ outhtml, slides and handout are file names, none leaves that output out, and per-sheet is the number of slides on a handout page. Everything else goes to presentation unchanged.
The counters start afresh per output, measured on the deck: it numbers 1, 2, 3 and does not carry on where the HTML version stopped, although Typst runs introspection across the whole bundle.
--features bundle,html. And a file that uses bundle can only be compiled with --format bundle; a plain typst compile talk.typ talk.pdf stops with “constructing a document is only supported in the bundle target”. Whoever wants to keep both routes open puts the body in a #let and calls presentation by hand.Notes#
speaker-note files a note with the slide. It stands in the body or as the argument note on slide:
== The Pythagorean Theorem
#speaker-note[Show the dissection first, then the formula.]The note appears in the speaker view, on s in the bar, and on the handout. It produces nothing in the deck PDF.
Making it your own#
The aim of this chapter: a deck that looks like yours and not like the package.
Choosing a theme#
Five ship with the package. They are made for different occasions rather than being the same slide in five colours: the title sits sometimes in a bar, sometimes free, sometimes under a line; the progress indicator grows, travels, or is missing entirely.
| Theme | Made for |
|---|---|
themes.default | A conference talk. Title in a coloured band, bar of progress. |
themes.lesson | A lesson. Built from a measured textbook page: 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, nothing else. |
themes.editorial | Reading rather than presenting. A serif face, generous measure, 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 colours are paper, ink, strong, accent, muted, surface and border; header is "band", "plain" or "run", footer is "fraction", "number", "center" or "none", progress is "bar", "top", "tick" or "none", and box is "bar" or "label". title-slide and section are functions, because those two are whole pictures rather than variations on one another.
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 },
)#set rule written in the document, so shared typography 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.Building blocks for the body#
card- A named box.
number:puts a numbered disc in front of the text. callout- The one that has to stick, with the bar down its left side. Its caption follows the document language and can be replaced with
title:. side-by-side- Columns;
split:gives the widths,equal: truemakes both the height of the taller. tiles- A grid that numbers its own reveals, one tile per step, without a hand-counted
at:on each. statement- One large sentence, centred, for the slide that carries a single claim.
Handing it on#
The aim of this chapter: getting the talk to where it will be given.
One file#
assets: "inline" is the default and writes the runtime into the HTML. The result is one file that runs from a memory stick, from a download folder, from an email attachment. No server, no network, nothing loaded afterwards.
The six example decks measure between 1.2 and 2.1 MB that way, and the runtime is about 100 KB of that. What makes up the rest are the slides themselves: the tour holds 136 SVGs and 6111 glyph references.
Beside the file#
assets: "split" refers to two files next to the HTML instead, and runtime-files gives you their names and contents so you can write them out:
#for f in runtime-files {
// f.name and f.content
}That is worth it where many decks are published together, because the browser then caches the runtime once for all of them. assets: "https://…" points at a directory on a server or a CDN.
Hosting#
The HTML file is static. Anything that serves files serves it: GitHub Pages, a university web space, an S3 bucket. Two things to watch.
Media travels beside the file. video("clip.mp4") refers to a file that has to lie next to the HTML, and a deck that works locally will show an empty frame once uploaded without it.
A deck opened from file:// behaves like one from a server, including the speaker view, because everything here goes over postMessage. That is the one place where this package is easier than the browser-native tools, which need a server for exactly that reason.
What it cannot do#
The aim of this chapter: the limits, in one place, so they are not discovered in front of an audience.
Accessibility#
This is the hardest limit, and it follows directly from the design decision on the first page.
The slides are SVG outlines. Text in them is drawn as paths and glyph references, not as text. Nothing in the browser is selectable, nothing is searchable, and a screen reader finds nothing to read. There is no text alternative behind them and no reading order.
What does work: the document carries a lang attribute from text.lang, so the page announces its language. Navigation is fully operable from the keyboard, and the full key list is one press of ? away. Colour and contrast are the theme’s and therefore yours to set, and themes.plain is the darkest of the five on white.
What does not work yet: prefers-reduced-motion is not honoured. A viewer who has asked their system for less motion still gets every fade, flight and slide transition. If that matters for your audience, transition: "none" and enter: "none" are the manual way to the same place.
Reach#
Measured in Chrome, Firefox 154 and Safari 26 on macOS, and on an iPhone. The six example decks run through in all three engines with identical numbers and no console errors.
Not measured: older browsers, Windows, Android. The runtime uses the Web Animations API, ResizeObserver, PointerEvent and CSS zoom, so a browser from before about 2023 is likely to fall short somewhere.
Size and speed#
A slide is typeset as often as it has states, and every tracked element is typeset once more in a frame of its own. Compile time therefore grows with steps, not with slides, and flipbook grows with frames.
The largest of the six example decks compiles in a few seconds and weighs 2.1 MB. A deck of a hundred slides with a flip book on each would be a different matter, and the honest advice is to measure rather than to guess.
When nothing happens#
The traps, in roughly the order they are usually hit.
- No HTML export
--features htmlis missing. The export is experimental on Typst’s side, not on this package’s.- The deck is empty but for the title
- the two notations have been mixed. In the heading form you write
= …and== …; in the argument form you callslide(...)and hand the slides topresentation. Aslide(...)call inside the body of a show rule produces no slide and no error either. Measured on a probe: one slide instead of three, and nothing said. - The first paragraph is missing
- content before the first heading belongs to no slide and appears nowhere.
- The slide titles ignore a
#set heading - it stands after the show rule and they have left the region it encloses.
style:reaches them. #pausedoes nothing- it is inside a grid cell or a table.
#pausesplits the body, and there is nothing there to split.animgoes anywhere content goes. - A transition or an entrance is duller than it was meant to be
- an unknown name quietly becomes a cross-fade. Check the spelling against the tables above, since a typo does not stop the build.
- The bullets beside an applet start at step three
- an
embeduses no step, but something before it did. Count the reveals, not the elements. - A flying equation has the wrong font
- typography set with
#setdoes not reach a tracked element, which is typeset in a frame of its own.style:onpresentationreaches both. - An embedded frame stays empty and gets no jobs
- the document has not announced itself with
postMessage({typstage: 1, ready: 1}). - An embedded frame is tiny on the projector and right on the laptop
- its content is sized in pixels instead of
em. Inside a zoomed frame one CSS pixel is one point of the slide. - “constructing a document is only supported in the bundle target”
- the file uses
bundleand therefore needs--format bundle. - The speaker view does not open
window.openneeds a real keypress, and a script cannot stand in for the gesture.
API reference#
Generated from the comments in the source files. The order follows the build of the package: first the presentation and its slides, 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:
bundletypst 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.
Verified: the counters start over for each output. The slide deck numbers its slides 1, 2, 3 and does not continue counting where the HTML version left off, even though Typst runs introspection across the whole bundle.
| Name | Vorgabe |
|---|---|
body | – |
html | "talk.html" |
slides | "slides.pdf" |
handout | none |
per-sheet | 3 |
..args | – |
presentation#
presentation(
..slides,
title: none,
subtitle: [],
author: [],
date: none,
assets: "inline",
theme: themes.default,
transition: "slide",
transition-duration: 420,
duration: 520,
style: it => it,
width: auto,
height: auto,
margin: auto,
handout: false,
)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.pdftheme: 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.
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.
| Name | Vorgabe |
|---|---|
..slides | – |
title | none |
subtitle | [] |
author | [] |
date | none |
assets | "inline" |
theme | themes.default |
transition | "slide" |
transition-duration | 420 |
duration | 520 |
style | it => it |
width | auto |
height | auto |
margin | auto |
handout | false |
Slides#
slide#
slide(title: none, note: none, transition: none, ..rest)A regular slide.
title: none, or a bare == in heading form, leaves out the title bar; the body then gets the whole area.
| Name | Vorgabe |
|---|---|
title | none |
note | none |
transition | none |
..rest | – |
title-slide#
title-slide(title: [], subtitle: [], author: [], date: none)| Name | Vorgabe |
|---|---|
title | [] |
subtitle | [] |
author | [] |
date | none |
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;fromsays where the new slide comes from:"right"(default),"left","top","bottom".pushshoves the old one out,coverlies down on top,uncoverpulls 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,fromadditionally 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.
Revealing, moving, staggering#
alternatives#
alternatives(
..variants,
start: auto,
align: top + left,
enter: "fade",
duration: auto,
inline: 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.
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.
| Name | Vorgabe |
|---|---|
..variants | – |
start | auto |
align | top + left |
enter | "fade" |
duration | auto |
inline | false |
anim#
anim(
body,
at: auto,
enter: "fade-up",
exit: "fade",
duration: auto,
delay: 0,
)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.
| Name | Vorgabe |
|---|---|
body | – |
at | auto |
enter | "fade-up" |
exit | "fade" |
duration | auto |
delay | 0 |
morph#
morph(
name,
body,
at: "1-",
duration: 900,
match: "auto",
inline: true,
)Magic move: the same name on two slides, and the thing flies across.
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.
| Name | Vorgabe |
|---|---|
name | – |
body | – |
at | "1-" |
duration | 900 |
match | "auto" |
inline | true |
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.
The name is a string or a label.
stagger#
stagger(
..items,
start: auto,
stride: 1,
enter: "fade-up",
duration: auto,
stagger: 60,
spacing: 0.65em,
)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.
| Name | Vorgabe |
|---|---|
..items | – |
start | auto |
stride | 1 |
enter | "fade-up" |
duration | auto |
stagger | 60 |
spacing | 0.65em |
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 left marks it on every slide at a glance as “the thing to remember”, without it looking like a second box.
| Name | Vorgabe |
|---|---|
body | – |
title | auto |
color | auto |
radius | 7pt |
inset | (x: 14pt, y: 11pt) |
width | 100% |
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.
| Name | Vorgabe |
|---|---|
body | – |
title | none |
number | none |
color | auto |
fill | auto |
stroke | auto |
radius | auto |
inset | (x: 12pt, y: 10pt) |
width | 100% |
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.
| Name | Vorgabe |
|---|---|
..parts | – |
split | (1.25fr, 1fr) |
gutter | 18pt |
align | horizon |
equal | false |
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.
| Name | Vorgabe |
|---|---|
body | – |
size | 1.6em |
color | none |
above | 0.6em |
below | 0.6em |
tiles#
tiles(
..items,
columns: auto,
gutter: 14pt,
row-gutter: auto,
at: auto,
stride: 1,
stagger: 0,
enter: "fade-up",
align: top + left,
)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.
| Name | Vorgabe |
|---|---|
..items | – |
columns | auto |
gutter | 14pt |
row-gutter | auto |
at | auto |
stride | 1 |
stagger | 0 |
enter | "fade-up" |
align | top + left |
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: white,
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), typography (
surface border invertedfont title-font size), the ordinary slide (
title-size weight trackingheader title-fill) and the two whole pictures (
rule-size rule-fill head-gap foot-gap band-height box footer
footer-rule progresstitle-slide, section).
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.
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.
| Name | Vorgabe |
|---|---|
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 | white |
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 |
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, travels, or is missing entirely.
Media and embeds#
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,
)Arbitrary web content in a sandboxed frame.
bridge names the element so step jobs can be sent to it: that is how a companion package such as typstage-geogebra drives its applet without the core knowing anything about it.
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.
| Name | Vorgabe |
|---|---|
url | none |
html | none |
width | 100% |
height | 200pt |
at | "1-" |
enter | "fade" |
bridge | none |
zoom | true |
style | true |
fallback | none |
link | none |
label | auto |
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.
| Name | Vorgabe |
|---|---|
render | – |
frames | 24 |
fps | 30 |
width | 200pt |
height | 150pt |
loop | true |
pingpong | false |
at | "1-" |
enter | "fade" |
still | auto |
video#
video(
src,
width: 100%,
height: 200pt,
poster: none,
autoplay: true,
loop: false,
muted: true,
controls: false,
radius: 0pt,
at: "1-",
enter: "fade",
)| Name | Vorgabe |
|---|---|
src | – |
width | 100% |
height | 200pt |
poster | none |
autoplay | true |
loop | false |
muted | true |
controls | false |
radius | 0pt |
at | "1-" |
enter | "fade" |
The bridge#
bridge-job#
bridge-job(target, payload, at: "1-")Send a job to a bridged element.
targetis thebridgename given toembed.atis a step selector, resolved exactly as foranim:autotakes 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.
payloadis 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.
| Name | Vorgabe |
|---|---|
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.
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.