Precise animated presentation framework in Typst. Check the manual for details.
Examples
Click on the image to jump to the source code.
![]() |
![]() |
![]() |
![]() |
![]() |
Sanor provides a framework for creating highly animated PDF presentations by step-by-step reveal controls over each element in a Typst document.
Features
- Step-by-Step Reveals: Control content visibility with
pause()for narrative flow or tagged animations for interactive elements - Content Tagging System: Use
tag()to mark elements that can be animated, revealed, or transformed - Animation Rules: Apply transformations to the elements with
apply()(persistent),once()(single step),cover()(hide),revert()(reset), orclear()(remove prior modifiers) - Reusable Objects: Create components with
object()that can have multiple visual states via named cases - Cases System: Define semantic transformations with
case()that can be referenced by name instead of repeating properties - Predefined Components: Import
mcompsorccompsfor reusable object wrappers that integrate directly withtag(). - Simultaneous Actions: Coordinate animations across multiple elements by grouping rules in an array
- Handout Support: Generate static handouts from animated presentations with
set-option(handout: true) - Pdfpc Integration: Integrates Pdfpc functionality from Polylux package.
To demostrate these features, take a look at examples file.
Core Concepts
Tags & Rules
A tag marks content for animation. Rules define what happens to tagged content at each step:
tag("name", content)marks content with a unique identifierapply("name", ...)applies a transformation from now on (persists across steps)once("name", ...)applies a transformation for just this stepcover("name", ...)hides content by applying a hidden caserevert("name", ...)resets content to a base state without inheritanceclear("name")resets content to base state and clear all previous modifiers to base case.
Cases
A case is a named transformation that can be applied to content:
case(fill: red)— Styling properties that modify content appearancecase(text.with(weight: "bold"))— Wrapper functions that transform structure- Cases can be defined when creating objects for reuse:
object(text, red: case(fill: red)) - Reference cases by name:
apply("elem", "red")instead ofapply("elem", text.with(fill: red))
Objects
An object is a reusable component with built-in state management:
object(func, case1: case(...), case2: case(...))— Define multiple named cases- Objects cache transformations, so multiple
apply()calls accumulate effects - Use
revert()orcover()to change behavior between steps
Animation Workflow
- Define slide content using
#slide(s => ([
#let tag = tag.with(s)
// Your content goes here
], s))
- Mark elements with
tag("name", content)that you want to animate - Push animation rules with
s.push():- Single rule:
s.push(apply("name")) - Multiple rules at once:
s.push((apply("left"), apply("right"))) - Advance without animation:
s.push(1)
- Single rule:
- Each presentation step corresponds to one or more calls to
s.push()
Presentation Package Comparison
There are several Typst presentation packages, each with different strengths. Choose Sanor if you need fine-grained animation control and incremental content reveals.
- Touying: Sanor provides fine-grained animation controls that are applicable to any packages, not only CeTZ or Fletcher. Since Sanor does not inspect content, it can be used with any functions, even in
contextblocks. - Touying, Polylux: You can arrange the steps of the animation of each element without knowing the subslide index. Unlike traditional PDF presentation packages that animate contents based on either the subslide index or the position where they are put in the source code, Sanor separates the declaration and animation of the content: put the content in the code wherever you think it’s good, and then animate it later.
- Presentate: This functionality is closely related to Presentate’s
motionandtagfunctions. However, the framework provided there cannot interact well withpauseand has less flexibility (e.g., managing the showing state of the element). So, I implemented it here in a separate package, created specifically for animations.
Installation
Add the package to your Typst project:
#import "@preview/sanor:0.3.0": *
Quick Start
#import "@preview/sanor:0.3.0": *
#slide(s => ([
// A short hand to avoid repeating `s`.
#let tag = tag.with(s)
= Hello World
// Tag an element with a name.
#tag("title")[This is a presentation slide]
// Apply it on your slide.
#s.push(apply("title", text.with(fill: blue)))
], s))
Basic Examples
Basic Pause Example
Reveal bullet points one at a time:
#slide(s => ([
= My Points
#s.push(1)
#pause(s, [- First point])
#s.push(1)
#pause(s, [- Second point])
#s.push(1)
#pause(s, [- Third point])
#s.push(1)
], s))
Tagging and Animation
Mark content and apply transformations:
#slide(s => ([
#let tag = tag.with(s)
= Animated Content
#tag("title")[Hello World!]
#tag("subtitle")[Step-by-step animation]
// Step 1: Show title
#s.push(apply("title", text.with(size: 32pt)))
// Step 2: Show subtitle
#s.push(apply("subtitle", text.with(style: "italic")))
// Step 3: Make the title blue
#s.push(apply("title", text.with(fill: blue)))
], s))
Using Objects and Cases
Create a reusable component with named states:
#let fancy-box = object(
rect,
normal: case(width: 3cm, height: 2cm, fill: blue),
highlight: case(width: 3cm, height: 2cm, fill: yellow, stroke: black),
large: case(width: 5cm, height: 4cm),
)
#slide(s => ([
#let tag = tag.with(s)
#tag("box", fancy-box[Content])
#s.push(apply("box", "normal"))
#s.push(apply("box", "highlight"))
#s.push(apply("box", "large"))
], s))
Simultaneous Changes
Coordinate animations across multiple elements:
#slide(s => ([
#let tag = tag.with(s)
#grid(columns: 2fr, gutter: 1em)[
#tag("left")[Left item]
][
#tag("right")[Right item]
]
// Both appear together
#s.push((
apply("left", text.with(fill: red)),
apply("right", text.with(fill: green)),
))
// Both change together
#s.push((
once("left", text.with(weight: "bold")),
once("right", text.with(weight: "bold")),
))
], s))
Slide-Level Cases
Define global cases available throughout a slide:
#slide(
defined-cases: (
"error": case(text.with(fill: red, weight: "bold")),
"success": case(text.with(fill: green, weight: "bold")),
"highlight": case(block.with(fill: yellow.transparentize(80%))),
),
s => ([
#let tag = tag.with(s)
#tag("msg1")[Operation completed]
#tag("msg2")[Check the results]
#s.push(apply("msg1", "success"))
#s.push(apply("msg2", "highlight"))
], s),
)
Handout Mode
Generate a static version showing all steps:
// Enable handout mode globally
#let (slide,) = set-option(handout: true)
// Now all slides generate multi-page handouts with all steps visible
#slide(s => ([
#let tag = tag.with(s)
#tag("content")[This content evolves]
#s.push(apply("content", text.with(fill: red)))
#s.push(apply("content", text.with(weight: "bold")))
], s))
Inspiration and Possibilities
The inspiration for the Sanor package came from an amazing animation library for creating mathematical animations in Python called Manim. I always wanted to include such transformation of elements into Typst presentations. This is because Typst provides good defaults for laying out elements; I don’t need to specify coordinates or calculate much where to put something on a slide, and Typst packages are awesome (don’t you agree?). Moreover, animated PDF files can be opened anywhere. I just need a thumb drive and put it on any computer to present my slides. Therefore, based on the UI of Manim, I created this package.
Then, when I started creating some slides with it, I thought of a way to integrate this package with Tanim, a program that lets you create animations from Typst documents. Since the frame-by-frame specification is already implemented, the only remaining (VERY complex) task is to interpolate those discrete animations over a period of time. Since Typst HTML export is starting to mature, I think it is possible to upgrade this package into a tool for animated HTML presentations like Manim-Slides.
Change Log
- 0.3.0 Updated documentation for the new release.
- Added support for
tag(name, body)callbacks that receive a non-hidden tagging helper for nested tagging. - Changed internal representation of cases, rules, and objects. minor breaking change.
- Documented predefined component imports and usage via
components.markupandcomponents.cetz. - Updated installation examples and gallery reference snippets for version
0.3.0. - Added magic selector
selectfor a smart selector used with show rules.
- Added support for
- 0.2.1 Refractored the whole animation control system.
- The
slidefunction is now accepting a function that returns an array of content and slide contexts => ([body], s)breaking change. - The
slidecontrol is moved to a more favorable#s.push(rule)than thecontrolsargument, thuscontrolsargument is removed. breaking change. - The
hideris now named ashidden, representing the modifier when the element is hidden breaking change. - Introduced
casefunction that can accept more flexible modifiers. - Integrated with
pausefunction to incrementally show content without tags and control the flow of animation with#s.push(int). - Added
pdfpcmodule from Polylux/Touying to support pdfpc integrations. - Arguments of
slidefunction are renamed as follows:infotooptionsbreaking changedefined-statestodefined-casesbreaking change.
- The
- 0.1.0 First Release
License
MIT License - see LICENSE file for details.
Contributing
Contributions welcome! Please feel free to submit issues and pull requests.




