pixelsculp module pixelsculp_anim

Animation System (pixelsculp_anim)

A typed, deterministic, dependency-free animation engine for OpenGL 2D interfaces. Physical springs, tweens with CSS curves, decay flings, keyframes and choreographed sequences.

General information

pixelsculp_anim is a Zig package consumed on its own that does exactly one thing: animate typed values. You tell the engine "this field of my struct goes from where it is to this target", and on every frame it leaves the interpolated value written in that field. Your render code takes no part in any of this: it reads the variable and draws.

It neither renders nor measures time; you pass in the delta from your render loop. Those two restrictions buy something interesting for free: almost any numeric property of your objects (positions, opacities, colors, radii, angles, scroll offsets…) animates through the same mechanism.

CapabilityToolTypical case
Realistic physical motion with bounceSpringParams + Animator.spring_toA button that sinks and returns, panels arriving with overshoot
Deterministic timing-curve transitionsTweenSpec + CurveOpacity fades, slides with ease_out
Drag inertia that switches itself offAnimator.decay_fromTouch scroll fling
Multi-point paths with per-segment easingTrack(T)@keyframes: complex traversals
Step-by-step choreographyAnimator.sequence_toToasts: enter → wait → leave
Global time controltime_scale, pause_allSlow motion, debugging, game pause

Animation fundamentals

This section is the on-ramp if you have never programmed animations. It explains what animating means, how a computer does it, and every technical term with analogies and numbers. If you already know the concepts, jump straight to Quick start.

What does "animating" mean to a computer?

A screen only knows how to show still images: the computer never moves anything. What we call animation is a film trick older than computers themselves.

The flipbook analogy. Draw a ball at the bottom corner of a notebook; on the next page, draw it slightly higher; repeat until the last page. Flip the pages fast and your eyes see a ball rising. Each drawing is a frame: one still image. The illusion comes from showing many frames in quick succession.

MediumFrames per second (FPS)
Old cinema24
Regular screen60
High refresh rate monitor120–144

At 60 FPS your program redraws the whole interface 60 times per second — once every ~16 milliseconds. Animation boils down to a single idea:

The golden rule. Change a number between each draw, little by little, so the object seems to move smoothly. That number is the attribute: a button's x position, its opacity (0 = invisible, 1 = solid), its size, its color, its angle.

Interpolation: computing the path between two points

You want to move a button from x = 100 to x = 500. You know the start and the end; what's missing is where to put it on each intermediate frame. Computing those points is called interpolating, and the simplest formula is the lerp (linear interpolation):

current_value = start + (end − start) × t

where t ∈ [0..1] means "how far along am I?"
  t = 0   →  at the start       (button at x = 100)
  t = 0.5 →  halfway            (button at x = 300)
  t = 1   →  destination reached (button at x = 500)

The trip analogy. You drive from Madrid to Barcelona. t is your answer to "how much of the way have I covered?": with t = 0.25, you are a quarter into the trip. The formula just turns "trip progress" into "position on the map". Nothing more.

Where does t come from? The clock and dt

Someone must tell you your progress. In real life, the clock: if the animation lasts 1 second and 0.4 have elapsed, then t = 0.4 / 1.0 = 0.4. Your library works like this:

  1. You call animator.update(dt) once per frame.
  2. dt (delta time) is how long the previous frame took — typically 0.016 s.
  3. The animator adds that time to its internal counter and computes t.
  4. It writes the resulting value straight into your variable (button.x = 347.2). Your drawing code only reads it.

Technical term — deterministic. The library never consults the system clock: you hand it the time. If you record the dt values and replay them, you get exactly the same animation — like a recipe that always bakes the same cake from the same ingredients.

Easing curves: the personality of motion

If you use t as-is, the motion moves at perfectly constant speed and looks robotic, like a cheap elevator. The car analogy: when pulling away you accelerate (slow→fast); on arrival you brake (fast→gentle). Nothing natural travels at constant speed right off the bat.

