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.

Your first presentation#

A complete, presentable talk in ten minutes.

One file is enough#

An import, a show rule, headings. This file is complete and can be typed out as it stands:

#import "@preview/typstage:0.2.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.

More than two levels#

By default = becomes a section slide and == a slide. slide-level moves that cut: a heading above it becomes a section slide, a heading at it or below it becomes a slide.

#show: presentation.with(title: [Analysis I], slide-level: 3)
= Part I -- Limits
== Sequences
=== What a sequence is
A map from the naturals into the reals.
=== Convergence
For every epsilon there is an N.
== Series
=== Partial sums
The sum of the first n terms.

= Part I and == Sequences each become a section slide, every === becomes a slide. Every section heading is its own transition slide, so there is nothing to switch on.

slide-level: 1 makes every heading a slide; the deck then has no structure level at all.

A heading written as a function call counts by its level like a typed one: #heading(level: 3)[…] as ===, #heading[…] without a level as =. A subheading that stays in the body and begins no slide goes into a block: #block(heading(level: 3)[…]).

The five bundled themes draw a deeper level more quietly: the title gets smaller, and above it stands what the section hangs under. What the deck knows about its structure is in info() – see “info(): what the deck knows about itself”.

Text that belongs to no slide#

A section slide is a whole picture the theme draws; it has no body. Text between a section heading and the next heading therefore belongs to no slide, and stops the compile instead of silently disappearing.

A sentence between = The proof and == The dissection aborts with content between the heading "The proof" and the next one belongs to no
slide
:

#show: presentation.with()
= The proof
This sentence belongs to no slide and stops the compile.
== The dissection

It belongs under the slide heading:

#show: presentation.with()
= The proof
== The dissection
This sentence belongs to the slide and gets typeset.

Text before the first heading is refused the same way, as long as the deck has at least one heading. Both rules apply to heading notation only.

When the slides are computed#

Headings created while the document is set become slides too. A loop over a list gives one slide per entry:

#for element in ("Water", "Air", "Earth") [
  == #element
  Something about #element.
]

Where the slides come entirely from data, hand them over one by one instead. Each slide is a function call, and a list of slides spreads with .. like any other array:

#presentation(
  title-slide(title: [The Pythagorean Theorem], author: [A. Schulz]),
  section[The proof],
  slide([The dissection], note: [Show the square first.])[
    The text of the slide.
  ],
)

Both spellings give the same output; presentation tells them apart from its arguments. The heading form is the ordinary case.

Two compilations#

The same file gives two outputs. The flags decide which:

typst compile talk.typ talk.html --format html --features html
typst compile talk.typ talk.pdf
Careful
Without --features html, HTML export is unavailable, and Typst’s error message reads like a mistake in your file rather than a missing flag. The feature is experimental in Typst itself, not in this package.

Looking at it#

The HTML is one file. Double-click it and it runs: no server, no network, nothing loaded afterwards.

Arrow keys page. ? shows a line with the main keys, o opens the overview, f goes full screen, and n opens the speaker view in a second window.

While you write#

That is the finished deck. While one is still taking shape, typst watch takes the compiling over: it rebuilds on every save, and for HTML it also brings a small server and puts one line into the page it serves, with which the browser reloads itself.

typst watch talk.typ talk.html --format html --features html --port 3000

Open http://127.0.0.1:3000 once and leave the tab alone. A deck of twenty-eight slides – four megabytes of HTML – is back about seventy milliseconds after a save, and it is back on the step it was on: the step stands in the address, and the deck reads it as it loads.

Note
--port may be left out; Typst then takes the first free port between 3000 and 3005 and prints it. Naming it keeps the address the same every time, and a second run on that port says port 3000 is already in use instead of quietly serving somewhere else.
Careful
The page the server hands out and the file beside your source are not the same. The reload line is ninety-seven bytes that only the server adds; the file never carries it, so what you pass on is untouched. --no-serve and --no-reload switch the two off separately.

In VS Code this is one keystroke. Put the following in .vscode/tasks.json, and the build shortcut – Shift+Cmd+B on macOS, Ctrl+Shift+B elsewhere – runs the watch for whichever file is in front of you; errors land in the problem list with a line to click.

{
  "version": "2.0.0",
  "tasks": [{
    "label": "Deck live",
    "type": "shell",
    "command": "typst",
    "args": ["watch", "${file}",
             "${fileDirname}/${fileBasenameNoExtension}.html",
             "--format", "html", "--features", "html",
             "--root", "${workspaceFolder}", "--port", "3000"],
    "isBackground": true,
    "group": { "kind": "build", "isDefault": true },
    "problemMatcher": {
      "owner": "typst",
      "pattern": [
        { "regexp": "^(error|warning): (.*)$", "severity": 1, "message": 2 },
        { "regexp": "^\\s*┌─ (.+):(\\d+):(\\d+)\\s*$",
          "file": 1, "line": 2, "column": 3 }
      ],
      "background": {
        "activeOnStart": true,
        "beginsPattern": "compiling \\.\\.\\.",
        "endsPattern": "(compiled |is already in use)"
      }
    }
  }]
}