zgx

Animation

anime.js v4’s API on a Rust engine that ticks once per display frame and sleeps at rest.

The anime global follows anime.js v4’s API. Animations run in a Rust engine: one task ticks every playing animation once per display frame, in one reactive batch, and writes translation, opacity, scale, rotation and skew straight to the scene, once per element and frame. Other properties (colors, sizes, radii, text size) go through the node’s reactive style. Nothing runs while nothing plays: the engine asks for no frames at rest. Script code runs per frame only for callbacks a script sets (onUpdate, onRender), function eases and function modifiers. Methods work as anim.play() (as in JavaScript) and anim:play().

anime.animate(".card", {
    x = { 0, 240 }, opacity = 0.6, rotate = "1turn",
    delay = anime.stagger(60, { from = "center" }),
    duration = 900, ease = "outElastic(1, .5)",
    loop = true, alternate = true,
    onComplete = function(a) print("done") end,
})
anime.jsLuau
animate(targets, params)same; targets are selectors ("#id", ".class", "button", "button.primary", comma lists), refs, elements, states, plain tables, or lists of them
createTimeline(params)tl:add(targets, params, position), tl:add(timerParams, position), set, call, label, sync, remove; positions 500, "+=100", "-=100", "<", "<<", "<+=50", "label", "label+=50"; defaults
createTimer(params)endless unless given a duration
createAnimatable(targets, { x = 400, y = { duration = 600, ease = "out(3)" } })a.x(value, duration?, ease?) animates; a.x() reads
createDraggable(target, params)x/y (true, false, { snap }), container (selector, element or { top, right, bottom, left }), containerPadding, containerFriction, snap, dragSpeed, velocityMultiplier, minVelocity, maxVelocity, releaseStiffness, releaseDamping, releaseMass, cursor, onGrab, onDrag, onUpdate, onRelease, onSnap, onSettle; enable, disable, reset, revert, stop, setX, setY; x, y, isDragging, velocity
onScroll(params), as autoplaycontainer (default: the nearest scroll ancestor), target, enter/leave ("<container edge> <target edge>": start center end, %, points, +=/-=), sync (true, a smoothing amount, or an ease), repeat, onEnter, onLeave, onUpdate, onSync
stagger(value, params)ranges { -100, 100 }, units, start, from ("first", "center", "last", "random", index), reversed, ease, grid, axis, use, total, modifier
createSpring({ mass, stiffness, damping, velocity }), spring({ bounce, duration })an ease that sets the duration it settles in
eases.*, ease stringslinear, in(p), out(p), inOut(p), outIn(p), Quad to Quint, Sine, Circ, Expo, Bounce, Back(overshoot), Elastic(amplitude, period), steps(n, fromStart), cubicBezier(…), linear(…), irregular(…); functions; v3 names (easeInOutQuad)
createScope({ defaults })scope:add(fn), scope:revert()
text.split(text, params)returns a node: one text per character (chars) or word (words), with a class to animate; style styles each piece, props the row
svg.createMotionPath(d)SVG path data; returns translateX, translateY, rotate to animate along it; svg.getTotalLength(d)
utilsset, get, remove, cleanInlineStyles(targets, props?) (only the named properties when given; each returns to the node’s own value), random, randomPick, shuffle, round, clamp, snap, wrap, mapRange, lerp, damp, degToRad, radToDeg, roundPad, padStart, padEnd, createSeededRandom; called without the value, round returns a modifier that runs in Rust
enginespeed/playbackRate, fps, timeUnit ("ms" or "s"), currentTime, pause(), resume()
anime({ targets = …, … })anime.js v3’s form

Elements also animate blur (the content’s), maskX and maskY on the compositor.

Parameters: duration, delay, ease, composition ("replace", "none", "add"), modifier, loop (count or true), loopDelay, alternate, reversed, autoplay, frameRate, playbackRate, playbackEase, keyframes (a list of property sets, or { ["0%"] = …, ["50%"] = … }), callbacks onBegin, onBeforeUpdate, onUpdate, onRender, onLoop, onPause, onComplete. Property values: numbers, strings with units (px, %, rem, em, deg, rad, turn), relative ("+=10", "-=10", "*=2"), colors, strings with numbers in them, functions of (target, index, total), staggers, value keyframes ({ from, …, to }), and tween objects ({ to, from, duration, delay, ease, composition, modifier }), alone or in lists. Bare numbers for angles are degrees, as in anime.js.