Smoothing motion is called easing: instead of using t directly, you pass it through a mathematical function that distorts it. With t = 0.5, the value no longer necessarily advances 50% of the way — it depends on the curve. These are the five families offered by pixelsculp_anim, plotted (value vertical, time horizontal):

time 0 1
linear. Constant slope: same speed for the whole trip. It looks mechanical. Useful for counters, wheels or honest progress bars.
time 0 1
ease_in. The car pulling away: hesitant at first, then charges at the end. For things that leave the screen or vanish while accelerating.
time 0 1
ease_out. The car braking: instant response and an elegant landing. The queen curve of UI: menus, tooltips, entrance fades.
time 0 1
ease_in_out. The bullet train: accelerates, crosses fast, brakes. For traveling between two points that are both visible on screen.
time 0 1
steps(n). The digital staircase: discrete jumps with no transition, like an LCD clock or retro game sprites.
time 0 1
custom bézier. The family that contains them all: two handles (green points) pull the curve, just like Figma's pen tool or CSS's cubic-bezier(). This is the material-emphasized preset.

Springs: real physics, bounce included

No fixed curve reproduces the bounce well. That's what springs are for: they simulate actual physics.

The weight-on-a-spring analogy. Hang a spring with a weight, pull it down and let go: it rises, overshoots its resting point, comes back, overshoots less, and oscillates ever more gently until it settles. That's exactly what you see when a button "bounces" when released:

target time 0 overshoot settle
A lightly damped spring: overshoots the target, returns, overshoots again by less each time, and settles right on the dashed line.

Three knobs control the behavior:

KnobAnalogyWhat it controls
stiffnessSpring hardnessHigh = nervous and fast; low = floaty and lazy
dampingCar shock absorberLow = lots of bouncing; high = barely oscillates
massHanging weightHeavy = slow and solemn; light = nimble
// Physical form: direct constants.
try an.spring_to(f32, &scale, 1.0, .{ .stiffness = 380, .damping = 26, .mass = 1 });

// Descriptive form: ask questions in plain language.
// "how long until it settles? how much does it bounce?"
try an.spring_to(f32, &scale, 1.0, anim.SpringParams.duration_bounce(0.35, 0.25));

The presets snappy_spring / bouncy_spring / gentle_spring package ready-made personalities (see Motion types). And they do something fixed curves cannot imitate: if mid-flight you change the destination (retargeting), the spring starts from where it is while keeping its velocity — like a ball that gets hit again while still flying. Hence the practical rule: springs to respond to the user, curves for planned choreography.

Decay: the friction of the physical world

The third motion simulates friction: you give it an initial velocity and the value slows down by itself until it stops.

The glass analogy. Push a glass across a table: it starts fast, loses speed to friction and stops gradually. It never bounces or speeds up — it just dies away gently. That is exactly your phone's flick: the list keeps sliding after you lift your finger.

time 0
Decay: starts steep (fast) and flattens out asymptotically — it approaches rest without an abrupt ending. It will stop at exactly v₀/λ.
// When the gesture is released with the finger's velocity:
_ = try an.decay_from(&scroll_y, finger_velocity, .{});

// Higher λ = brakes sooner:
_ = try an.decay_from(&scroll_y, finger_velocity, .{ .deceleration = 4.0 });

A numeric example, frame by frame

Let's animate a window's opacity from 0.0 (invisible) to 1.0 (visible), duration 1 second, with ease_out, watching 10 frames for simplicity:

Frameraw tease_out(t)written opacityWhat you see
00.000.000.00invisible
10.100.190.19pops in!
20.200.360.36clearly visible
30.300.510.51halfway, slowing down
50.500.750.75almost done
101.001.001.00solid, end

Look at the ease_out(t) column: with raw t, frame 1 would show opacity 0.10; with ease_out it shows 0.19. The curve pushes progress forward at the start and brakes it at the end — that's the whole secret. The complete code fits in three lines:

var opacity: f32 = 0.0;

