Animation System
ZenitUI has three animation channels that coexist and are all ticked
from the same place (Layout::update):
- Transitions — implicit, driven by style changes between two
states. Handled entirely by
StyleResolver(see Style System §4). Not covered here. - Imperative animations — C++
UIAnimationobjects with explicit tracks, triggered by name viaplayAnimation(). - CSS animations —
@keyframesdeclared in.zstyle, driven by theanimation:property. There are two sub-channels: - Node CSS: applied to the node itself, ticked incrementally.
- Part CSS: applied to
::partstyles, ticked absolutely.
This document covers the imperative channel and the two CSS channels,
plus the shared primitives in Anim.
1. The data model
All animation types live in AnimPrimitives.hpp, split into three
groups: imperative, keyframe definition, and active-instance.
1.1 Imperative: UIAnimation and AnimState
class UIAnimation {
public:
struct TrackBase {
virtual ~TrackBase() = default;
virtual void apply(float raw_t, Layout* target) = 0;
};
template <typename T>
struct Track : TrackBase {
T start_val, end_val;
std::function<void(Layout*, T)> setter;
TransitionFunction transition;
void apply(float raw_t, Layout* target) override {
float ratio = getRatio(raw_t, transition);
setter(target, lerpProp(start_val, end_val, ratio));
}
};
float duration;
float delay{0.0f};
bool blocksInput{true};
std::vector<std::unique_ptr<TrackBase>> tracks;
std::function<void()> onFinished;
};
Track<T> is type-erased via TrackBase so different value types can
live in the same tracks vector. apply normalizes the raw progress
(raw_t in [0, 1]) through getRatio(raw_t, transition) before
interpolating.
struct AnimState {
std::shared_ptr<UIAnimation> anim;
float elapsed{0.0f};
float delayElapsed{0.0f};
bool playing{false};
bool reverse{false};
};
AnimState is the per-animation runtime state: which animation, how
long it's been running, whether it's currently playing, and in which
direction.
AnimationPlayer::imperative is an unordered_map<string, AnimState>
keyed by the name given to addAnimation.
1.2 Keyframe definition: Keyframe and KeyframeAnimation
struct Keyframe {
float t{0.0f};
Style delta;
};
struct KeyframeAnimation {
std::string name;
std::vector<Keyframe> keyframes;
};
A Keyframe has a normalized time (t in [0, 1]) and a Style with
the deltas — only the properties that are animated need to be set. The
Style is the same struct used everywhere else in the framework, so
every property is animatable.
KeyframeAnimation is the pure definition: no timing, no state. It's
stored in Theme::keyframes keyed by name, populated by the
@keyframes parser.
1.3 Active instance: ActiveCssAnimation
struct ActiveCssAnimation {
std::string name;
float elapsed{0.0f};
double startTime{0.0};
float duration{1.0f};
float delay{0.0f};
int iterations{1}; // -1 = infinite
bool alternate{false};
bool fillForwards{false};
bool finished{false};
TransitionFunction ease{TransitionFunction::Linear};
};
This is the runtime instance of a KeyframeAnimation. It carries the
timing (from the CSS animation: shorthand) and the current elapsed
time.
AnimationPlayer has two containers for these:
std::vector<ActiveCssAnimation> css;— for the node's own animations.std::unordered_map<std::string, std::vector<ActiveCssAnimation>> parts;— for::partanimations, keyed by part name.
2. The shared primitives (Anim namespace)
Four free functions in namespace Anim, declared in
AnimationPlayer.hpp and defined in AnimationPlayer.cpp.
2.1 syncRefs
void Anim::syncRefs(std::vector<ActiveCssAnimation>& anims,
const std::vector<AnimationRef>& refs,
double now)
{
// 1. Remove orphans
anims.erase(
std::remove_if(anims.begin(), anims.end(),
[&](const ActiveCssAnimation& a) {
for (const auto& r : refs)
if (r.name == a.name) return false;
return true;
}),
anims.end());
// 2. Update existing / add new
for (const auto& r : refs) {
bool found = false;
for (auto& a : anims) {
if (a.name == r.name) {
a.duration = r.duration;
a.delay = r.delay;
a.iterations = r.iterations;
a.alternate = r.alternate;
a.fillForwards = r.fillForwards;
a.ease = r.ease;
found = true;
break;
}
}
if (!found) {
ActiveCssAnimation a;
a.name = r.name;
a.startTime = now;
// copy r's fields
anims.push_back(std::move(a));
}
}
}
Aligns the active instance list against a list of references (the
animations field of a Style). Three cases:
- Orphan: an active animation whose name isn't in
refsis removed. - Existing: an animation whose name matches a ref has its timing fields updated from the ref (so a live edit of the shorthand takes effect without restarting).
- New: a ref with no matching active instance gets a fresh
ActiveCssAnimationwithstartTime = now.
The startTime parameter is double, coming from
UIContext::get().time. It's used for absolute ticking (parts).
Called from AnimationPlayer::syncCss (node CSS) and from
applyCssToPart (part CSS).
2.2 tickIncremental
void Anim::tickIncremental(std::vector<ActiveCssAnimation>& anims, float dt)
{
for (auto& anim : anims) {
if (anim.finished) continue;
anim.elapsed += dt;
auto it = Theme::get().keyframes.find(anim.name);
if (it == Theme::get().keyframes.end()) {
anim.finished = true;
continue;
}
bool fin = false;
(void)sampleActive(anim, fin);
if (fin) anim.finished = true;
}
anims.erase(
std::remove_if(anims.begin(), anims.end(),
[](const ActiveCssAnimation& a) {
return a.finished && !a.fillForwards;
}),
anims.end());
}
Used for the node's own CSS animations. Advances elapsed by dt,
then samples the animation to see if it's finished. Removes finished
animations that don't have fillForwards.
The elapsed += dt accumulation is why node CSS animations are called
"incremental" — they depend on dt being stable. A frame drop causes a
larger dt for that frame, which advances the animation further.
2.3 tickAbsolute
void Anim::tickAbsolute(std::vector<ActiveCssAnimation>& anims, double now)
{
for (auto& anim : anims) {
if (anim.finished) continue;
anim.elapsed = (float)(now - anim.startTime);
// ...
}
// ...
}
Used for part CSS animations. Instead of accumulating dt, it
recomputes elapsed from the absolute time. This makes part animations
insensitive to frame drops — they always progress at wall-clock rate.
Why the difference? Node animations are tied to the frame loop; if the frame takes 30ms instead of 16ms, the animation should still feel "1 frame slower". Parts are visual flourishes (glowing, popping) that look better at wall-clock rate. It's a small semantic difference, but intentional.
2.4 sampleActive
inline float sampleActive(const ActiveCssAnimation& a, bool& finished) {
finished = false;
float t = a.elapsed - a.delay;
if (t < 0.0f) return 0.0f;
if (a.duration <= 0.0f) { finished = true; return 1.0f; }
float total = (a.iterations < 0) ? -1.0f
: (a.duration * (float)a.iterations);
if (a.iterations >= 0 && t >= total) {
t = total;
finished = true;
}
float iter = std::floor(t / a.duration);
if (a.iterations > 0 && finished && iter > 0.0f) {
iter = std::min(iter, (float)(a.iterations - 1));
}
float local = (t - iter * a.duration) / a.duration;
if (a.alternate && ((int)iter % 2 == 1))
local = 1.0f - local;
return std::clamp(local, 0.0f, 1.0f);
}
Returns the local progress within the current iteration, [0, 1].
Sets finished if the total elapsed exceeds the total duration.
Handles:
- Delay: if
elapsed < delay, returns 0 (progress hasn't started). - Zero duration: immediately finished at
1.0. - Infinite iterations (
iterations < 0): never finished. - Alternate: on odd iterations, reverses the local time.
- Overrun: clamps to the last iteration's end when finished.
The iter > 0 clamp for finished non-infinite animations handles the
edge case where elapsed exactly equals total: without the clamp,
floor(t / duration) would point to the next iteration, which
doesn't exist.
2.4b sampleActiveCached
sampleActiveCached is a thin wrapper around sampleActive that
caches the result keyed on elapsed. It exists because on every frame
sampleActive is called twice per animation:
- In
tickIncremental(ortickAbsolute), to detect whether the animation just finished. - In
overlayCssComputed(oroverlayCssStyle), to computelocalTfor the keyframe evaluation.
Between those two calls, elapsed doesn't change (the tick advances
it, the overlay reads it). The cached result is therefore guaranteed
to be identical, and the second call is free.
inline float sampleActiveCached(const ActiveCssAnimation& a, bool& finished) {
if (a.cachedSampleElapsed == a.elapsed) {
finished = a.cachedFinished;
return a.cachedLocalT;
}
a.cachedLocalT = sampleActive(a, finished);
a.cachedFinished = finished;
a.cachedSampleElapsed = a.elapsed;
return a.cachedLocalT;
}
The cache fields on ActiveCssAnimation are mutable, because
sampleActive is logically a read-only query — it doesn't change the
animation's timing, only reports what the current state is.
The cache is invalidated automatically whenever elapsed differs from
cachedSampleElapsed. There's no frame counter involved: the elapsed
time itself is the cache key. This means the cache survives across
frames if dt == 0 (nothing changed), and is invalidated on the first
frame where the animation advances.
Note on the implementation vs the ROADMAP entry. The ROADMAP
originally proposed caching evaluateKeyframes, on the assumption that
it was evaluated twice per frame. Reading the actual code shows that's
not the case: evaluateKeyframes is only called from the overlay
phase, once per frame. The double call is on sampleActive, which is
cheaper but still measurable across many animations. The implemented
fix targets sampleActive, which is the actual hotspot.
2.5 evaluateKeyframes
inline Style evaluateKeyframes(const KeyframeAnimation& anim,
float localT,
TransitionFunction ease)
{
Style out;
if (anim.keyframes.empty()) return out;
if (anim.keyframes.size() == 1) return anim.keyframes[0].delta;
// Find the two keyframes surrounding localT
const Keyframe* k0 = &anim.keyframes.front();
const Keyframe* k1 = &anim.keyframes.back();
for (size_t i = 0; i + 1 < anim.keyframes.size(); ++i) {
if (localT >= anim.keyframes[i].t &&
localT <= anim.keyframes[i + 1].t)
{
k0 = &anim.keyframes[i];
k1 = &anim.keyframes[i + 1];
break;
}
}
float span = k1->t - k0->t;
float u = (span > 0.0f) ? (localT - k0->t) / span : 0.0f;
u = getRatio(std::clamp(u, 0.0f, 1.0f), ease);
#define X(T, name, def) \
if (k0->delta.name.is_set && k1->delta.name.is_set) \
out.name = lerpProp(k0->delta.name.value, k1->delta.name.value, u); \
else if (k0->delta.name.is_set) out.name = k0->delta.name.value; \
else if (k1->delta.name.is_set) out.name = k1->delta.name.value;
BUBBLE_STYLE_PROPS(X)
#undef X
return out;
}
Given a localT in [0, 1], finds the segment between two keyframes
and interpolates each set property. The output is a Style (with Opt
fields) — this will be overlaid onto the node's ComputedStyle later.
The BUBBLE_STYLE_PROPS macro expands to all ~45 properties. For each
property:
- If both endpoints have it set, lerp.
- If only one has it, use it (this is the auto-fill behavior from the parser's perspective; but note the parser already auto-fills missing properties, so in practice both endpoints usually have it set unless the animation is defined programmatically).
The easing is applied to u, not to the segment boundaries. So an
animation with ease-out-back will overshoot at the end of each
segment, not just the last one. This matches CSS.
2.6 overlayCssStyle / overlayCssComputed
void Anim::overlayCssStyle(Style& out,
const std::vector<ActiveCssAnimation>& anims)
{
for (const auto& anim : anims) {
auto it = Theme::get().keyframes.find(anim.name);
if (it == Theme::get().keyframes.end()) continue;
bool fin = false;
float localT = sampleActive(anim, fin);
if (fin && !anim.fillForwards) continue;
Style frame = evaluateKeyframes(it->second, localT, anim.ease);
out.overlay(frame);
}
}
Iterates the active animations, evaluates each keyframe, and overlays them in order. The last animation wins on conflicts.
Two variants:
overlayCssStyle(Style&, anims)— used inStyleResolver::partForto overlay CSS animations onto a part'sStyle.overlayCssComputed(ComputedStyle&, anims)— used inLayout::drawto overlay animations onto the node's render style.
Both call overlayComputed internally, which is the same macro-driven
"apply all set fields" as Style::overlay.
3. Imperative animations
3.1 AnimationPlayer::play
void AnimationPlayer::play(Layout& node, const std::string& name,
bool playReverse)
{
auto it = imperative.find(name);
if (it == imperative.end()) return;
auto& state = it->second;
state.reverse = playReverse;
state.playing = true;
if (!state.reverse && state.elapsed >= state.anim->duration)
state.elapsed = 0.0f;
else if (state.reverse && state.elapsed <= 0.0f)
state.elapsed = state.anim->duration;
state.delayElapsed = state.anim->delay;
}
Called from Layout::playAnimation, which is called by user code.
Behavior:
- If the animation isn't registered, do nothing silently.
- Set the direction.
- Set
playing = true. - Reset
elapsedonly if the animation is at the wrong end. If you play forward and the animation already finished, reset to0(restart). If it's in the middle, resume from where it was. - Reset the delay counter so the delay is applied again.
This means an animation that's already playing forward and gets
playAnimation(name, false) again does nothing visible — it just
continues. A reverse call on a finished-forward animation starts from
the end and runs backwards.
The delay is applied on every play, not just the first. If you want a
delay only on the first play, clear it after the initial call (there's
no API for this — set anim->delay = 0 from the callback).
3.2 tickImperative
bool AnimationPlayer::tickImperative(Layout& node, float dt)
{
bool localBlocks = false;
for (auto& [n, s] : imperative) {
if (!s.playing) continue;
if (s.anim->blocksInput) localBlocks = true;
if (s.delayElapsed > 0.0f) {
s.delayElapsed -= dt;
continue;
}
s.elapsed += (s.reverse ? -dt : dt);
bool justFinished = false;
if (s.elapsed <= 0.0f) {
s.elapsed = 0.0f;
s.playing = false;
}
if (s.elapsed >= s.anim->duration) {
s.elapsed = s.anim->duration;
if (s.playing && !s.reverse) justFinished = true;
s.playing = false;
}
float t = (s.anim->duration > 0.0f)
? (s.elapsed / s.anim->duration) : 1.0f;
for (auto& track : s.anim->tracks)
track->apply(t, &node);
node.internal_.pendingTransition = 1;
if (justFinished) {
auto cb = s.anim->onFinished;
if (cb) cb();
}
}
return localBlocks;
}
Called from Layout::update, before the state machine. Returns whether
any active animation has blocksInput.
Step by step for each animation:
- Skip if not playing.
- If
blocksInputis set, marklocalBlocks = true(this persists even if the animation is in its delay phase). - If in the delay phase, decrement
delayElapsedand skip the rest. - Advance
elapsedbydt, in the direction ofreverse. - Clamp at both ends. Set
playing = falsewhen reaching an end. - Set
justFinished = trueonly if finishing forward (reverse completion doesn't fireonFinished). - Compute
t = elapsed / duration. - Apply each track with
t. - Set
node.internal_.pendingTransition = 1, so the style system re-resolves. - If
justFinished, copy the callback and call it.
The callback is copied before being invoked, because the callback
might modify the imperative map (e.g. by calling removeFromParent
on the node, which doesn't affect the map, or by calling
addAnimation which does). Copying the std::function ensures the
call is safe even if the map is mutated during the callback.
3.3 hasActive and hasActiveLocal
bool AnimationPlayer::hasActive(const Layout& node) const
{
for (const auto& [n, s] : imperative)
if (s.playing) return true;
if (!css.empty()) return true;
if (hasActivePartAnims()) return true;
for (const auto& c : node.children)
if (c->hasActiveAnimations()) return true;
return false;
}
hasActive is recursive: it checks whether the node or any
descendant has active animations.
bool hasActiveLocal() const {
for (const auto& [n, s] : imperative)
if (s.playing) return true;
if (!css.empty()) return true;
for (const auto& [pn, v] : parts)
if (!v.empty()) return true;
return false;
}
hasActiveLocal is non-recursive: only checks whether this node
has active animations.
hasActiveLocal is used in Layout::update to fire
onAnimationsFinished when the node goes from "active" to "inactive".
hasActive is used by the public Layout::hasActiveAnimations().
Note that css is checked with !css.empty(), not "has any not-finished".
A finished animation with fillForwards = true is still in the list
(see §4.4), so hasActiveLocal returns true for it. This is correct:
fillForwards animations are visually active (they keep the final
keyframe applied), so the node can't be considered "done".
4. Node CSS animations
4.1 syncCss
void AnimationPlayer::syncCss(Layout& node)
{
const auto& refs = node.style_.targetStyle.animations;
Anim::syncRefs(css, refs, UIContext::get().time);
}
Called from Layout::update after any style resolution (state change or
pending transition). It syncs the active CSS animation list against the
animations field of the node's target style (not current).
Why target style? Because targetStyle is what the node "should look
like" now; currentStyle lags behind during a transition. Syncing
against the target means a new animation starts as soon as the target
style includes it, without waiting for the transition to complete.
4.2 tickCss
void AnimationPlayer::tickCss(Layout&, float dt)
{
Anim::tickIncremental(css, dt);
}
Called from Layout::update, after style_.tick. Node CSS animations
use incremental ticking — elapsed += dt.
4.3 When animations start and stop
Start:
- A style is resolved with a new
animation:shorthand. syncRefsadds a newActiveCssAnimationwithstartTime = now,elapsed = 0.- On the next
tickCss, it starts advancing.
Stop (natural):
tickIncrementalsamples the animation and finds it finished.- If
fillForwards == false, the animation is removed. - If
fillForwards == true, it stays in the list withfinished = true.
Stop (forced):
- A style is re-resolved without the
animation:shorthand. syncRefssees the ref is gone and removes the active instance.- The animation stops immediately, no fade.
There's no animation-play-state: paused equivalent. To pause an
animation, remove the animation property (or the class that carries
it) and re-add it. Restarting requires the same trick, plus the
syncRefs behavior of not resetting startTime for an existing name —
but if the instance was removed and re-added, startTime is fresh.
4.4 fillForwards
If fillForwards = true, a finished animation stays in the list
with finished = true. During the overlay, sampleActive returns
localT = 1.0 (the last keyframe), so the final animated values are
applied to the render style.
This means a forwards animation keeps the node looking like the last
keyframe until the animation: property is removed. Useful for
one-shot animations that should end on a specific state (fade-in
ending at opacity 1).
If fillForwards = false (the default), the animation is removed when
it finishes, and the node reverts to its non-animated style. This is
the correct behavior for infinite animations (which never finish) and
for animations that should "snap back" (like a pulse).
4.5 Where the overlay happens
Node CSS animations are applied at draw time, not at style resolution time:
void Layout::draw(float parentOpacity) {
// ...
ComputedStyle renderStyle = style_.currentStyle;
Anim::overlayCssComputed(renderStyle, anim_.css);
// ...
}
This means:
getStyle()returns the un-animated style.- Hit-testing uses the un-animated rect.
- Layout (
measure/arrange) uses the un-animated size. - Only the visual output reflects the animation.
This is a deliberate simplification. The alternative would be to apply
the animation to currentStyle in update, which would make
getStyle() reflect the animation but would force a re-layout every
frame for every animated node. For most animations (opacity, scale,
translate) that's not necessary. For animations of width / height
it would matter — but those are rare.
The consequence is documented in Animations §2.6:
don't rely on getStyle() to observe an animation's effect.
5. Part CSS animations
5.1 applyCssToPart
void AnimationPlayer::applyCssToPart(Layout& node, const std::string& partName,
Style& out)
{
const auto& refs = out.animations.is_set
? out.animations.value
: std::vector<AnimationRef>{};
auto& anims = parts[partName];
const double now = UIContext::get().time;
Anim::syncRefs(anims, refs, now);
Anim::tickAbsolute(anims, now);
Anim::overlayCssStyle(out, anims);
}
Called from StyleResolver::partFor, which is itself called from
partStyle(partName) inside a widget's renderContent.
Three steps:
- Sync: align the active list with the part's declared
animations(from the resolved part style). - Tick: advance elapsed by recomputing from
now - startTime. - Overlay: apply the current keyframe values to the part's
Style(out), which is then returned bypartFor.
Note that tickAbsolute is used here, not tickIncremental. The part
has no dt — it only knows the current absolute time and when it
started. So elapsed is recomputed each call.
5.2 The parts map
std::unordered_map<std::string, std::vector<ActiveCssAnimation>> parts;
Keyed by part name. Each entry is the active list for that part.
Entries are added lazily: the first applyCssToPart(node, "track") call
for a given node creates parts["track"]. Entries are never removed —
even if a part is no longer styled, its (empty) anims vector stays in
the map. This is a minor memory overhead, not a leak.
5.3 When parts tick
A part only ticks when applyCssToPart is called, which is when
partStyle(name) is called, which is when a widget's renderContent
(or renderChrome) runs. If a widget skips rendering a part (e.g.
Checkbox skips ::mark when unchecked), the part's animations don't
advance. When the part is drawn again, its elapsed is recomputed from
now - startTime, so it "catches up" instantly.
This is why absolute ticking is the right choice for parts: it doesn't matter when the calls happen, only the wall-clock time matters.
5.4 Part animation examples
@keyframes knobGlow {
0% { color: #d6ff32; }
50% { color: #ffffff; }
100% { color: #d6ff32; }
}
Slider:hover::knob {
animation: knobGlow 0.6s ease-in-out infinite;
}
Slider::renderContent calls partStyle("knob") every frame, which
calls applyCssToPart(node, "knob", knobStyle). On the first frame
where :hover is active, syncRefs sees a new knobGlow ref and
creates an active instance. On subsequent frames, tickAbsolute
advances it. The overlay applies the interpolated color to knobStyle,
which renderContent uses to draw the knob.
When :hover goes away, the rule stops matching, syncRefs sees the
ref disappear, and the active instance is removed. The part reverts to
its base style.
6. Interaction with the dirty system
Animations are one of the two reasons a node can't be cached (the other
is a pending state transition). The relevant logic is in
Layout::recomputeDirty:
bool inTransition = (style_.transitionTimer < 1.0f);
internal_.subtreeDirty = wasPending || internal_.pendingTransition || anyChildDirty ||
styleChanged || inTransition;
internal_.pendingTransitionis set by imperative animation tracks (they write toinlineBaseviagetInlineBase()), and by every other setter that touches the style.inTransitionis true while a CSS state transition is in progress.- CSS keyframe animations do not affect
internal_.subtreeDirtyby themselves.
A node with only a CSS keyframe animation (no transition, no pending
transition) doesn't set internal_.subtreeDirty from the animation. Its
subtree can be cached. The keyframe animation still renders, because
the overlay is applied at draw time on a local renderStyle copy of
currentStyle.
An imperative animation, by contrast, writes into inlineBase and
therefore sets internal_.pendingTransition every frame. This is what makes the
value visible: on the same frame, resolvePendingTransition re-resolves
the target style, sees the active imperative animation, and snaps
currentStyle to the new target (see
Style System §4.2). Without that snap the
transition timer would reset on every frame and currentStyle would
never advance.
6.1 onAnimationsFinished
bool anyLocal = anim_.hasActiveLocal();
if (internal_.hadLocalAnimationsLast && !anyLocal && onAnimationsFinished)
onAnimationsFinished();
internal_.hadLocalAnimationsLast = anyLocal ? 1 : 0;
internal_.hadLocalAnimationsLast is a per-node bool. It's true if the node
had any active local animation last frame, and false otherwise. The
callback fires on the falling edge: internal_.hadLocalAnimationsLast == 1
and anyLocal == false.
The check is local, not recursive — child animations don't count.
This means a container whose children are still animating won't fire
its own onAnimationsFinished until its children finish too. Correct
behavior for a "the subtree is done" callback, and it's documented.
7. Interaction with the style overlay
The interaction between CSS animations and styles is the subtle part.
Let's trace a node with a :hover { animation: pulse 1s infinite; }
rule:
7.1 Frame N: pointer enters
updateFlagssetsstate.hovered = 1.computeNextStatereturnsHover, state changed.beginStateTransitionresolves the new target style. This style includesanimations = [{name: "pulse", duration: 1, ...}].anim_.syncCss(*this)is called. It seesanimationsnow has"pulse", creates anActiveCssAnimationwithstartTime = now,elapsed = 0.style_.tickruns the state transition (interpolating from non-hover to hover style over the declared transition duration, if any).anim_.tickCssadvanceselapsedfor "pulse" bydt.- Draw:
renderStyle = currentStyle; overlayCssComputed(renderStyle, css). The overlay evaluates "pulse" atlocalT = elapsed / durationand applies the resultingStyleon top ofcurrentStyle. - The node is rendered with the pulsing values.
7.2 Frame N+1..M: hovering
Same as above, but elapsed advances. The animation runs to completion
(if finite) or continues forever (if infinite).
7.3 Frame M+1: pointer leaves
updateFlagssetsstate.hovered = 0.- State changes back to
Idle. beginStateTransitionresolves the non-hover target style. This style does not haveanimations = [{name: "pulse", ...}].anim_.syncCssseesanimationsis now empty.syncRefsremoves the "pulse" active instance.- Draw:
renderStyle = currentStyle(mid-transition). The overlay has nothing to apply. - The node reverts to its non-hover style, transitioning if a
transition:is declared.
The pulse animation stops immediately — no graceful finish. This is documented in Animations §7.3.
7.4 Overlapping animations
If the rule declared two animations (animation: pulse 1s, glow 2s),
both are in the animations vector. syncRefs creates two
ActiveCssAnimation. overlayCssComputed iterates them in order and
applies each. The last one wins on conflicts, which is what CSS
does.
There's no way to control the priority order other than the order in the shorthand.
7.5 Interaction with transitions
If a property has both a transition: and is in an animated keyframe:
- The transition runs on
currentStyle, from the old state to the new state. - The animation overlays the transition result at draw time.
So the property ends up with the animated value, not the transition
value. The transition still runs (visually invisible), and its timer
still reaches 1.0 (so inTransition eventually becomes false).
This wastes CPU on the invisible transition. To avoid it, don't transition a property you're also animating. In practice, this is rare.
8. Removing and re-adding animations
8.1 Removing an imperative animation
node->addAnimation("intro", anim);
node->playAnimation("intro");
// ...
// To remove:
// (no public API — you'd need to modify node->anim_.imperative)
There's no public removeAnimation on Layout. The imperative map
is a private member of AnimationPlayer, accessible only via
friend. To remove an imperative animation:
- Overwrite the same name with a new one (the old one is dropped).
- Remove the node itself.
- Add a subclass that accesses
anim_via the friend relationship.
In practice, imperative animations are rarely removed — they're small and inactive ones don't tick.
8.2 Removing a CSS animation
Remove the class that carries the animation: rule:
// No public removeClass() — see below
There's no removeClass in the current API. To stop a CSS
animation:
- Rebuild the node without the class.
- Add an inline style that overrides
animation:to an empty list — exceptapplyStyleAttrdoesn't handleanimation, so this doesn't work either. - Define the
animationunder a rule that can be deactivated (e.g..node.animating { animation: pulse 1s; }), and toggle.animating— except there's noremoveClassto remove it.
The realistic options are:
- Rebuild the node — replace it with a fresh one that doesn't have the animating class.
- Use an imperative animation — they're easier to stop (though
there's no
stopAnimationAPI either, but at least you can overwrite). - Use a state-driven animation —
:hover,:checked, etc. The animation runs only while the state is active.
The demo uses approach 3 for the slider knob glow. For most other cases, imperative is the way.
8.3 Re-adding an animation
Same as removing: rebuild, or use a state. If you want a "play again"
behavior, an imperative animation with playAnimation("name") resets
elapsed if it's at the end:
// Assuming "intro" already finished forward
node->playAnimation("intro"); // restarts from 0
This works because play checks:
if (!state.reverse && state.elapsed >= state.anim->duration)
state.elapsed = 0.0f;
So calling play on a finished-forward animation restarts it.
For CSS animations, the equivalent would require re-adding the class,
which isn't possible without a removeClass. So imperative wins for
repeatable one-shots.
9. Extending the system
9.1 Adding a new easing function
- Add the enum value in
Easing.hpp:
cpp
enum class TransitionFunction {
// ...
EaseInOutSine,
};
- Add the case in
getRatio:
cpp
case TransitionFunction::EaseInOutSine:
return -(std::cos(pi * t) - 1.0f) / 2.0f;
- Add a name in
parseEasing's table:
cpp
{"easeinoutsine", TransitionFunction::EaseInOutSine},
- Add a name in
Debug::toString(TransitionFunction)for dumps.
All three places use a switch or a table. Adding a function means adding one entry to each.
9.2 Adding a new animation pattern
If you want a custom animation behavior (e.g. a "shake" that's not
expressible as keyframes), you'd write an imperative UIAnimation with
tracks that use std::sin in the setter:
auto shake = std::make_shared<UIAnimation>(0.5f);
shake->addTrack<float>(0.0f, 1.0f,
[](Layout* l, float t) {
float x = std::sin(t * 20.0f) * (1.0f - t) * 10.0f;
l->getInlineBase().translateX = Px(x);
},
TransitionFunction::Linear);
The track's apply computes t (normalized progress), and the setter
can use any math. This is the escape hatch for animations that don't
fit the keyframe model.
9.3 Adding a new track type
UIAnimation::addTrack<T> is generic. It works for any T that has a
lerpProp overload. Available:
floatColorValueSpacing- Any enum (snap)
ZIndex(snap)std::string(snap)
To add a new type, add a lerpProp overload for it in Style.hpp. If
the type doesn't have a natural linear interpolation (like
TextureRef), you can snap (return t > 0.0f ? b : a;) or provide a
custom interpolation.
9.4 Interpolating non-Style data
A track's setter receives the node and the interpolated value. It can do anything with them:
anim->addTrack<Vec2>({0, 0}, {100, 0},
[](Layout* l, Vec2 v) {
// Update a custom field, call a function, whatever
},
TransitionFunction::Linear);
Vec2 doesn't have a lerpProp overload, so you'd need to add one.
Or use a float track and construct the Vec2 in the setter.
10. Performance
The three channels have very different costs.
10.1 Transitions
Cheap. They only run when a state changes, and the tick is a set of
lerpProp calls on the properties declared in transition:. The
number of properties is the cost per frame, not the number of nodes.
10.2 Imperative animations
Medium. Each playing animation runs a for loop over its tracks and
calls each track's setter. The setter typically writes to
getInlineBase(), which sets internal_.pendingTransition = 1, which forces
a style re-resolve on the same frame. So an imperative animation costs:
- The tick loop itself (
applyper track). - One
resolveForper frame for the node. - One
renderStyleoverlay per frame at draw time (no animation overlay for imperative, but the style re-resolve does happen).
For a few animations, this is fine. For dozens on the same frame, the style resolution becomes the bottleneck.
10.3 Node CSS animations
Medium. Each active animation runs sampleActive +
evaluateKeyframes per frame. evaluateKeyframes iterates all ~45
properties via the macro. That's the cost.
sampleActive and evaluateKeyframes are called in two places:
tickIncremental— to detect finish.overlayCssComputed— to apply at draw time.
So each active CSS animation does two full evaluations per frame. This
is a known inefficiency. If you have many CSS animations, you'd want
to cache the evaluation between tickCss and draw. The current
implementation doesn't.
10.4 Part CSS animations
Cheap-to-medium. Same as node CSS, but only when the widget calls
partStyle(partName), which is once per frame per drawn part.
Same double-evaluation issue applies.
10.5 Memory
imperativemap: oneAnimStateper registered animation.shared_ptrto theUIAnimation, which owns its tracks (which own their setters, which capture whatever the user captured).cssvector: oneActiveCssAnimationper running animation. Small (a string + a few floats).partsmap: one entry per part name per node that has part animations. Even empty entries take a slot.
There's no per-frame allocation in the tick path. tracks is a vector
of unique_ptr, iterated by reference. evaluateKeyframes returns a
Style by value — that's a stack allocation (a std::vector and a
std::unordered_map inside, but usually empty), so the cost is
minimal.
11. Pitfalls
onFinished fires only forward.
Reverse completion (via playAnimation(name, true)) doesn't fire the
callback. If you need reverse-completion notification, use
onAnimationsFinished on the node (which fires when all animations
stop, either direction).
onFinished is copied before being invoked.
If the callback replaces itself (sets anim->onFinished = ...), the
new callback takes effect on the next play, not the current one. This
is by design — it prevents mutation during iteration.
blocksInput persists through the delay.
If an animation has delay = 1s and blocksInput = true, the node is
blocked for the full second of delay. To let input through during the
delay, set blocksInput = false explicitly.
CSS animations don't re-start on state re-entry.
If a node has :hover { animation: pulse 1s; } and the pointer leaves
and re-enters within a few frames, the animation resumes from where it
was — because syncRefs for an existing name doesn't reset startTime.
To force a restart on each hover, the state must change to remove the
animation (which it does, since the state changes to Idle and the
animation is removed), and then re-add it (creating a fresh instance
with startTime = now). So in practice it does restart, but only if
the state fully changes. If you re-enter in the same frame, no.
hasActiveAnimations() is recursive.
It returns true if the node or any descendant has active animations.
Use it for "is this subtree done?", not "is this node done?". For the
latter, hasActiveLocal is the right check, but it's a private method
of AnimationPlayer (accessed via friend).
Node CSS animation value is not in getStyle().
As documented: only renderStyle at draw time sees the animated value.
Hit-testing, layout, and getStyle() see the un-animated value.
CSS animations tick twice per frame.
tickIncremental calls sampleActive and evaluateKeyframes to detect
finish; overlayCssComputed calls both again to apply. So each active
node CSS animation does ~90 property comparisons and up to ~90
lerpProp calls per frame. For an infinite animation, that's ~90
lerps × 60 fps per animation. Not catastrophic, but not free.
Imperative animation setters that write to non-style state.
anim->addTrack<float>(0, 1, [](Layout* l, float t) {
// Suppose you have a custom widget with a `progress` field
static_cast<MyWidget*>(l)->progress = t; // ⚠️ static_cast is unsafe
});
The Track template doesn't know the actual type of the layout — it's
Layout*. If you static_cast to your widget, you're trusting that the
animation is only ever played on a MyWidget instance. If you play it
on a plain Layout, you get undefined behavior. Use dynamic_cast for
safety (with the associated cost), or only use layout properties in the
setter.
getInlineBase() in a setter triggers a re-resolve.
l->getInlineBase().opacity = v;
getInlineBase() returns a mutable reference and sets
internal_.pendingTransition = 1. Even reading it (without writing) sets the
flag. So if your track setter reads the base to compute something, then
writes back, you pay for two re-resolves. Use getStyle() for reading,
getInlineBase() only for writing.
Actually, getInlineBase() does NOT read — it just returns a reference
and sets the flag. So calling it once per frame is one flag set. But
if you call it twice per frame (once to read, once to write), you set
the flag twice (idempotent, but the second call returns the same
reference, so it's really just the flag). It's not a real cost, but
worth knowing that merely calling getInlineBase() has a side effect.
The animate() shorthand in .zstyle doesn't exist.
It's animation: (with -i-), not animate:. A typo silently does
nothing (the property is "unknown" and logged as a warning). Check the
logs if animations don't run.
Keyframe names must be unique.
Theme::addKeyframes stores by name, so a second @keyframes pulse
overwrites the first. If you load two stylesheets that both define
pulse, the second wins. There's no namespacing.
animation-delay requires animation first.
If animation-delay: 0.5s appears without a preceding animation:,
the parser logs a warning and ignores it. The parser uses
st.animations.is_set to decide whether there's a target for the
longhand. Same for all the other longhands.