Zombie Animations
An exit animation animates a node that no longer exists. By the time the
layout callback has returned the DOM without it, reconciliation has already
unmounted it and layout has given its space to its neighbours — there is
nothing left to move. -azul-animation-out is what stops that: the node's
declaration makes the window retain the previous frame's subtree, and the
exit animates against that retained copy. The retained copy is the zombie.
.sidebar {
-azul-animation-in: slideInLeft 220ms spring;
-azul-animation-out: slideOutLeft 180ms ease-in;
}
@keyframes slideOutLeft {
from { transform: translateX(0); }
to { transform: translateX(-100%); opacity: 0; }
}
Retention is opt-in
A node without -azul-animation-out disappears the frame it unmounts — no
zombie, no retained tree, no engine-default slide. Departure animations are
something you or a widget library declare; nothing happens by default, and
the common unmount keeps its incremental layout entry untouched.
Retention also needs a resolvable name and a rect: a name that matches
neither a @keyframes block nor an attached animation function contributes
nothing, and a node that never laid out has nothing to animate from. Both
cases unmount instantly.
The zombie owns the frame it animates
When at least one exit resolves, the previous frame's layout result is moved out of the window's live results into the zombie — not cloned, not shared. Three consequences you can rely on:
- Hit-testing skips it by construction. Input walks the live layout results, and the zombie is no longer among them. Clicks land on the new tree; your callbacks never see the departing node.
- Scroll offsets freeze. The retained frame keeps the scroll positions it was showing, so a list that scrolls under a departing panel does not yank the panel's content with it.
- It is freed exactly once, when the track finishes.
An infinite exit is clamped to a single run — an exit that never ends would
never reap its zombie. infinite on -azul-animation-in is not clamped, and
is how a spinner is expressed.
Clipping
By default an exit is clipped to the rect it was retained at, so a
translating zombie cannot paint over the neighbours that have already taken
its space. The no-clip keyword turns that off for a declaration:
-azul-animation-out: flyAway 400ms ease-out no-clip;
A native animation function can override it per frame through
ZombieFrame::clip_to_frozen_rect.
Remounting mid-exit
Toggle a panel off and on again before its exit finishes and the exit is caught rather than restarted. The retained node is keyed by its reconciliation identity, so the remounted node is recognised as the same node: the zombie is dropped (no double image), and the live node travels home from wherever the exit had carried it, with velocity preserved.
The direction it travels home along is -azul-animation-in if the new
cascade declares one. If it does not, the engine reverses the out-track over
the time the exit had already spent, so the return runs at the pace the
departure did.
The mirror case works too: a node that unmounts mid-enter starts its out-track from wherever the in-track had carried it, instead of snapping to its laid-out position first.
Native animation functions
@keyframes covers declarative motion. When a frame has to be computed —
physics, a value read off the live DOM, motion that depends on how far the
last gesture threw the panel — attach a function to the node and name it from
CSS:
use azul::prelude::*;
extern "C" fn shrink_out(
_data: &mut RefAny,
_info: &mut TimerCallbackInfo,
z: &ZombieAnimInfo,
) -> ZombieFrame {
// `z.t` is RAW LINEAR progress; the engine does not pre-apply easing to
// native functions. `evaluate` applies the curve the CSS asked for.
let eased = z.timing.evaluate(z.t);
ZombieFrame {
translate_x: -z.rect.size.width * eased,
translate_y: 0.0,
opacity: 1.0 - eased,
width: OptionF32::Some(z.rect.size.width * (1.0 - eased)),
clip_to_frozen_rect: true,
}
}
fn sidebar() -> Dom {
Dom::create_div()
.with_class("sidebar".into())
.with_animation_callback(
"shrinkOut".into(),
ZombieAnimCallback { cb: shrink_out as usize },
RefAny::new(()),
)
}
.sidebar { -azul-animation-out: shrinkOut 300ms ease-in-out; }
The function lives on the node's own NodeData, not in app-global state: a
widget ships its fly-out next to its own DOM, and a library can hand you a
component whose motion travels with it.
Name resolution order: stylesheet @keyframes first, attached functions
second. The web mechanism stays the only default name source — a @keyframes shrinkOut block anywhere in the stylesheet shadows the attached
"shrinkOut". There are no engine builtins; an unresolvable name animates
nothing.
What the function receives
ZombieAnimInfo is the zombie-specific half:
| Field | What it is |
|---|---|
styled_dom, node_id |
The tree the animated node lives in — retained for an exit, live for an enter — and its index in that tree. Borrowed for the call: walk it, don't store it. |
rect, viewport, dpi_factor |
The retained rect for exits, the solved rect for enters; the viewport it was laid out in. |
t |
Raw linear progress, 0..=1. Not eased. |
timing |
The timing the CSS declared (ease, spring, cubic-bezier(…)). Apply it with timing.evaluate(t) or substitute your own curve. |
velocity_x, velocity_y |
Speed entering this frame, logical px/s. Reversing with continuity means emitting frames that start at this speed. |
The other half is a full TimerCallbackInfo — the same thing a timer
callback gets. The live DOM, the change queue, node measurement and the
momentum API are all reachable from inside an animation frame.
What it returns
ZombieFrame holds absolute values, not deltas:
translate_x/translate_y— logical px about the node's origin.opacity—0.0skips the node entirely for that frame.width— absolute painted width; left-anchored narrowing for exits.Nonekeeps the full width. Ignored for enters, where the live layout owns width.clip_to_frozen_rect— per-frame override of the CSSno-clipkeyword. Ignored for enters.
Cost
A zombie is a whole retained frame — its own layout result, its own text and
layout caches. Tracks that change width re-solve that retained tree per
frame; LayoutWindow::zombie_relayouts counts how often that happened, which
is the number to watch if an exit animation costs more than it should. Tracks
that only translate or fade never re-solve anything.
Cross-references
- Animations: the
animationand-azul-animation-*properties, timing keywords, and@keyframes. - Reconciliation: the identity that lets a remount recognise its own zombie.
- Timers:
TimerCallbackInfo, which every animation function receives in full.