// When opening the window:
_ = try animator.tween_to(f32, &opacity, 1.0,
                          .{ .duration_s = 1.0, .curve = anim.curve.ease_out });

// On every frame of the main loop:
try animator.update(dt);    // writes opacity by itself
window.draw(opacity);       // you only read it

Design philosophy

Note. The performance numbers quoted in this document were measured with ReleaseFast builds on x86_64, with thousands of concurrent animations. In a typical UI (dozens of active animations) simulation cost stays under 0.05% of a 16 ms frame budget.

Quick start

1. Add the dependency to build.zig.zon

.{
    .name = .my_app,
    .version = "0.1.0",
    .dependencies = .{
        // Relative path (or hash after `zig fetch --save ../animations`)
        .pixelsculp_anim = .{ .path = "../animations" },
    },
}

2. Import the module in build.zig

const anim_dep = b.dependency("pixelsculp_anim", .{});
const anim_mod = anim_dep.module("pixelsculp_anim");

const exe = b.addExecutable(.{ .name = "my_app", .root_module = exe_mod });
exe_mod.addImport("pixelsculp_anim", anim_mod);

3. Animate something

const std = @import("std");
const anim = @import("pixelsculp_anim");

var button_scale: f32 = 1.0;

pub fn main() !void {
    var animator = anim.Animator.init(std.heap.page_allocator);
    defer animator.deinit();

    // The user presses a button: request a spring and you're done.
    // The animator will write `button_scale` on every update(dt).
    _ = try animator.spring_to(
        f32,
        &button_scale,
        0.94,
        anim.snappy_spring, // physics preset (see "Motion types")
    );

    while (true) {
        const dt = wait_for_frame();     // your render loop
        try animator.update(dt);         // advances EVERYTHING and writes fields
        draw_button_with_scale(button_scale);
    }
}

fn wait_for_frame() f32 {
    // Real dt from your platform (GLFW: glfwGetTime; game: fixed tick).
    return 1.0 / 60.0;
}

That is the whole contract: request an animation toward a target and call update(dt) once per frame. Everything else in this document is a variation on that pattern.

Conceptual model

The three pieces

PieceWhat it isWhen to use it
Animation(T) A concrete animation of a value of type T, between a base point and a target, governed by a MotionSpec. Explicit use: you own the instance, advance it by hand and read value(). Useful for custom engines, tests or highly bespoke effects.
Animator Implicit registry of animations keyed by the destination field's address. You ask it to "animate that field" and it takes care of creating, retargeting, writing and cleaning up. 95% of UI cases. The recommended mode.
Chain(T) Sequence of segments over one same field, created by sequence_to. A single Animation re-seeded segment by segment. Choreographies: enter-wait-leave, rise with a final bounce, etc.

MotionSpec: the description of the motion

A MotionSpec is a union with three families:

// The three ways to say "go to 240":
try an.spring_to(f32, &x, 240, .{ .stiffness = 300, .damping = 22 }); // direct physics
try an.tween_to (f32, &x, 240, .{ .duration_s = 0.35 });             // duration + curve
try an.animate_to(f32, &x, 240, anim.snappy);                        // MotionSpec preset

Tween or spring?

Use tween when…Use spring when…
The exact duration matters (synchronized choreographies).Interruption mid-flight is likely (the user is in charge).
You need total reproducibility (same input → same curve).You want a natural feel without designing curves.
It's a simple state transition (hover, fade).It's a direct response to a gesture (drag, press).

Motion types

Tween

The predictable motion: lasts exactly as long as you ask and follows the curve you give it. Accepts delay_s and all playback options (repeat, fill…). Internally, the bézier solver uses Newton's method plus bisection, reusing the previous frame's root as the seed, which saves about a third of the cost.

// Opacity from 0 to 1 in 250 ms with the ease_out preset curve.
_ = try an.tween_to(f32, &opacity, 1.0, .{
    .duration_s = 0.25,
    .curve      = anim.curve.ease_out,
});

