Animations
Three properties drive everything the engine animates:
| Property | Runs when | Names |
|---|---|---|
animation |
a property's computed value changes between DOM rebuilds | all, or the property to scope to |
-azul-animation-in |
the node mounts | a @keyframes block or an attached function |
-azul-animation-out |
the node unmounts | a @keyframes block or an attached function |
There is no transition property. animation is where that job lives.
The shorthand
<name> <duration> [<delay>] [<timing>] [infinite | <count>] [no-clip]
animation: all 200ms ease-out; /* every change transitions */
animation: width 1s, background-color 2s; /* per-property scopes */
-azul-animation-in: slideIn 220ms spring;
-azul-animation-out: slideOut 180ms 50ms ease-in no-clip;
The first time value is the duration, the second the delay — CSS order. A list is read last-match-wins, so a later entry overrides an earlier one for the properties it covers.
Transitions
animation turns covered property changes into timed interpolations instead
of instant updates. The property list is read off the old cascade: adding
animation in the same rebuild that changes a value does not retro-animate
that change.
.row { background-color: #fff; animation: all 150ms ease-out; }
.row.selected { background-color: #dce8ff; }
Rebuild the DOM with selected on the row and the colour walks over 150 ms.
Change it again mid-flight and the animation retargets from its current
value rather than stacking a second run — rapid A→B→C stays smooth. A
transition on a paint-only property (colour, opacity, transform) patches the
display list; one on width or font-size re-runs layout per frame.
Presence: enter and exit
-azul-animation-in and -azul-animation-out animate a node's arrival and
departure. Both name a @keyframes block or a function attached to the node,
resolved in that order.
.toast {
-azul-animation-in: toastIn 200ms spring-snappy;
-azul-animation-out: toastOut 150ms ease-in;
}
An exit needs something to draw after layout has removed the node, so a
declared -azul-animation-out makes the engine retain the previous frame's
subtree for the duration — the zombie. Retention, catching an exit that
remounts mid-flight, and per-frame native animation functions are
Zombie Animations.
infinite on an enter track is how a spinner is expressed; on an exit it is
clamped to one run.
@keyframes
@keyframes toastIn {
from { transform: translateY(24px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes pulse {
0% { opacity: 1.0; }
50% { opacity: 0.4; }
100% { opacity: 1.0; }
}
Stops accept from, to, and percentages. The compiled track reads
transform (translate, translateX, translateY, scale, rotate),
opacity, width and height; percentage translations resolve against the
node's own rect. Other properties in a stop are ignored. When two blocks
share a name, the last definition wins.
Timing
ease (the default), linear, ease-in, ease-out, ease-in-out,
cubic-bezier(x1, y1, x2, y2), and three springs: spring,
spring-gentle, spring-snappy.
Springs settle on physics rather than on the clock — the declared duration is their retarget time base, not a stop watch. Reach for one when the animation can be interrupted (a panel the user re-opens mid-close); reach for a bezier when the motion has to land on an exact beat.
Driving an animation yourself
Motion that follows application state — a simulation, a game loop, a value
arriving over the network — belongs in a timer that mutates your model and
returns Update::RefreshDom per frame:
extern "C" fn animate(data: RefAny, info: TimerCallbackInfo) -> TimerCallbackReturn {
let mut state = data.downcast_mut::<State>().unwrap();
let Some(start) = state.anim_start else {
return TimerCallbackReturn::terminate_unchanged();
};
let end = start.clone().add_duration(&state.anim_duration);
let t = info.frame_start.linear_interpolate(start, end);
state.current_opacity = state.anim_from + (state.anim_to - state.anim_from) * ease_out(t);
if t >= 1.0 {
state.anim_start = None;
TimerCallbackReturn::terminate_and_refresh_dom()
} else {
TimerCallbackReturn::continue_and_refresh_dom()
}
}
Instant::linear_interpolate(start, end) returns the clamped 0..=1
fraction; layer easing on top. The layout callback then reads
state.current_opacity and writes it into the style it builds. See
Timers for scheduling, cancellation, and the 60 fps
pattern end to end.
Animating images, not the DOM
When the only thing that changes is pixels — a sprite sheet, a video frame, a
GL texture — info.update_all_image_callbacks() re-invokes every image
callback without touching layout or the display list.
Cross-references
- Zombie Animations: exit retention and native per-frame animation functions.
- Timers: the timer mechanics the manual path builds on.
- Reconciliation: the node identity that decides what counts as a mount, an unmount, and a move.