Controls: play, pause, resume, restart, reverse, alternate, seek(time), complete, cancel, revert, reset, stretch(duration), refresh, and done(fn) (anime.js then, a Luau keyword; a["then"](fn) also works). Fields: id, currentTime (settable), progress (settable), iterationProgress, currentIteration, duration, iterationDuration, paused, began, completed, reversed, speed, labels.

Scripts create animations before the elements they target mount: an animation resolves its targets the first time it plays, and draggables and scroll observers attach once their targets mount. Draggables and scroll-linked animations live without a script handle, until their script is replaced.

The ui.tween(state, opts), ui.spring(state, opts) and ui.loop(opts) bindings remain for declarative transitions: a property bound to one eases whenever the state changes, with any anime.js ease. Their duration is in milliseconds, as anime’s is (ui.tween(open, { duration = 160 }); 250 by default, a loop’s cycle 1000).

Cost

Release builds need LUAU_CXXFLAGS=-DNDEBUG. Luau checks its asserts unless NDEBUG is defined, and the crate that compiles it for mlua never defines it: with them, every script loop, call and table access runs about a fifth slower (list_state_bench: 6.3 M instructions a tick against 5.0 M). This workspace sets it in .cargo/config.toml; an app built elsewhere sets it in its own:

# .cargo/config.toml
[env]
LUAU_CXXFLAGS = "-DNDEBUG"

A release build without it says so once on stderr and in ScriptHost::warnings(). Debug builds keep the asserts either way.

anime_bench animates 1,000 elements’ y and opacity every frame, headless, and times one frame (Apple M5 Max, release):

per frame
Luau + anime engineabout 90 µs
The same motion in Rust, a signal and reactive style per elementabout 240 µs
The same values written straight to the scene (no engine)about 120 µs

Writing to the scene directly is the floor for the work itself; the engine is below it because it skips values that did not change and writes a moving, fading element’s translation and opacity together, damaging each look once. What remains is the scene’s own invalidation.

script_layout_bench (cargo run --release -p zgx-script --example script_layout_bench) reverses 1,000 rows of three heights in a scroll view (same machine, release, mean of 20):

the changeits layout frameeach moving frame
ui.each0.36 ms0.25 ms(none)
ui.each with animate0.51 ms0.55 ms0.13 ms, nothing laid out or painted

The change reads every row’s place once, the frame that lays it out reads them again and starts one animation for all the rows that moved, and the frames after only composite. Once the rows settle nothing asks for a frame.

Keep hot paths out of the script. Work a script does on every frame of a resize or an animation is work Rust could do without it. Say what depends on the size, not what it comes to: a box placed by percentages and points (left = "40%", ml = 2, both insets of an axis to stretch between them), grow and basis, or layout = "size" is resolved by the layout engine on every frame, while the script runs only when what it describes changes. The terminal example’s split panes work this way: lib/layout.luau turns a split tree into each pane’s edges as a percentage of the content area plus points (ratios scale, gaps add), frames.luau gives those to the cards as insets with snap = true (whole-point edges, so 1-point borders stay crisp at any ratio), and resizing the window or easing the sidebar open runs no script at all; a change of structure eases cards with anime and hands them back with anime.utils.cleanInlineStyles. Moving it out of the script cut the frames of a sidebar animation from about 0.55 ms of Luau each to none. ScriptHost::script_calls() counts calls into scripts, so a test can hold a hot path to zero (resizing_the_window_moves_the_panes_without_running_script in tests/terminal_example.rs).

Rotation, scale and skew promote a node to a cached layer: its subtree is painted once into a texture, and each frame only composites that texture under a new matrix (no layout, no repaint). Scales re-rasterize at steps of √2, so a scale animation repaints a handful of times, not every frame. Each transformed node is its own texture and draw: thousands of them at once are bounded by the renderer’s layer budget (64 MiB, 1,024 textures). Hit testing follows the transform; positions inside widgets that track the pointer themselves (a slider’s thumb, a text caret) do not yet account for a rotated or scaled ancestor.

Edit on GitHub

On this page