// Custom cubic Bézier (same control points as CSS cubic-bezier()).
_ = try an.tween_to(f32, &offset_y, -120, .{
    .duration_s = 0.45,
    .curve      = .{ .bezier = .{ .x1 = 0.16, .y1 = 1, .x2 = 0.3, .y2 = 1 } },
});

// The Penner families a bézier cannot express: bounce, elastic,
// back, circ, expo, quart, quint, sine, quad, cubic + smoothstep, always
// in three directions (_in / _out / _in_out). Exact endpoints guaranteed:
// eval(0) == 0 and eval(1) == 1 with no floating-point residue.
_ = try an.tween_to(f32, &scale, 1.0, .{ .duration_s = 0.5, .curve = anim.curve.bounce_out });
_ = try an.tween_to(f32, &brightness, 1.0, .{ .duration_s = 0.7, .curve = anim.curve.elastic_in_out });

All curves also exist as pure functions in anim.curve.easings (same name: easings.bounce_out(t)), in case you want to sample outside an animation — paint a preview, generate keyframes, test values. And if no family+direction combination works for you, the comptime adapters in anim.curve.adapt build new stateless functions: time_flip moves the effect to the other end of the timeline, value_flip inverts the output, pair(a, b) composes in+out by halves and blend(f, g, k) mixes two curves with a fixed weight.

Spring

A damped oscillator integrated with semi-implicit Euler at fixed substeps of 1/240 s: stable under any dt (even 200 ms hitches) with rest detection per substep, so leftover time is never lost. It can be parameterized in two equivalent ways:

// Physical form: direct spring constants.
try an.spring_to(f32, &scale, 1.0, .{ .stiffness = 380, .damping = 26, .mass = 1 });

// Descriptive form: how long until it settles? how much does it bounce?
try an.spring_to(f32, &scale, 1.0, anim.SpringParams.duration_bounce(
    0.35,   // response_s: approximate settling time
    0.25,   // bounce:     0 = no bounce, 1 = infinite bounce
));
SpringParams presetFeelIdeal for…
anim.snappy_springFast, minimal oscillationButton feedback, toggles, tooltips
anim.bouncy_springPlayful, visible bouncePlayful confirmations, stickers, badges
anim.gentle_springSlow and dampedLarge panels, camera, backgrounds

Tip. Calling spring_to on a field that is already flying somewhere else? No problem: the animator retargets. It starts from the currently visible value, preserves the velocity the previous spring was carrying, and the transition continues without jumps. It always works, nothing to configure.

Decay (fling)

Applies exponential friction v' = −λv to an initial velocity: the value moves ever more slowly until it stops, ending at exactly v₀/λ. That's the natural motion of inertial scrolling. Only accepts f32 (use init_decay or decay_from).

// When the finger lifts with velocity vy (px/s):
_ = try an.decay_from(&scroll_offset, velocity_y, .{});

// With explicit parameters: deceleration = λ (1/s). Higher λ, brakes sooner.
_ = try an.decay_from(&scroll_offset, velocity_y, .{ .deceleration = 4.0 });

Warning. decay_from requires a pointer to f32 (it compiles but fails with any other type). Also, decays cannot be part of a sequence_to: the engine rejects the sequence with error.DecaySegmentUnsupported before touching its registry.

Curves and easing

A Curve is a function t→t′ applied to the tween's normalized time. The union supports:

VariantBehavior
linearConstant speed.
holdNothing until the end (useful in tracks as "hold and jump").
bezierCubic Bézier with control points (x1,y1,x2,y2), same as CSS cubic-bezier().
stepsN discrete steps, anchored at the start or the end (CSS steps() style).

Presets available in anim.curve: ease_in, ease_out, ease_in_out, ease and linear (exact equivalents of the CSS ones).

// The classic material-emphasized curve.
const emphasized = anim.Curve{ .bezier = .{
    .x1 = 0.2, .y1 = 0.0, .x2 = 0.0, .y2 = 1.0,
} };
_ = try an.tween_to(f32, &panel_x, 320, .{ .duration_s = 0.4, .curve = emphasized });

