Animations
ZenitUI has two animation systems that coexist and complement each other:
- Transitions — implicit animations driven by state changes. You
declare them in
.zstylewithtransition:, and they automatically animate whenever a property changes between states. - Animations — explicit, time-driven sequences. There are two kinds:
- CSS animations declared with
@keyframesin.zstyle, triggered by the presence of theanimation:property. - Imperative animations built in C++ with
UIAnimation, triggered manually withplayAnimation(name).
Both use the same easing functions (EaseInQuad, EaseOutBack,
EaseOutElastic, …) and both are ticked once per frame during
Layout::update.
This document is the reference for the user-facing API. For the internal architecture (how CSS animations interact with the style resolver, how imperative tracks are applied), see Animation System.
1. Transitions
A transition animates any property when it changes between two style
states. The most common case is state transitions (:hover,
:pressed, :focus, :checked, :disabled), but the same mechanism
handles any style change on the node.
1.1 Declaring a transition
Button {
transition: background 0.25s ease-out;
}
Syntax: <property> <duration> <easing>.
<property>— a property name from ZStyle §4, orall.<duration>—0.25sor250ms.<easing>— one of the easing names.
Multiple properties, comma-separated:
.btn-primary {
transition: background 0.25s ease-out-quad,
scale 0.12s ease-out-back;
}
Now background and scale animate independently, with different
durations and easings:
.btn-primary:hover { background: #3296FF; scale: 1.15; }
.btn-primary:pressed { background: #505050; scale: 0.90; }
The .zstyle in the demo uses exactly this pattern.
1.2 all
transition: all 0.2s ease animates any property that changes. This is
convenient for prototyping but usually wasteful — you don't want to
interpolate padding, border-width, and font-size at 60 fps unless
you actually animate them.
.panel { transition: all 0.2s ease-out; }
1.3 Longhand forms
.element {
transition: background 0.25s ease-out, scale 0.12s;
transition-delay: 0.05s; /* applies to all specs */
transition-duration: 0.3s; /* overrides all durations */
transition-timing-function: linear; /* overrides all easings */
}
The longhand forms only take effect if a transition shorthand was
declared first on the same rule. Using transition-duration alone
logs a warning and is ignored. (This mirrors CSS, where the shorthand
must come first.)
1.4 Per-property defaults
Properties that aren't mentioned in any transition: spec do not
animate — they snap to their new value instantly.
/* Only background animates. */
Button {
background: #0079F1;
border-color: #505060;
transition: background 0.25s;
}
Button:hover {
background: #3296FF; /* animates over 0.25s */
border-color: #FDF900; /* snaps immediately */
}
1.5 What can be interpolated
Interpolation depends on the type of the property:
| Type | Behavior |
|---|---|
float |
Linear interpolation (using easing). |
Color |
Per-channel linear interpolation. |
Value |
Term-by-term. If both sides have the same units, they combine. If not, all units are present in the result. auto on either side causes a snap (no interpolation). |
Spacing |
Element-wise on all four sides. |
Enums (Align, Justify, Overflow, Position) |
Snap. No interpolation. |
ZIndex |
Snap. |
string (font, effect) |
Snap. |
TextureRef, FilterRef list, BoxShadow |
Snap. |
When a snap happens, the property jumps to the target value at the
beginning of the transition (t = 0 if the source is auto, otherwise
at the midpoint). In practice, if you mix auto and a concrete value,
the transition looks like a hard cut.
1.6 Transition and state
The transition fires whenever ComputedStyle changes between two frames
and there's a matching spec in transitions. The state machine
(:hover, :pressed, …) is the most common source, but any style change
that re-resolves the target style works — including inline changes:
btn->getInlineBase().scale = 1.2f; // triggers a transition if declared
This is what the demo does for Modal::show() and Modal::hide():
void show() {
Style vis;
vis.opacity = 1.0f;
setInlineBase(vis); // triggers a transition on opacity
}
1.7 Nesting
A transition on a parent doesn't cascade to children. Each node declares
its own transitions. The exception is ::part — see §4.
2. CSS animations (@keyframes)
A keyframe animation is a predefined sequence of style deltas. When a
node has animation: <name> <duration> ... in its style, the animation
runs.
2.1 Declaring a keyframe
@keyframes pulse {
0% { scale: 1.00; }
50% { scale: 1.18; }
100% { scale: 1.00; }
}
Frames use percentage timestamps, or from / to as aliases for
0% / 100%:
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
Frames are sorted by time at parse time, so you can write them in any order.
2.2 Properties allowed in a keyframe
Every property from ZStyle §4 is allowed. The most
common ones are opacity, scale, rotation, translate-x,
translate-y, colors, and radius.
Nested keyframes (a keyframe inside another keyframe) are not supported.
2.3 Auto-fill of missing properties
If a property is set in some frames but not others, the missing values are automatically filled from the nearest neighbor (previous frame first, then next). A warning is logged for each filled slot.
@keyframes example {
0% { scale: 1.00; }
50% { scale: 1.18; opacity: 0.5; } /* warning logged */
100% { scale: 1.00; }
}
Here opacity is auto-filled in 0% and 100% (from the 50% frame,
since it's the only one that has it). The warning helps catch typos —
in practice you'll almost always want to write all the values
explicitly.
2.4 Using an animation
.pulse {
animation: pulse 1.2s ease-in-out infinite;
}
.blink {
animation: blink 0.5s linear forwards;
}
.combo {
animation: fadeIn 0.3s ease-out,
glow 1s ease-in-out infinite alternate;
}
Syntax: <keyframe-name> <duration> [<easing>] [<iterations>] [alternate] [forwards].
| Token | Meaning | Default |
|---|---|---|
1.2s / 200ms |
Duration. | 1s |
ease-in-out |
Easing function. | linear |
infinite |
Repeat forever. | 1 iteration |
3 |
Number of iterations. | |
alternate |
Reverse direction every other iteration. | off |
forwards |
Keep the last frame's values after finishing. | off |
Multiple animations, comma-separated.
2.5 Longhand forms
.element {
animation: pulse 1.2s;
animation-delay: 0.2s;
animation-duration: 0.5s;
animation-timing-function: ease-out;
animation-iteration-count: 3;
animation-direction: alternate;
animation-fill-mode: forwards;
}
Same rule as transitions — the longhand forms only take effect if
animation was declared first on the same rule.
2.6 Overlaying
The animated values overlay the current ComputedStyle during
rendering. This means:
getStyle()returns the transition value, not the animated value.- The render path (
renderChrome,renderContent,drawChildren) sees the animated value throughAnim::overlayCssComputed.
Concrete implication: hit-testing uses the transition value, not
the animated value. A button that visually slides left during a
slideIn animation is still hit-tested at its un-animated position.
This is a known limitation — for most cases, it doesn't matter.
2.7 Triggering
A CSS animation runs as long as the animation property is set on the
node. To start/stop it, add/remove a class (or change the inline style):
btn->cls("btn-pulse"); // start the pulse animation
btn->removeFromParent(); // (there's no removeClass — see below)
There's no removeClass() in the current API. To stop a CSS animation,
you'd need to rebuild the node or override the inline style:
// Alternative: set an inline animation that overrides
btn->getInlineBase().animation = ...; // not supported via inline
In practice, the pattern is:
- Use
:hover,:checked,:pressedon a::partfor short pulses (see §4). - Use imperative animations for start/stop control (see §3).
2.8 Infinite animations
infinite iterations run until the node is destroyed or the animation
property is removed. The engine ticks them every frame, so they have a
small per-frame cost. Don't have dozens of simultaneously-animating
nodes — you'll see it in the frame time.
3. Imperative animations
An UIAnimation is a C++ object holding a list of tracks. Each
track animates one property from a start value to an end value, using
an easing function.
Imperative animations are triggered by name with playAnimation(name).
3.1 Creating an animation
auto anim = std::make_shared<UIAnimation>(0.4f); // 0.4s duration
anim->addTrack<float>(0.0f, 1.0f,
[](Layout* l, float v) { l->getInlineBase().opacity = v; },
TransitionFunction::EaseOutCubic);
anim->addTrack<float>(-30.0f, 0.0f,
[](Layout* l, float v) { l->getInlineBase().translateY = Px(v); },
TransitionFunction::EaseOutBack);
addTrack<T>(start, end, setter, easing):
- T — the value type (float, Color, Value, Spacing, …).
- start, end — the values.
- setter — a lambda void(Layout*, T) that writes the interpolated
value somewhere.
- easing — the transition function (defaults to Linear).
The setter typically writes to getInlineBase() — that's the mutable
style object that won't be overwritten by the CSS cascade.
3.2 Attaching to a node
modal->addAnimation("Intro", anim);
You can attach multiple animations to the same node, each with its own name:
node->addAnimation("fadeIn", fadeAnim);
node->addAnimation("slideIn", slideAnim);
3.3 Playing an animation
node->playAnimation("fadeIn"); // forward
node->playAnimation("fadeIn", true); // reverse
playAnimation sets the animation's playing flag and resets its
elapsed time if it's at the end (or at the start, when reversing).
You can play it again later — for example, from a button callback:
auto card = VStack();
card->addAnimation("enter", anim);
auto trigger = Btn("Animate", [card]{
card->playAnimation("enter");
});
3.4 The blocksInput flag
By default, UIAnimation::blocksInput = true. While the animation is
playing, the node and its subtree do not receive input events. This is
useful for animations that shouldn't be interrupted (modal intros,
loading sequences).
anim->blocksInput = false; // allow interaction during the animation
3.5 The onFinished callback
anim->onFinished = []{ std::printf("done\n"); };
Fires once when the animation reaches its end (in forward mode) or its start (in reverse mode). The callback is copied before being invoked, so it's safe to modify the animation map from within it (e.g. remove the node).
3.6 Reverse playback
node->playAnimation("Intro", true); // reverse
Reverse playback starts from the current elapsed time (or from the end
if the animation had already finished forward). It runs backwards until
elapsed == 0, then stops.
The Modal class uses this for hide():
void hide() {
setInteractive(false);
Style hid;
hid.opacity = 0.0f;
setInlineBase(hid);
content->playAnimation("Intro", true);
}
3.7 Multiple tracks and order
Tracks in a single UIAnimation all run in parallel — they all sample
the same elapsed and apply to their respective targets. To sequence
animations, use the onFinished callback to chain them:
anim1->onFinished = [node]{
node->playAnimation("anim2");
};
3.8 Example: modal intro
From the Modal implementation:
auto intro = std::make_shared<UIAnimation>(0.4f);
intro->addTrack<Value>(VH(100.0f), Value(0.0f),
[](Layout* l, Value v) { l->getInlineBase().translateY = v; },
TransitionFunction::EaseOutBack);
content->addAnimation("Intro", intro);
Then:
modal->show(); // calls content->playAnimation("Intro")
modal->hide(); // calls content->playAnimation("Intro", true)
The modal content slides up from below the viewport (100vh down),
settling at 0 with a small overshoot thanks to EaseOutBack.
4. ::part animations
Individual parts (slider tracks, checkbox marks, text-input cursors) can have their own animations. This is how the demo makes the slider knob "glow" when hovered.
4.1 Declaring
Slider:hover::knob {
animation: knobGlow 0.6s ease-in-out infinite;
}
Slider:pressed::knob {
animation: knobPop 0.2s ease-out;
}
The state (:hover / :pressed) is on the widget, the animation is
on the part. The part has its own style resolver (partTransitions)
that ticks independently from the node's.
4.2 How parts animate
The part's Style is resolved in StyleResolver::partFor, which:
- Collects all rules matching
Tag::part(with the current node state). - Runs the part's own transition (
partTransitionsmap). - Calls
Anim::applyCssToPart, which overlays the current keyframe values onto the part's style.
Then renderContent reads the part's style:
Style knobStyle = partStyle("knob");
Color knobColor = knobStyle.color.is_set
? knobStyle.color.value
: defaultColor;
r->fillCircle(center, radius, knobColor.withAlpha(op));
Note: partStyle("knob") triggers a re-tick of the part's animation
every time it's called. In practice, renderContent calls it once per
frame, which is what you want.
4.3 States on parts
Slider:hover::knob { ... }
Slider:pressed::knob { ... }
Checkbox:disabled::mark { color: #808080; }
The state is checked against the widget, not the part — a part doesn't have its own hover state.
4.4 Transition on parts
Parts support their own transition: declarations:
Slider::knob {
color: #3296FF;
transition: color 0.2s ease-out;
}
Slider:hover::knob {
color: #d6ff32; /* animates over 0.2s */
}
The part transition is independent from the node's transition. They use
the same TransitionSpec list format but are stored separately in
StyleResolver::partTransitions.
4.5 Built-in parts
| Widget | Parts |
|---|---|
Slider |
track, fill, knob |
Checkbox |
box, mark |
ProgressBar |
track, fill |
TextInput |
cursor |
To add a part to a custom widget, call partStyle("name") inside
renderContent and use the returned Style. See
Custom Widgets.
5. Animation timing and the frame loop
Animations are ticked in Layout::update, once per frame, before
the state machine runs:
bool localAnimBlocks = anim_.tickImperative(*this, dt); // imperative first
bool blockSubtree = ancestorBlocked || localAnimBlocks;
// ... state flags, callbacks ...
style_.tick(*this, dt); // CSS transitions
anim_.tickCss(*this, dt); // CSS keyframes
// ... children update ...
Key implications:
- Imperative animations run before the state machine. This means an
animation that changes
opacitywill affect the current frame's rendered output, but the node'sconfig.enabled/state.hoveredflags are computed with the animation's effect already applied. - CSS transitions run after the state machine. A
:hoverstate change resolves the new target style and starts a transition, which then ticks from that frame onward. - CSS keyframes run after transitions. If both a transition and a keyframe target the same property, the keyframe overlays the transition result at render time.
5.1 blocksInput and subtree blocking
If an imperative animation has blocksInput = true (the default), its
node and its subtree are marked as "blocked" for the current frame:
bool blockSubtree = ancestorBlocked || localAnimBlocks;
bool selfBlocked = blockSubtree || !config.enabled || (!config.interactive && config.blocksRaycast);
Blocked nodes don't update their hover/pressed flags, and their
descendants inherit the block. This is why Modal with blocksInput
prevents clicks on the underlying screen while the intro is playing.
To disable blocking:
anim->blocksInput = false;
5.2 onAnimationsFinished
A node's onAnimationsFinished callback fires once when it transitions
from "has active animations" to "has none":
node->onAnimationsFinished = []{ std::printf("all done\n"); };
"Active" means: imperative animations in play, CSS node animations,
or ::part animations. It's tracked with a per-node
internal_.hadLocalAnimationsLast flag.
This is useful for cleanup:
auto fade = std::make_shared<UIAnimation>(0.3f);
fade->addTrack<float>(1.0f, 0.0f,
[](Layout* l, float v) { l->getInlineBase().opacity = v; });
fade->onFinished = [node]{
node->removeFromParent();
};
Or, with onAnimationsFinished:
node->onAnimationsFinished = [node]{
node->removeFromParent();
};
Both work. anim->onFinished is per-animation; onAnimationsFinished
is per-node and fires when all animations (including CSS) are done.
6. Complete examples
6.1 Hover scale (transition)
.btn-primary {
background: #0079F1;
transition: background 0.25s ease-out-quad,
scale 0.12s ease-out-back;
}
.btn-primary:hover {
background: #3296FF;
scale: 1.15;
}
Nothing else needed. Hovering the button animates both properties in parallel, each with its own duration and easing.
6.2 Pulse (CSS keyframe, infinite)
@keyframes pulse {
0% { scale: 1.00; }
50% { scale: 1.18; }
100% { scale: 1.00; }
}
.btn-pulse {
animation: pulse 1.2s ease-in-out infinite;
}
auto btn = Btn("Pulsing", []{});
btn->cls("btn-primary")->cls("btn-pulse");
The button pulses forever until it's removed or the animation property is removed.
6.3 Slide-in on build
auto card = VStack();
auto anim = std::make_shared<UIAnimation>(0.6f);
anim->addTrack<float>(-20.0f, 0.0f,
[](Layout* l, float v) { l->getInlineBase().translateX = VW(v * 0.01f); },
TransitionFunction::EaseOutBack);
anim->blocksInput = false;
card->addAnimation("enter", anim);
card->playAnimation("enter");
The card slides in from the left over 0.6s with a slight overshoot.
The CSS equivalent:
@keyframes slideIn {
0% { translate-x: -20vw; opacity: 0.0; }
100% { translate-x: 0vw; opacity: 1.0; }
}
.slide-in-box {
animation: slideIn 0.6s ease-out-back;
}
The CSS version runs once and stops (no infinite); to replay it, you'd
need to rebuild the node or toggle the class.
6.4 Modal intro (imperative, with reverse)
auto modal = Modal::create();
modal->children[0]->addChild(Label("Hello"));
modal->children[0]->addChild(Btn("Close", [modal]{ modal->hide(); }));
parent->addChild(modal);
modal->show();
Modal::onBuild sets up the intro animation:
auto intro = std::make_shared<UIAnimation>(0.4f);
intro->addTrack<Value>(VH(100.0f), Value(0.0f),
[](Layout* l, Value v) { l->getInlineBase().translateY = v; },
TransitionFunction::EaseOutBack);
content->addAnimation("Intro", intro);
And show() / hide() trigger it forward / reverse:
void show() {
setInteractive(true);
Style vis; vis.opacity = 1.0f;
setInlineBase(vis);
content->playAnimation("Intro", false);
}
void hide() {
setInteractive(false);
Style hid; hid.opacity = 0.0f;
setInlineBase(hid);
content->playAnimation("Intro", true);
}
Two animations run in parallel: the modal's opacity is a CSS
transition (via setInlineBase), and the content's translateY is the
imperative one. Both are driven by the same dt.
6.5 Glowing slider knob (part animation)
@keyframes knobGlow {
0% { color: #d6ff32; }
50% { color: #ffffff; }
100% { color: #d6ff32; }
}
Slider::knob {
color: #d6ff32;
transition: color 0.2s ease-out;
}
Slider:hover::knob {
animation: knobGlow 0.6s ease-in-out infinite;
}
Hover the slider and the knob pulses between two colors. Move the mouse away and it settles back to the base color (via the part transition).
6.6 Chained imperative animations
auto node = VStack();
node->addChild(Label("Loading..."));
auto fadeIn = std::make_shared<UIAnimation>(0.3f);
fadeIn->addTrack<float>(0.0f, 1.0f,
[](Layout* l, float v) { l->getInlineBase().opacity = v; });
auto slideUp = std::make_shared<UIAnimation>(0.4f);
slideUp->addTrack<Value>(Px(30), Px(0),
[](Layout* l, Value v) { l->getInlineBase().translateY = v; },
TransitionFunction::EaseOutBack);
fadeIn->onFinished = [node]{ node->playAnimation("slide"); };
node->addAnimation("fade", fadeIn);
node->addAnimation("slide", slideUp);
node->playAnimation("fade");
The node fades in first, then slides up.
7. Interaction with other systems
7.1 With setEnabled(false)
Disabling a node doesn't stop its animations. The node's state machine
returns Disabled, but imperative animations still tick (unless
blocksInput prevents it — that affects input, not ticking). To
explicitly pause animations on disable, override onEnabledChanged:
void onEnabledChanged(bool nowEnabled) override {
for (auto& [name, state] : anim_.imperative)
state.playing = nowEnabled;
}
TextInput uses onEnabledChanged for a different purpose — clearing
its repeat state and cursor blink.
7.2 With removeFromParent()
When a node is removed from its parent, it's erased during the next
cullRemovedChildren pass. Any animations still running on it are
destroyed with it. If you want a fade-out before removal, chain the
removal to onFinished or onAnimationsFinished:
auto fade = std::make_shared<UIAnimation>(0.3f);
fade->addTrack<float>(1.0f, 0.0f,
[](Layout* l, float v) { l->getInlineBase().opacity = v; });
fade->onFinished = [node]{ node->removeFromParent(); };
node->addAnimation("fadeOut", fade);
node->playAnimation("fadeOut");
7.3 With .zstyle state rules
A CSS animation declared under :hover runs as long as the state is
active. When the state changes, the animation stops immediately —
there's no "finish the current iteration" behavior. This is different
from transitions, which interpolate back.
.btn:hover { animation: pulse 1s infinite; }
Hover → animation starts. Un-hover → animation stops, node snaps to its non-animated style.
If you need a graceful exit, use a transition instead:
.btn {
transition: scale 0.2s ease-out;
}
.btn:hover {
scale: 1.1;
transition: scale 0.6s ease-in-out;
}
7.4 With filters
A node with a filter: goes through the "layer" render path — its
subtree is rendered to a target, then filters are applied. Animations
on the node's own properties (scale, opacity) still work, but they
happen around the render target operation, not inside it. See
Render Pipeline for details.
8. Performance notes
- Transitions are the cheapest: they only tick when the target
style changes, and the tick itself is a set of
lerpPropcalls on the properties declared intransition:. - CSS animations tick every frame as long as they're running.
infiniteanimations never stop. Keep the count low. - Imperative animations tick every frame while
playing. Each track calls its setter once per frame. Tracks are cheap, but the setter writes togetInlineBase(), which triggers ainternal_.pendingTransitionflag — that then forces the node's style to be re-resolved. While an imperative animation is running, the resolver skips the CSS transition and snapscurrentStyleto the target, so the animated value is visible on the same frame. The cost is one style re-resolution per animated node per frame; for dozens of simultaneous animations this can become noticeable, so keep the count moderate. ::partanimations tick every timepartStyle(name)is called, which is once per frame duringrenderContent. They're essentially free unless you have hundreds of parts animating.- The "dirty" system: a node in an active transition or animation
keeps
internal_.subtreeDirty = 1, which prevents measure caching. Once all animations stop, the subtree can be cached again.
For 60fps budgets, the rule of thumb is: transitions and part animations are free, CSS animations are cheap, imperative animations are fine in the dozens, not in the thousands.
9. Pitfalls
CSS animation on a property that also has a transition.
The animation overlays the transition at render time. getStyle()
returns the transition value; hit-testing uses the transition value;
only the rendered output sees the animated value. Don't rely on
getStyle() to observe the animation.
animation set inline via getInlineBase() doesn't work.
The animations field of Style is Opt<std::vector<AnimationRef>>,
but applyStyleAttr doesn't have a branch for animation — the parser
does. To start/stop a CSS animation from C++, you need a class or a
.zstyle rule:
node->cls("pulse"); // ✅ if .pulse declares animation: pulse ...;
There's no removeClass in the current API. To stop a CSS animation
from C++, either rebuild the node or use an imperative animation.
Forgetting blocksInput = false on UIAnimation.
The default is true. If you animate a widget that should stay
interactive (e.g. a button that scales on hover, implemented as an
imperative animation), set blocksInput = false or the button won't
respond to clicks during the animation.
onFinished firing twice.
If an animation is played to completion, then played in reverse to
completion, onFinished fires each time. If you need it to fire only
on forward completion:
anim->onFinished = [node]{
// check some state to decide whether to act
};
There's no direction parameter passed to the callback.
playAnimation on a name that doesn't exist.
AnimationPlayer::play returns early if the name isn't in the
imperative map. No error, no log. Typos silently do nothing.
Reverse playback starting from the middle.
playAnimation(name, true) starts from the current elapsed time
(or from the end if the animation had already finished forward). If
you want it to always start from the end, you'd need to reset
elapsed manually — but there's no public API for that, since
imperative is a private member of AnimationPlayer that's
friend-accessible only from Layout.
Part animations don't inherit from node transitions.
A transition: on the node doesn't affect parts. Each part needs its
own transition: declaration under ::part.
Interpolating Value with different units.
Px(10) to Percent(50) interpolates term-by-term on the union of
units, which is often what you want, but the intermediate values can
look odd. auto on either side forces a snap. If you need smooth
interpolation, keep both endpoints in the same unit.
translate-x: -50% in a keyframe.
translate-x resolves against the node's own width. In a keyframe,
this is resolved at tick time using the node's current rect. If the
node's size changes during the animation, the translation value
changes with it. Usually fine, occasionally surprising.
Chaining via onFinished requires capturing the node.
anim->onFinished = [node]{ node->playAnimation("next"); }; captures a
shared_ptr to the node, which keeps it alive as long as the animation
object is alive. If the animation is stored on the node itself, you
have a reference cycle:
node->addAnimation("a", anim);
anim->onFinished = [node]{ /* ... */ }; // cycle!
Break it with a weak_ptr:
std::weak_ptr<Layout> weak = node;
anim->onFinished = [weak]{
if (auto n = weak.lock()) n->playAnimation("next");
};
The engine itself doesn't create these cycles — it's an issue only for your own callbacks.