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.

Developing a calculation#

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; the runtime pairs the glyphs at both ends and moves each one to its new place.

And on one slide#

A morph flies between two steps, and two steps of one slide count for as much as two slides. Use two calls of the same name with ranges that do not overlap:

== Completing the square

#statement[#morph(<sq>, $ x^2 + 6 x $, at: "1")]
#statement[#morph(<sq>, $ (x + 3)^2 - 9 $, at: "2-")]

The second call gives the slide its second step: with an at past step one, a morph counts like an anim.

The name also has to be free on the slide before: a morph that starts after step one may not share its name with one on the previous slide. The package says so while compiling. A chain counts as one: where one morph of the name stands from step one, the flight across the edge lands there, and the later ones follow on their own steps.

Tip
Two versions in the same place fly no distance at all, and all you see is the glyphs rearranging themselves – often exactly right. To see movement, put the two versions one above the other.

Two shorthands for the common case#

alternatives(morph: true) lets its versions fly into one another instead of replacing one another:

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

stagger(morph: true) is the chain where every line stays: the new line grows out of the line above, which stays put. Paging back takes the same way in reverse: the last line flies back into the one it grew out of instead of fading out.

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

Both take a name of your own instead of true. That is needed only where the flight carries on past the edge of the slide.

Careful
A morph has no entrance, so both refuse enter: and easing: rather than quietly dropping them, and stagger also refuses dim: – an argument alternatives does not have. duration: is read, and it is the time of the flight.

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.

"auto" pairs per glyph as long as neither side carries more than 120 glyphs, and moves the whole thing as one block above that. The limit is a question of looks and of cost: every glyph costs two ghosts, and a very long formula taken apart character by character reads as a swarm rather than as a movement. Measured in Chrome on a 1600-pixel stage: 51 glyphs fly without dropping a frame, at 121 glyphs one frame goes, at 261 the flight stalls for 117 ms. A deck of long formulas sets the limit itself:

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

Whatever carries a name travels regardless. A pin says outright that two pieces belong together, and that holds above the limit too: there the named pieces fly and everything else changes in place.

A pin may hold several glyphs – #pin(<s>, $sum_(i=1)^n$) – and they travel together: each glyph of the group finds its counterpart within the group on the other side, sigma to sigma and limit to limit. As long as the group is arranged the same way over there they move as one piece; where it is arranged differently, each glyph goes to its own new place.

Tip
"block" is the right answer more often than it looks. A picture or a table has no glyphs worth pairing, and per-glyph matching there gives a swarm rather than a movement.

Source order decides what lies on top, at rest and in flight: what is written after the morph lies above it. A caption need not wait for the picture to land.

When the wrong signs fly#

Where the pairing goes astray, name the pieces. Matching pin 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.

duration is 900 ms rather than the presentation’s, since a flight takes longer than a fade-in; auto falls back to the presentation’s value.

A morph is present from the first step, at both ends of a chain, since paging back swaps the roles. Only the first link may be delayed, because no flight arrives there; the package checks that at compile time.

Where the magic move stops#

Two targets on the same slide may share a name. Both then start from the same place, and the glyph visibly splits in two.

Careful
A morph is typeset a second time, in a frame of its own, and that frame never sees a #set rule written in the document. Shared typography belongs in style: on presentation. This is the most common reason for a flying equation in the wrong font.

How the slide itself changes#

transition decides how a slide comes in. The presentation sets the default, 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"))[…]
KindTakesWhat happens
"none"–A hard cut.
"fade"–A cross-fade, nothing moves.
"slide"fromThe new slide moves in a short way and fades up while doing it, the old one gives way in the other direction.
"push"fromThe new one pushes the old one over the edge.
"cover"fromThe new one lays itself over the old one, which stays.
"uncover"fromThe 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"directionA round aperture: "open" opens the new slide, "close" closes over the old.
"wipe"direction, fromThe same as a straight edge; from names the edge it starts at.
"flip"axisTurning over in space, like a leaf.
"cube"axisLike 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".

The transition belongs to the boundary between two slides, not to the direction of travel. What counts is the setting of the later slide; backwards it runs mirrored rather than again.

Where a morph meets the slide, it cross-fades. Otherwise the slide would push away the very object flying across it. A chain of transformations therefore needs no transition switched off by hand.

In the middle of a movement#

A reveal takes half a second, a flight close to one, a slide change a short half. Anyone presenting briskly presses the next key while that is still running, and it must not break anything.

Paging on interrupts without a jump: the new movement starts where the picture stands, not at the value the old one started from, and it gets the time the rest of the way is worth – interrupt at four fifths and you see the last fifth, not the full duration over again.

Paging back reverses. A running reveal, a running flight and a running slide change continue backwards instead of starting afresh: the ghost travels back along its path, the slide slides back where it came from. The time already spent is the time the way back still needs.

Note
Under prefers-reduced-motion: reduce there is nothing to interrupt: the picture changes without movement there.

When the content runs past the slide#

A slide is a viewport. The body is laid out on a canvas, and normally the two are the same size: what fits on the slide stands on it.

Where the content reaches further, the canvas grows with it – without a measure for it written anywhere. Two things make it grow:

A flow that runs on
a calculation continuing line by line, a list longer than the slide. The canvas grows downwards.
A place at the top level of the body
a box with dx: 780pt stands beside the slide. The canvas grows sideways.

In the talk the stage shows the viewport, and the view follows whatever is being revealed: as long as the new step is in sight the picture stands still; as soon as it would run out of the bottom, the view pans after it. The slide’s head – band or title line – does not travel along. It sits as its own layer above the canvas so the title stays put while the calculation passes under it.

== A calculation, step by step
#stagger(dim: true)[
  $ 3x + 5 = 20 $
][
  $ 3x = 15 $
][
  $ x = 5 $
][
  // … and so on, past the slide
]

On paper there is nothing to pan. There the whole canvas goes onto the page, fitted and centred, with a line underneath saying what happens in the talk: “canvas 1 × 1.29 slides, panned in the talk”. The same holds for the handout and for pages: "step" – a slide larger than its viewport stands complete in its frame there too.

Note

Only a place at the top level counts. One inside a box, a grid cell or an anim measures its offset against that container, and from the outside the two cannot be told apart. To put something beside the slide, write the place straight into the slide body. Its anchor counts: place(bottom +
right, dx: 20pt)
stands 20pt beyond the bottom right corner of the body, not 20pt beyond the top left one. A place without an anchor stands at its spot in the flow, which cannot be known from outside; it counts as top + left.

The overflow check still reports it. It asks whether the body runs past the viewport, and here it does. It is off by default (overflow: "none"); whoever switches it on wants exactly that answer. Where the panning is wanted, leave it off.