// Slot-machine style counter: 10 discrete jumps.
_ = try an.tween_to(i32, &counter, 10, .{ .duration_s = 1.0, .curve = .{ .steps = .{
    .count = 10, .jump_end = false,
} } });

Generic interpolation

The typed heart of the system is mix(comptime T, a, b, t): recursive compile-time interpolation over structures made of floats. It doesn't require the type to declare anything special:

const Color = struct { r: f32, g: f32, b: f32, a: f32 };

const black = Color{ .r = 0, .g = 0, .b = 0, .a = 1 };
const blue  = Color{ .r = 0.1, .g = 0.4, .b = 1.0, .a = 1 };

// Direct, no animator:
const middle = anim.mix(Color, black, blue, 0.5);

// And animated — the whole struct travels by itself:
_ = try an.tween_to(Color, ¤t_theme, blue, .{ .duration_s = 0.3 });

Note. In fact, any struct of your own made only of floats and arrays of floats animates just the same. As soon as a non-numeric field shows up (an enum, say) the engine no longer knows how to blend it; in that case animate an f32 progress value and derive the rest yourself, or keep the type flat.

Keyframes (Track(T))

A track defines stops with normalized offset [0..1] and easing per segment, conceptually equivalent to CSS @keyframes. It allocates no memory and is sampled with binary search.

const track = anim.Track(f32).init(&.{
    .{ .offset = 0.0, .value = 0 },                      // start at 0 (linear easing)
    .{ .offset = 0.6, .value = 120, .easing = anim.curve.ease_out }, // arrives early…
    .{ .offset = 0.8, .value = 110, .easing = anim.curve.ease_in  }, // …bounces back…
    .{ .offset = 1.0, .value = 120 },                    // …and settles.
});

// Manual sampling inside your own driver:
const value = track.sample(progress); // progress ∈ [0,1]

An empty track returns zero (never undefined), and you can combine it with tweens by using the track as the source of values.

Choreographed sequences

sequence_to chains segments over a single field. Internally it is one single Animation (a Chain) re-seeded at every segment boundary; leftover time from a segment that finishes rolls into the next one without cuts, so there are no micro-flickers even when segments change mid-frame.

// Full toast lifecycle, in a single call:
_ = try an.sequence_to(f32, &toast_y, &.{
    .{ .to = 64,  .spec = anim.snappy },                       // slides in
    .{ .to = 64,  .spec = .{ .tween = .{ .duration_s = 2.0 } },
        .delay_s = 0.15 },                                     // rests (0.15 s of extra breathing room)
    .{ .to = -80, .spec = anim.gentle_spring },                // leaves gently
});

// Sequencing over a field that ALREADY has a sequence replaces the
// whole sequence (nodes are never mixed up or corrupted).

Important. No segment of a sequence can be decay (the engine validates before registering and returns error.DecaySegmentUnsupported). If you need "fling then snap", chain them by hand: listen for the decay's end in on_complete and launch the spring there.

Playback control

Every Animation(T) — including the ones living inside the animator — exposes the same levers. The handle returned by the *_to methods is ephemeral but valid for configuration right after creation:

const h = try an.tween_to(f32, &opacity, 1.0, .{ .duration_s = 0.5 });

h.delay_left = 0.3;                          // wait 300 ms before starting
h.speed = 2.0;                               // at twice the speed
h.repeat = .{ .count = 4, .mode = .reverse }; // round trip ×4 (even → ends at base)
h.repeat = .{ .count = .infinite, .mode = .reverse }; // eternal pulse
h.fill = .none;                              // on finish, restore the base value

h.pause();          // freezes
_ = h.resume_anim();
h.finish_now();     // jumps to the final state and releases
const was_running = an.cancel(&opacity); // true if something was live on that field
OptionValuesSemantics
repeat.countinteger or .infiniteTotal repetitions. With reverse mode and an even count, the animation ends at the base value (CSS alternate rule).
repeat.mode.restart / .reverseRestart from the beginning or alternate direction each cycle.
fill.forwards (default) / .noneHold the final value or restore the base one on completion.
delay_leftsecondsCountdown before start (assignable afterwards too, for staggering).

