This document is the user-facing reference for Candy's Typst DSL. Candy turns
.tyx X-sheets (standard Typst extended with candy directives) into self-contained
videos (MP4 / MKV / WebM) or SVG drafts.
A
.tyxfile is valid, standard Typst. Compiling it withtypst compilerenders the first frame of the animation (every object at its flow placement, everyplayblock visible, andanimate/pause/audioinert). The Candy Rust pipeline reads the same directives from the source AST and produces the full clip. So a single.tyxis simultaneously a normal Typst document and a Candy animation script.
#import "@preview/candy:0.1.0": *
#mobject("dot", circle(radius: 1cm, fill: blue))
#animate("dot", to: (4cm, 0pt), duration: 1000, easing: "linear")
#pause(duration: 500)Full documentation. This README is the quick reference shown on Typst Universe. The complete, restructured docs — a learn-by-doing Tutorial and a lookup Reference (directives, easing, counters, CLI, codecs, errors) — live in the repository's
docs/.
- Getting started
- Core concepts
- Directives reference
- Easing reference
- Counters &
ecval - Worked examples
# Default: H.264 in an MP4 container → dist/<stem>.mp4 (uses system ffmpeg; falls back to openh264 if unavailable)
candy build examples/dot_move.tyx
# AV1 in WebM
candy build examples/dot_move.tyx --format webm --codec av1
# SVG draft (one file per frame under .candy/<stem>/)
candy build examples/dot_move.tyx --format svg
# Animated GIF of every frame (looping) — no codec needed
candy build examples/dot_move.tyx --format gif
# Static PNG poster of the final frame
candy build examples/dot_move.tyx --format pngEvery directive is inert under standard Typst (it either returns body or none),
so you can iterate on layout with typst compile and render the animation with
candy build.
- Candy uses a millisecond timeline internally. The
--fpsCLI flag only sets the output frame rate; a 1000 ms slide at 30 fps yields ~30 frames, at 60 fps ~60 frames — the wall-clock duration is unchanged. #animate/#pause/#play/#transform/ …duration:arguments are expressed directly in milliseconds (default500). There is no frame-based timing — the scheduler works entirely in ms, and only the final rasterization samples that timeline at--fps.#subtitleand#ecnewlifetimes are expressed in milliseconds directly.- Object animations (see Object animations) additionally accept
timing:anddelay:to control how they are sequenced on the timeline. Scene animations and masks do not accept these (see Timing & sequencing).
#scene(name: "intro")[
// all mobjects & actions here
]scene marks a segment of the timeline. It does not size the canvas and does
not paint a background: the viewport comes from the global #show: candy
configuration (and any #set page), and the background is whatever the page under
the scene paints.
Scene semantics (how candy groups and shows content):
- Flat, never nested — a
#scene(...)call inside another scene's body is a parse error. There is only "switch scene", never "enter a sub-scene". Nesting is detected through the Typst AST, so import style is irrelevant. - Document structure — a document must be either a sequence of parallel
#scene(...)calls with no content at the document root, or root content with no#scene(...)call at all. Mixing the two is a parse error. - Implicit whole-document scene — with no
#scenecall, the entire document is a single scene. - Typst scope — a mobject /
play/subtitlebelongs to thescenewhose body encloses it (the scope in which it is evaluated). - One page per scene — a scene occupies one viewport. Content that overflows is
reported as
W018and clipped at rasterization; split it into more scenes instead. - Background — set it on the page under the scene, e.g.
#set page(fill: black)as the first line of the scene body. - Named scenes & switching —
#scene(name: "foo")can be jumped to with#scene-switch(target: "foo"). Anonymous scenes get auto-assigned names.
Unknown named arguments (including the removed width / height / bg) are a parse
error rather than being silently ignored.
// flat, sibling scenes: "outer" plays first, then "inner" takes over
#scene(name: "outer")[
#mobject("a", circle(radius: 1cm, fill: blue))
#animate("a", to: (4cm, 0pt), duration: 1000)
]
#scene(name: "inner")[
#set page(fill: black)
#mobject("b", square(size: 2cm, fill: red))
#animate("b", to: (3cm, 2cm), duration: 800)
]A mobject is an animatable object: a bare Typst block/element (circle(...),
text(...), [$E = mc^2$], an imported canvas(...), etc.). Its placement is
automatic — taken from where the body lands in the document flow. You never pass an
at: coordinate; instead you animate it relative to that home position.
An action (#animate, #blink, #morph, …) targets a mobject by its label
string and changes a transform (position / scale / rotation / opacity) over duration
milliseconds. Multiple actions on different targets run in parallel.
Layout & hidden mobjects. A mobject's flow placement is where body lands in
the document flow; ensure_flow measures that box by rendering every object once.
Mobjects that are temporarily not rendered at frame 0 — a #reveal / #typewriter
target before it has typed anything, a play block, or a transform target whose
content timeline starts as none — still reserve their flow box in the flow:
candy wraps them in Typst #hide[…] so the space is kept (and the hidden object gets a
correct flow_pos to be placed at once it appears) while later mobjects do not shift up
to fill the gap. Pure containers with no content of their own are the only objects
skipped. This means you can safely stack a reveal caption between two always-visible
shapes without the layout jumping when the text types in.
Object animations (the Object animations section below) accept two extra timing parameters that control how the animation is sequenced relative to the previous animation on the timeline. This mirrors 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). Only object animations accepttiming.delay:— an extra wait in milliseconds before this animation begins, on top oftiming(default0).
#animate("a", to: (4cm, 0pt), duration: 1000) // after the previous one
#animate("b", to: (0cm, 3cm), duration: 1000, timing: "with") // parallel with "a"
#animate("c", dx: 2cm, duration: 500, timing: "after", delay: 300) // 300ms after "b"Scene animations (scene / scene-switch / transition / camera / zoom-to) and
the mask (subtitle) do not accept timing / delay — they are laid out in
document order.
These are scene animations: they set up the canvas, switch between slides, or move
the whole view. They have no timing / delay.
Define a flat scene (a "slide"). See Scene / canvas for full
semantics. Scenes never nest and never size the canvas; under standard Typst this just
renders body inside the candy-configured page.
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; a positive value
animates the transition. Inert under standard Typst.
Mark a slide transition. kind: "cut" (instant, default), "fade" (crossfade),
"slide" (push). Only "cut" is fully implemented; the others are recorded for future
versions. 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, so they have no
timing / duration.
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. The group's rotation pivots about the figure's
centroid, so a ring of objects placed around a center spins in place.
#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: as described in 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")#appear(target, timing: "after", delay: 0) / #disappear(target, timing: "after", delay: 0) {#appear}
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
save slot. Mirrors mobject.save-state(). Inert under standard Typst.
#restore(target, slot: "default", duration: 500, easing: "smooth", timing: "after", delay: 0) {#restore}
Interpolate a mobject from its current state back to a previously saved state. Mirrors
Restore(mobject). Inert under standard Typst.
#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) {#indicate}
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. Inert under standard Typst.
Briefly scale a mobject up by factor and fade it toward transparent, then restore — a
"flash" attention effect. factor is a ratio (e.g. 200%, default enlarges by 100%). Mirrors
Flash. Inert under standard Typst.
#wiggle(target, degrees: 15deg, duration: 500, easing: "wiggle", timing: "after", delay: 0) {#wiggle}
Oscillate a mobject's rotation by ±degrees a few times, then return. Mirrors Wiggle.
Inert under standard Typst.
#set-color(target, color: black, duration: 1, easing: "linear", timing: "after", delay: 0) {#set_color}
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. 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. Inert under standard Typst.
#spiral-in(target, scale: 300%, rotate: 360deg, duration: 300, easing: "smooth", timing: "after", delay: 0) {#spiral-in}
Fly in from a scaled-up, rotated, invisible state to the flow position. Mirrors
SpiralIn. Inert under standard Typst.
#focus-on(target, factor: 125%, duration: 300, easing: "smooth", timing: "after", delay: 0) {#focus-on}
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. Inert under
standard Typst.
#fade-transform(from, to, duration: 300, easing: "smooth", timing: "after", delay: 0) {#fade-transform}
Crossfade two pre-registered mobjects: fade out from while fading in to. Both must be
registered via mobject. Mirrors FadeTransform (simple crossfade variant). Inert under
standard Typst.
#move-along-path(target, path, duration: 500, easing: "smooth", mode: "polyline", orient: false, timing: "after", delay: 0) {#move-along-path}
Move target along a polyline through path (array of (x, y) points in cm, absolute).
The scheduler generates a keyframe at each point, distributed across duration. Mirrors
MoveAlongPath (linear paths; arcs/beziers approximated as polylines). Inert under
standard Typst.
#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. Inert under
standard Typst.
#transform(target, to: none, duration: 500, easing: "smooth", timing: "after", delay: 0) {#transform}
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 Mobjects &
actions), 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")#audio(path, blocking: false, loop: false, volume: 1.0, slice: none, timing: "after", delay: 0) {#audio}
Insert a voice / audio track. Audio is an object animation, so it accepts timing
and delay. Inert under standard Typst (does nothing).
| 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 (default 500) as its own animation unit (a
block-level object, precisely 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 anothersubtitlein 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)
]A key-value store of animatable integers, referenced from mobject / subtitle bodies via
ecval(name). Standard Typst sees the integer seed; the candy pipeline steps the value
over time, shaped by the counter's easing.
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)easing: accepts a named curve or a custom spec:
Named curves (unknown names fall back to linear with a warning):
"linear", "smooth", "smoothstep", "smootherstep",
"quad-in" / "quad-out" / "quad-in-out",
"cubic-in" / "cubic-out" / "cubic-in-out" (aliases "ease-in", "ease-out",
"ease-in-out"),
"sin" (sine ease-out), "there-and-back", "wiggle", "lingering".
Custom specs (accepted by #animate, #subtitle, #ecnew, …):
expr:<math>— a mathematical expression int∈ [0, 1], e.g."expr: 1 - (1 - t)^3".bezier:x1,y1,x2,y2— a cubic Bézier control-point spec (CSS-style easing curve), e.g."bezier:0.25,0.1,0.25,1.0".
#animate("sq", to: (10cm, 0cm), duration: 2000, easing: "expr: 1 - (1 - t)^3")
#animate("dot", to: (0cm, 5cm), duration: 2000, easing: "bezier:0.25,0.1,0.25,1.0")The easing-counter module lets you drive Typst parameters (widths, radii, text content)
with live, animatable integers. The substitution happens in the Rust renderer, so the
same .tyx compiles under plain typst compile with the seed value.
#scene()[
#let r = ecnew("r", seed: 40, step: 1)
#mobject("dot", circle(radius: ecval(r) * 1pt + 1cm, fill: blue))
#animate("dot", to: (0cm, 5cm), duration: 2000, easing: "bezier:0.25,0.1,0.25,1.0")
#subtitle([r = #str(ecval(r))], position: "bottom")
#ecpause("r")
#pause(duration: 600)
#ecresume("r")
]| File | Demonstrates |
|---|---|
examples/dot_move.tyx |
mobject + animate + pause (the simplest clip) |
examples/box_anim.tyx |
absolute to: + absolute scale: |
examples/rotate_fade.tyx |
rotate: + opacity: + there-and-back easing |
examples/transform_demo.tyx |
#transform on shapes and formulas (keeps the label) |
examples/manim_features.tyx |
save_state/restore, wiggle, flash, indicate, appear/disappear |
examples/composite_demo.tyx |
blink, spiral-in, fade-transform, focus-on |
examples/full_demo.tyx |
move-along-path + morph + indicate |
examples/modules_demo.tyx |
ecnew/ecval, custom expr:/bezier: easing, subtitle, counter control |
examples/preview_demo.tyx |
external @preview package (cetz) resolved in-process |
examples/custom_page.tyx |
custom page size via #set page |
examples/play_demo.tyx |
#play beat-by-beat reveal |
examples/audio_demo.tyx |
#audio + visual sync |
examples/video_placeholder_demo.tyx |
#video placeholder box |
examples/zoom_transition_demo.tyx |
#zoom-to + #transition + #set_color |
examples/easing_showcase.tyx |
all named easings + custom expr:/bezier: |
examples/math_derivation.tyx |
long step-by-step equation derivation via #transform + #subtitle + #indicate/#flash + #typewriter |
examples/constellation.tyx |
#spiral-in stars + sequential line fade-in + #wiggle sparkle (nested night-sky scene) |
examples/geometric_construction.tyx |
step-by-step regular-hexagon construction + #group + #animate(rotate:) symmetry spin |
examples/camera_tour.tyx |
cinematic #camera pan/zoom/rotate tour + nested title-card scene + #transition |
examples/data_viz.tyx |
animated horizontal bar chart "race" via #transform (reused labels) + #indicate leader + #typewriter |
examples/orbit_demo.tyx |
#group ring spin + #track orbiting planet + #camera push-in (orrery) |
examples/projectile_demo.tyx |
nested title scene + #track parabolic flight + #ecnew/#ecval live timer + #camera push-in + #transform of the trajectory equation |
See each file in examples/ for the full, runnable source. Build any of them with:
candy build examples/<name>.tyxLicensed under either of
- Apache License, Version 2.0 (
LICENSE-APACHEor http://www.apache.org/licenses/LICENSE-2.0) - MIT license (
LICENSE-MITor http://opensource.org/licenses/MIT)
at your option.
This README is the quick reference. For the complete guide, see the repository docs:
- Tutorial — install, first clip, animation basics, transforms, scenes/camera/groups, subtitles & counters, output & codecs.
- Reference — directives, easing, counters, CLI flags, codec matrix, and the error model.
The Rust backend developer reference is in
rust/README.md.