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.

Showing instead of claiming#

Three ways to make a slide demonstrate something rather than assert it, from the most involved to the simplest.

A document of your own on the slide#

Note

If that document is itself a Typst document, none of this is needed. Give its content a name and fetch it:

// map.typ -- still compiles on its own, with its own page
#let map = [ ... ]
#set page(width: 16cm, height: 8.2cm)
#map

// talk.typ
#import "map.typ": map
== The map
#map

The set page stays behind, the slide keeps its geometry. What arrives is the deck’s own content: the same fonts, sharp at any size, part of the PDF, and revealable step by step. Under typst watch (see While you write) a save in map.typ rebuilds the deck and brings it back on the same step. A frame can do none of that.

YouTube videos can use embed(url: "https://www.youtube.com/embed/VIDEO_ID") or the www.youtube-nocookie.com domain. Presenter play/pause, timeline and j/k/l control the stage; the preview follows muted. The external YouTube API loads only when a YouTube embed becomes visible. Internet and a deck served over HTTP(S) are required; file:// can cause error 153. If autoplay is blocked, click Play on the stage once.

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.

Tip
Size everything inside in em. Inside a zoomed frame one CSS pixel is one point of the slide, so em scales with the slide and px does not. A page that reflows on its own wants zoom: false.

style: false drops the deck’s basic style where the embedded document brings its own. fallback stands on paper in the frame’s place, link is the address printed beneath it.

Sending it something on a step#

A frame with a bridge: argument gets a name; bridge-job sends it a dictionary when a step arrives:

#embed(html: "…", bridge: "lamp", width: 100%, height: 190pt)

#bridge-job("lamp", (color: "#16a34a"), at: 2)
#bridge-job("lamp", (color: "#eb5e28"), at: 3)

The package never reads a job; the document on the other side interprets it. That is how the ggb- commands drive their applets — see the chapter GeoGebra.

Careful

The document has to announce itself once with postMessage({typstage: 1, ready: 1}). Until it does, it gets 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.

Two frames sharing a name both receive every job, and the runtime says so in the console. bridge-targets() reports the names on the current slide.

Audio and media clips#

audio accepts a local file beside the HTML or a direct HTTP(S) audio URL. It starts manually by default. Audio, video and YouTube accept start and end in source seconds; loop: true repeats only that segment.

#audio("music.mp3", start: 30, end: 75, loop: true)
#video("film.mp4", start: 30, end: 75, loop: true)
#embed(url: "https://www.youtube.com/embed/M7lc1UVf-VE",
       start: 30, end: 75, loop: true)

The presenter has playback controls and a draggable timeline. k plays or pauses; j and l seek ten seconds within the segment. Shift+L changes the presenter’s light/dark appearance. YouTube requires HTTP(S) and internet access. Its control bar is hidden by default; ?controls=1 in its URL restores it. Titles, branding and stream quality remain controlled by YouTube.

A class timer can play an optional signal on the stage when it reaches zero:

#show: presentation.with(room: (
  clock: (step: 5, sound: "gong.mp3"),
))

sound accepts a file or direct URL; none keeps the timer silent. Browser audio permissions apply. Use speaker-view: (shortcuts: false) to start with shortcut help hidden; h or the ? button toggles it without reserving space.

Video#

#video("clip.mp4", width: 100%, height: 260pt, poster: image("still.png"))

The file travels beside the HTML, not inside it. autoplay, loop, muted and controls are the usual switches; poster stands there before it runs and takes its place in the PDF. The frame crops rather than stretches.

A video that ends on the bell#

A music video runs before the lesson, and it should stop at the moment the lesson begins. ends-at says not when it starts but when it is to be over:

#video("intro.mp4", width: 100%, height: 100%, muted: false, ends-at: "08:15")

On entering the slide the runtime reads the video’s own length and starts it far enough in that its last frame falls on that minute. Unlock the room at 08:11 and you get the last four minutes; arrive at 08:07 and you get the last eight.

The clock is the room’s, not the talk’s. To move a lesson from the first period to the third, room: (bell: …) is one line rather than one per video:

#show: presentation.with(room: (bell: "09:50"))

A video(ends-at: auto) then takes its time from there.

Careful
A video with sound does not start on its own – browsers allow that only muted. One click or keypress in the window releases it, and the next one runs.

A flip book#

flipbook lets Typst render the motion itself, frame by frame:

#flipbook(
  t => box(width: 100%, height: 100%,
    place(left + horizon, dx: t * 88%, circle(radius: 9pt, fill: accent))),
  frames: 24, fps: 20, width: 100%, height: 46pt,
)

The function receives t, running from 0 to 1, and is called once per frame. It can draw with anything Typst has, CeTZ and Fletcher included, and every frame sits in the file as SVG. This is the tool for motion Typst can draw and CSS cannot: a traced curve, a turning mechanism.

loop, pingpong and still decide how it plays and which frame stands on paper. A viewer who has asked for “reduce motion” never sees it play.

The clock starts when the flip book becomes visible: flipbook(at: "3-") lies on frame 0 until step 3, then plays from zero – again on every fresh reveal.

Careful
Every frame is typeset separately: twenty-four frames are twenty-four layouts and twenty-four SVG trees in the file. This is the most expensive element in the package. Reach for it only where the motion carries the argument.