Note. Infinite repeats clamp their internal clock to two cycles (stable f32 precision even after hours of continuous pulsing), and the animator's max_dt caps the largest jump per update (100 ms by default) after applying time_scale, avoiding teleports after a process pause.

The Animator in depth

Field-keyed registry

The animator indexes every animation by the memory address of the destination field. Practical consequences:

Handles and callbacks

const Ctx = struct { toast: *Toast };

fn on_finished(ctx_ptr: ?*anyopaque) void {
    const ctx: *Ctx = @ptrCast(@alignCast(ctx_ptr.?));
    ctx.toast.parent.remove(ctx.toast);
}

var ctx = Ctx{ .toast = &toast };
var h = try an.sequence_to(f32, &toast.y, &segments);
h.on_complete_fn = on_finished;
h.on_complete_ctx = &ctx;

Warning (reentrancy contract). During update() callbacks fire while the engine is in two phases (advance/write first, free dead nodes afterwards). Because of that: a callback may cancel other fields (an.cancel(&other)), but it must not destroy its own holder, call an.clear() or an.deinit(). If you need that, schedule it for the next frame. Nested update() calls from inside a callback are safely ignored.

Global control

an.time_scale = 0.25;   // slow motion at 25% (debugging, replay, hit-stop)
an.max_dt     = 0.05;   // anti-hitch clamp (applied after scaling time_scale)
an.pause_all();         // menu open: everything frozen
an.resume_all();        // …and it continues where it was
_ = an.is_animating();  // anything still alive? (useful to keep repainting)

Lifecycle and memory

Nodes use internal memory recycling (no churn per frame). When an animation finishes: with fill = .forwards the field stays pinned at the target and the node is freed; with fill = .none the field returns to its base value. In both cases the on_complete callback fires exactly once.

Integration with pixelsculp widgets

WidgetContext exposes an optional animator shared by the whole widget hierarchy:

// At app startup (optional):
ctx.anim = try anim.Animator.init(gpa);

// In a widget's paint — recommended pattern:
if (ctx.anim) |an| {
    _ = try an.spring_to(f32, &self.press, 1.0, anim.snappy_spring);
} else {
    self.press = 1.0; // no animator: jump straight there (demo compatibility)
}

Performance

Measurements (ReleaseFast, x86_64, 10,000 concurrent animations simulated over 10 s of virtual time):

OperationCost per node per frame
Spring step (1/240 substep included, settle check)~28 ns
Tween sampling with bézier (warm-start active)~34 ns
Tween sampling without warm-start (reference)~47 ns
Write + animator housekeeping< 5 ns

In other words: 100 simultaneous springs cost under 3 µs per frame (~0.02% of 16 ms). Simulation will never be your bottleneck; if a frame is slow, look at rendering, not here.

Relevant architecture decisions

Use cases and recipes

Patterns ready to adapt, ordered by how often they show up in a real UI.

1. Button with press feedback

fn set_pressed(self: *Button, an: ?*anim.Animator, pressed: bool) !void {
    if (an) |a| {
        // Retargets only if already in flight; velocity is preserved.
        _ = try a.spring_to(f32, &self.scale, if (pressed) 0.96 else 1.0,
                            anim.snappy_spring);
    } else {
        self.scale = if (pressed) 0.96 else 1.0;
    }
}

2. Hover with a deterministic transition

_ = try an.tween_to(f32, &self.hover_mix, if (inside) 1.0 else 0.0,
                    .{ .duration_s = 0.15 });
// In the draw: background = mix(normal_bg, hover_bg, self.hover_mix);

3. Toast: enter, wait, leave

