Every directive is valid, standard Typst — under typst compile it either returns
body or none, so a .tyx is simultaneously a normal Typst document and a Candy
animation script. Argument tables list the directive's parameters and meaning.
All directives validate their argument types and enum values at compile time (via
panic); misuse fails loudly instead of producing an AST the Rust parser cannot
interpret.
Object animations accept two extra timing parameters that control how the animation is sequenced relative to the previous animation on the timeline (mirroring the Start options in the PowerPoint animation pane):
timing:—"after"(default) starts this animation once the previous one finishes;"with"starts it at the same time as the previous one (parallel).delay:— an extra wait in milliseconds before this animation begins, on top oftiming(default0).
Scene animations (scene / scene-switch / transition / camera / zoom-to) and the
mask (subtitle) do not accept timing / delay.
These are scene animations: no timing / delay.
Define a scene (a "slide"). See the Tutorial for full semantics. Under standard Typst
this sets the page and renders body.
Jump the timeline cursor to a named scene (target is the scene's name: or its
auto-assigned UUID-like name). duration: 0 is an instant jump. Inert under standard
Typst.
Mark a slide transition. kind: "cut" (instant, default), "fade" (crossfade),
"slide" (push). Only "cut" is fully implemented. Inert under standard Typst.
A global camera move (pan + zoom + rotate) applied to the whole scene. x / y are a
pan offset in cm from the page center; zoom > 100% magnifies; rotate tilts clockwise in
degrees. Scene-scoped. Inert under standard Typst.
#camera(zoom: 200%, x: -3cm, y: 1.5cm, duration: 1500, easing: "smooth")
#camera(zoom: 100%, rotate: 12deg, duration: 1500, easing: "smooth")Zoom-to-region: enlarge a rectangle of the canvas to fill the frame over duration
milliseconds. rect is (x, y, w, h) in cm, relative to the page origin. Implemented as
a scale + translate on all mobjects. Inert under standard Typst.
#zoom-to((4, 3, 6, 4), duration: 1000, easing: "smooth")These register or group animatable content; they are not animations.
Register an animatable object. label is a unique string id; body is a bare block or
element (never a string). Under standard Typst this simply renders body at its natural
position.
#mobject("dot", circle(radius: 1cm, fill: blue))Group several mobjects under a synthetic parent so they move / scale / rotate together.
Animate the name afterwards (e.g. #animate("g", rotate: 360deg)) to transform every
member at once. Groups may be nested.
#group("wheel", members: ("spoke1", "spoke2", "hub"))
#animate("wheel", rotate: 360deg, duration: 3000, easing: "linear")Insert a video reference as a placeholder mobject. Typst cannot embed video, so
Candy renders a labeled placeholder box (rounded rect + ▶ icon + filename). The
placeholder behaves like any other mobject body (can be animated). To show the real first
frame, extract it with ffmpeg and use #mobject("vid", image(...)) instead.
#mobject("clip", video("intro.mp4", width: 10cm, height: 6cm))
#animate("clip", scale: 120%, duration: 500, easing: "smooth")These target a mobject (or, for #audio, a media track) and accept timing: and
delay: (see Timing & sequencing). Under standard Typst they
are inert (return none), except where noted.
Animate target over duration milliseconds (default 500). Supports absolute and relative
transforms in any combination; each produces a parallel action.
| Argument | Meaning |
|---|---|
to: (x, y) |
absolute target point in lengths, e.g. (4cm, 0pt) |
dx: / dy: |
relative offset in cm (Manim-style shift), e.g. dx: 2cm |
scale: |
absolute scale factor (e.g. 150%) |
scale-by: |
relative scale multiplier (e.g. 150% grows 30%) |
rotate: |
absolute clockwise rotation in degrees (e.g. 45deg) |
rotate-by: |
relative rotation in degrees (e.g. 15deg adds 15°) |
opacity: |
target opacity as a ratio in [0%, 100%] (e.g. 50%) |
duration: |
length of the animation in milliseconds (default 500) |
easing: |
rate curve (default "smooth"; see Easing) |
timing: |
"after" (default) or "with" — sequencing vs the previous animation |
delay: |
extra wait in milliseconds before start (default 0) |
#animate("dot", to: (4cm, 0pt), duration: 1000, easing: "linear")
#animate("box", scale: 150%, duration: 800, easing: "smooth")
#animate("sq", dx: 2cm, rotate-by: 90deg, opacity: 50%, duration: 600, timing: "with")Make a mobject visible instantly (opacity: 100%) or invisible instantly (opacity: 0%), with no interpolation. Useful for appear/disappear-without-fading effects. Inert
under standard Typst.
Snapshot a mobject's current transform (x / y / scale / rotation / opacity) into a named
slot. Mirrors mobject.save-state().
Interpolate back to a previously saved state. Mirrors Restore(mobject).
#save-state("dot", slot: "home")
#animate("dot", to: (3cm, 2cm), duration: 800)
#restore("dot", slot: "home", duration: 200, easing: "cubic-in-out")#indicate(target, factor: 110%, dx: 0.0, dy: 0.0, duration: 300, easing: "smooth", timing: "after", delay: 0)
Briefly scale + shift a mobject, then return — a transient "look here" effect. factor is a
ratio (e.g. 110%, default enlarges by 10%). Mirrors Indicate.
Briefly scale up and fade toward transparent, then restore — a "flash" attention effect.
factor is a ratio (e.g. 200%, default enlarges by 100%). Mirrors Flash.
Oscillate rotation by ±degrees a few times, then return. Mirrors Wiggle.
Record a color change for a mobject. The renderer lerps the mobject's fill:/stroke: from
its current paint to color over duration milliseconds along easing, so the color animates
smoothly. Inert under standard Typst (the directive just records the change). Mirrors set-color.
#set-color("dot", color: red, duration: 300, easing: "smooth")Alternate opacity 1↔0 blinks times. Mirrors Blink.
#spiral-in(target, scale: 300%, rotate: 360deg, duration: 300, easing: "smooth", timing: "after", delay: 0)
Fly in from a scaled-up, rotated, invisible state to the flow position. Mirrors
SpiralIn.
Zoom in (enlarge) onto the target to emphasize it (default factor: 125% — enlarges by 25%; factor is a ratio, e.g. 125%, not a bare number). Mirrors FocusOn.
Crossfade two pre-registered mobjects: fade out from while fading in to. Mirrors
FadeTransform (simple crossfade variant).
#move-along-path(target, path, duration: 500, easing: "smooth", mode: "polyline", orient: false, timing: "after", delay: 0)
Move target along a polyline through path (array of (x, y) points in cm, relative to the object's flow layout position).
The scheduler generates a keyframe at each point, distributed across duration. Mirrors
MoveAlongPath (linear paths; arcs/beziers approximated as polylines).
#move-along-path("ball", ((2, 2), (6, 5), (10, 2), (14, 4)), duration: 2000, easing: "smooth")Morph one mobject into another by crossfading + scaling. Both must be registered via
mobject. This is the simplified Morph — true point-by-point morphing (Manim's
Transform) requires structured mobjects, which Candy's opaque-content model does not
support; the crossfade + scale variant is a reasonable approximation.
Morph a single mobject's content into new inline content — Candy's Manim-style
Transform / ReplacementTransform. target's current body is smoothly replaced by
to (a Typst body — a shape or a formula such as [$a + b + d = c$]), and the original
target label keeps the new content, so you can keep animating it afterwards.
For inline content (formulas and text) the transform is glyph-by-glyph, not a whole-block dissolve. Candy renders the old and new bodies with Typst's own SVG layout and extracts every glyph and decoration (fraction bars, roots, …) as a positioned fragment, then matches old↔new fragments by their outline signature (longest common subsequence). During the window:
- matched fragments glide from their old slot to their new slot,
- removed fragments fade and slide out toward the next kept glyph,
- inserted fragments fade and slide in from the previous kept glyph,
so the old equation visibly disassembles and reassembles into the new one. Fractions
(a/b, \frac{a}{b}) are kept intact as a single token so the fraction bar renders
correctly (stacked numerator/denominator, not a bare slash). For shapes (non-inline
content) the transform falls back to a crossfade + scale morph.
#mobject("eq", [$a + b = c$])
#transform("eq", to: [$a + b + d = c$], duration: 1000, easing: "smooth")Progressively reveal a string mobject by swapping its body to longer and longer prefixes
over duration. by: "word" reveals word-by-word; by: "char" reveals
character-by-character. Non-string bodies fall back to a plain FadeIn with a warning. The
body must be a string literal (#mobject("cap", "Hello")), not a content block. At frame
0 the target is none, but Candy keeps its layout box reserved (see
Tutorial · first clip), so
later content does not jump as it types in.
Convenience alias for #reveal(.., by: "char") — a classic typewriter reveal.
#mobject("cap", "Step 1: divide by a.")
#typewriter("cap", duration: 1500, easing: "linear")Drive a single target through several keyframes, each controlling a subset of its
properties — a timeline track that removes the need for many sequential #animates.
Mirrors a Manim ValueTracker-driven animation. keys is an array of
(t, (x, y, scale, opacity, rotation)) tuples, where t is the time offset (ms) from the
slide start and each inner value is optional (omitted properties carry their previous
value forward); x/y are in cm, scale/opacity/rotation unitless. A keyframe may
also be written flat as (t, x, y, scale, opacity, rotation).
#track("p",
keys: (
(0, (0cm, 0cm, 1, 1, 0)),
(1000, (3cm, 2cm, 1.5, 1, 90)),
(2000, (4cm, 0cm, 1, 0, 0)),
),
duration: 2000, easing: "smooth")Insert a voice / audio track. Audio is an object animation, so it accepts timing
and delay. Inert under standard Typst.
| Argument | Meaning |
|---|---|
path |
audio file (.opus/.ogg for WebM/MKV, .aac for MP4) |
blocking: |
if true, the timeline waits for the clip to finish |
loop: |
repeat the clip |
volume: |
gain in [0, 1] |
slice: |
optional (start, end) seconds sub-range of the clip |
timing: |
"after" (default) or "with" — sequencing vs the previous animation |
delay: |
extra wait in milliseconds before start (default 0) |
#audio("voice.opus", blocking: false, loop: false, volume: 0.9, slice: none)Show body for duration milliseconds as its own animation unit (block-level, controllable
like a mobject). play is a self-contained content block and does not accept timing
/ delay. Under standard Typst the body is shown in the first frame.
#play([#text(28pt, weight: "bold")[Step 1 of 3]], duration: 1000)
#play([#text(28pt, weight: "bold")[Step 2 of 3]], duration: 1000)Hold the current frame for duration milliseconds. Inert under standard Typst.
Overlay body (any Typst block content) on top of the animation. A subtitle is a
mask/overlay and does not accept timing / delay.
duration:lifetime in milliseconds.none(default) means persist — the caption stays until replaced by another#subtitlein the same Typst scope, or until that scope exits (auto-destroy). A positive number gives an explicit lifetime.position:anchor —"bottom"(default),"top","center","bottom-left","bottom-right","top-left","top-right", or a tuple(x, y)in cm for an absolute position.easing:rate curve for the caption's own fade (default"linear"). Custom modes"bezier:x1,y1,x2,y2"and"expr:<math>"are accepted.
Only one subtitle may be visible per Typst scope at a time; a later one replaces an earlier one. A subtitle in a parent scope is temporarily hidden while a child scope shows its own (shadowing).
#subtitle([Long-lived caption], position: "bottom")
#[
#subtitle([Child scope caption], position: "top", duration: 800)
#pause(duration: 800)
]Register an integer counter. Returns seed under standard Typst (so binding it captures
the initial value). With no duration, the counter steps once per millisecond; a positive
duration ramps seed → seed + step·duration over that window, shaped by easing.
Read the current value of an easing counter. Inside candy's pipeline it is substituted with
the live, eased integer and may be used directly as a Typst parameter
(rect(width: ecval(n) * 1cm)). Under standard Typst it returns its argument unchanged
when it is already a number, so bind the ecnew result (#let n = ecnew("n")) and pass
n.
Pause / resume / freeze a counter. Inert under standard Typst.
#let r = ecnew("r", seed: 40, step: 1)
#mobject("dot", circle(radius: ecval(r) * 1pt + 1cm, fill: blue))
#pause(duration: 600)
#ecpause("r")
#pause(duration: 600)
#ecresume("r")
#ecdestroy("r")Direction vectors (cm): dir-left dir-right dir-up dir-down dir-origin
dir-up-left dir-up-right dir-down-left dir-down-right.
Scale factors: grow (1.5) shrink (0.5) original (1.0).
Turns (degrees): quarter-turn (90) half-turn (180) full-turn (360).
Opacity presets: visible (1.0) half-visible (0.5) invisible (0.0).
#animate("dot", dx: dir-right.at(0) * 1cm, dy: dir-up.at(1) * 1cm, duration: 500)
#animate("dot", scale-by: grow, duration: 500)