_ = try an.sequence_to(f32, &toast.offset_y, &.{
    .{ .to = 0,   .spec = anim.snappy_spring },
    .{ .to = 0,   .spec = .{ .tween = .{ .duration_s = 2.5 } }, .delay_s = 0.2 },
    .{ .to = -64, .spec = anim.curve.ease_in },
});
// In on_complete: free the toast.

4. Scroll with fling and edge bounce

// When the gesture is released:
if (velocity != 0) {
    _ = try an.decay_from(&scroll.y, velocity, .{});
}
// In the decay's on_complete (or if the fling went past the limit):
if (scroll.y > 0 or scroll.y < -content_height) {
    _ = try an.spring_to(f32, &scroll.y, std.math.clamp(scroll.y, -content_height, 0),
                         anim.gentle_spring); // rubber-band back
}

5. Infinite attention pulse

var h = try an.tween_to(f32, &badge.alpha, 1.0, .{ .duration_s = 0.6 });
h.repeat = .{ .count = .infinite, .mode = .reverse };
// It cancels itself out of the way: _ = an.cancel(&badge.alpha);

6. Theme transition (structural color)

// Color is {r,g,b,a}: recursive mix() does the rest.
_ = try an.tween_to(Color, &self.current_bg, new_theme.bg,
                    .{ .duration_s = 0.3, .curve = anim.curve.ease_in_out });

7. Staggered list entrance

for (items, 0..) |*item, i| {
    item.alpha = 0;
    var h = try an.tween_to(f32, &item.alpha, 1.0, .{ .duration_s = 0.25 });
    h.delay_left = @as(f32, @floatFromInt(i)) * 0.04; // 40 ms cascade
}

8. Element chasing the cursor (elastic drag)

// Every frame while dragging: continuous retarget toward the pointer.
// The spring preserves velocity => elastic tracking, no jumps.
_ = try an.spring_to(Vec2, &card.pos, cursor_pos, anim.snappy_spring);

9. A progress bar that doesn't jitter

// Real progress changes in jumps (network, disk…); the spring smooths it.
_ = try an.spring_to(f32, &bar.shown, bar.real, anim.gentle_spring);
// When loading finishes: an.finish_now(&bar.shown) to nail down 100%.

10. Slow motion for debugging or drama

an.time_scale = 0.1;  // visually inspect any transition
an.time_scale = 1.0;  // …and return to reality.
// Game hit-stop: 60 ms of partial freeze after a hit.
an.time_scale = 0.05;
try schedule(0.06, back_to_1);

API reference

Main public surface (all functions follow the snake_case convention; errors are error.DescriptiveError).

Animator — implicit mode

SignatureDescription
init(allocator) AnimatorCreates the registry. One per app (or per isolated hierarchy).
deinit()Frees every live node. Do not call from callbacks.
update(dt) !voidAdvances all animations and writes the fields. One call per frame.
animate_to(T, *T, to, MotionSpec) !*Animation(T)Animates with any spec (retargets if one already existed for that field).
spring_to(T, *T, to, SpringParams) !*Animation(T)Spring shortcut. Preserves velocity on retarget.
tween_to(T, *T, to, TweenSpec) !*Animation(T)Tween shortcut (duration + curve).
decay_from(*f32, velocity, DecayParams) !voidFling: exponential friction from a velocity. f32 only.
sequence_to(T, *T, []Segment(T)) !voidMulti-segment choreography with optional delay_s per segment. Fails with error.DecaySegmentUnsupported if any segment is decay.
cancel(*field) boolCancels that field's animation (true if there was one).
finish_now(*field)Forces the final state immediately and releases.
is_animating(*field) bool / is_animating() boolIs anything animating on that field / anywhere?
pause_all() / resume_all()Global freeze/resume (paused nodes survive the pump).
clear()Removes all animations (outside of callbacks).
time_scale: f32 / max_dt: f32Global time scale / dt clamp (default 0.1 s).

Animation(T) — explicit mode

MemberDescription
init(base, target, spec)Creates between two values with a MotionSpec.
advance(dt) ?f32Advances; returns the leftover dt when it finishes (for manual chaining).
value() TThe current interpolated value. Not idempotent for beziers (±solver tolerance between consecutive reads).
retarget(to) / retarget_spec(to, spec)Changes the target mid-flight starting from the visible value (preserves spring velocity).
pause() / resume_anim() / cancel() / finish_now()Basic playback.
delay_left, speed, repeat, fillSee the playback control table.
on_update_fn/ctx, on_complete_fn/ctxCallbacks with *anyopaque context. They survive retargets.

Motion descriptions

TypeKey fields
TweenSpecduration_s, curve (Curve, defaults to linear), delay_s.
SpringParamsstiffness/damping/mass or duration_bounce(response_s, bounce). Non-physical params are asserted and saturated in init.
DecayParamsdeceleration (λ ≥ 0; ≤0 is asserted and saturated).
Repeatcount (integer or .infinite), mode (.restart/.reverse).
FillMode.forwards (default) / .none.
Segment(T)to, spec, delay_s (for sequence_to).

Utilities

SymbolDescription
mix(T, a, b, t)Recursive comptime lerp (structs/arrays of floats, integers with saturation, extrapolation allowed).
clamp01 / ilerp / remap / eerpRange arithmetic: clamp, inverse lerp, rescale between ranges, exponential lerp (scales).
damp(dt, half_life_s)Frame-rate-independent exponential smoothing factor: value += (target − value) * damp(dt, 0.1).
Curve / CubicBezier / Pennerlinear/hold/bezier/steps/penner; eval(x) and eval_seed(x, hint, &out) (warm start).
anim.curve.*Presets: CSS (ease, ease_in…) + full Penner set (bounce_out, elastic_in, circ_in_out… quad..bounce × in/out/in_out, smoothstep/smootherstep).
anim.curve.easings.*The same curves as pure fn(f32) f32 functions with guaranteed exact endpoints.
anim.curve.adapt.*Comptime combinators over easing functions: time_flip, value_flip, pair, blend.
anim.snappy / bouncy / gentleMotionSpec presets ready for animate_to.
anim.snappy_spring / bouncy_spring / gentle_springSpringParams presets for spring_to.
Track(T)Keyframes with offset + per-segment easing; binary-searching sample(t); empty ⇒ zero.
Chain(T)Sequence driver created by sequence_to; rolls leftover dt between segments.

Pocket glossary

TermMeaning
FrameOne complete image shown by the screen. Animation is changing numbers between frames.
FPSFrames per second. At 60 FPS there are ~16 ms between one draw and the next.
dt (delta time)How long the last frame took in seconds (~0.016 at 60 FPS). It is the only "clock" the engine consumes.
Interpolation / lerpComputing the intermediate values between two known ones: start + (end − start) × t.
Normalized tThe progress of the trip, from 0 (start) to 1 (end).
Easing / curveA function that distorts t to give the motion personality (braking, accelerating, scaling).
OvershootGoing past the target and coming back — the signature charm of springs.
SettleThe moment a spring comes to rest and the animation is considered finished.
RetargetingChanging the destination mid-flight starting from the currently visible value, with no restarts or jumps.
KeyframeAn intermediate stop defined by you (as in After Effects). In this library: Track(T).
Fill modeWhat happens on finish: .forwards pins the final value; .none returns to base.

Test what you've learned

Three real situations. Before revealing the answer, decide what you would use:

  1. A toggle the user just tapped.
  2. A confirmation dialog that appears when opening a menu.
  3. A list the user flung with a quick gesture and released.
Show answers
  1. spring_to with anim.snappy_spring. It responds to the finger with minimal wobble; if the user changes their mind halfway, retargeting handles it on its own.
  2. tween_to with ease_out. Planned choreography: you want an exact duration and a soft landing, no physical surprises.
  3. decay_from(&scroll_y, velocity, .{}). Pure inertia with exponential friction; if it goes past the limits, a gentle_spring brings it back rubber-band style.

Best practices and limitations

Do

Avoid

Known limitations