Animation

April 27, 2026 · View on GitHub

SLT animations are standalone structs, not Context methods. Each animation computes an f64 value from tick counts. You pass the computed value to style or layout methods yourself.

let mut fade = Tween::new(0.0, 1.0, 30).easing(ease_out_quad);
fade.reset(ui.tick());

// Each frame: compute the value, use it however you want
let opacity = fade.value(ui.tick());
ui.text("Hello").fg(Color::Rgb(255, 255, (255.0 * opacity) as u8));

Tick-based model

Animations run on frame ticks, not wall-clock time.

  • ui.tick() returns a u64 frame counter that increments every render frame.
  • RunConfig::default().tick_rate(Duration::from_millis(16)) controls the polling interval (default 16ms / ~60fps).
  • All animation types use reset(tick) to set a start time and value(tick) to sample.
  • Lower tick_rate = smoother animations but more CPU usage.

Animation types

Tween

Linear interpolation from A to B over a fixed number of ticks.

use slt::{Tween, Context, Color};
use slt::anim::ease_out_quad;

let mut tween = Tween::new(0.0, 100.0, 60)
    .easing(ease_out_quad);

slt::run(|ui: &mut Context| {
    if ui.tick() == 0 {
        tween.reset(ui.tick());
    }
    let x = tween.value(ui.tick()) as u16;
    ui.text("sliding").ml(x);
});

Key methods:

  • Tween::new(from, to, duration_ticks) — constructor, linear easing by default
  • .easing(fn) — set easing function (builder)
  • .on_complete(fn) — callback when done (builder)
  • .reset(tick) — start/restart the tween
  • .value(tick) -> f64 — sample current value
  • .is_done() -> bool — completion check

Spring

Physics-based damped harmonic oscillator. Unlike Tween, Spring has no fixed duration -- it settles naturally based on stiffness and damping.

use slt::{Spring, Context};

let mut spring = Spring::new(0.0, 0.2, 0.85);

slt::run(|ui: &mut Context| {
    let hovered = ui.button("Hover me").hovered;
    spring.set_target(if hovered { 10.0 } else { 0.0 });
    spring.tick();

    let offset = spring.value() as u16;
    ui.text("bouncy").ml(offset);
});

Key methods:

  • Spring::new(initial, stiffness, damping) — constructor
    • stiffness: acceleration per unit displacement (0.1..0.5)
    • damping: per-tick velocity multiplier, must satisfy 0.0 < damping < 1.0 (0.8..0.95). Both bounds are enforced via debug_assert! in Spring::new (v0.19.1); release builds do not panic but values outside this range conserve or amplify energy and never settle.
  • .on_settle(fn) — callback when settled (builder)
  • .set_target(value) — change the goal position (interactive use)
  • .tick() — advance simulation by one frame (call once per frame)
  • .value() -> f64 — current position
  • .is_settled() -> bool — true when velocity and distance are both < 0.01

Spring does not use reset(tick). Call .tick() every frame and .set_target() to change direction.

Damping note: This damping is not the standard ODE damping ratio ζ — it is a velocity multiplier applied each tick (velocity *= damping after the spring force). A value of 1.0 would conserve energy (eternal oscillation); > 1.0 would amplify it. The recommended 0.80..=0.95 range covers fast-settle to slow-bouncy UI feel.

Keyframes

Multi-stop timeline animation, like CSS @keyframes. Each segment between stops can use its own easing.

use slt::anim::{Keyframes, LoopMode, ease_out_quad, ease_in_cubic};

let mut kf = Keyframes::new(90)
    .stop(0.0, 0.0)       // start at 0
    .stop(0.3, 100.0)     // ramp up to 100 at 30%
    .stop(0.7, 100.0)     // hold at 100 until 70%
    .stop(1.0, 40.0)      // ease down to 40
    .segment_easing(0, ease_out_quad)
    .segment_easing(2, ease_in_cubic)
    .loop_mode(LoopMode::PingPong);

kf.reset(ui.tick());
let brightness = kf.value(ui.tick());

Key methods:

  • Keyframes::new(duration_ticks) — constructor
  • .stop(position, value) — add a stop at normalized position [0.0, 1.0]. Stops are kept sorted by position after every call, so the order in which you append them does not matter — a .stop(0.7, 100.0) after .stop(1.0, 40.0) lands in the right slot. Code that relied on insertion order to identify segments will see the sorted-by-time order instead.
  • .easing(fn) — default easing for all segments
  • .segment_easing(index, fn) — override easing for segment index (0 = first-to-second stop). Out-of-range indices are silently ignored in release builds (preserving the panic-free guarantee for runtime code) and trigger a debug_assert! panic in debug builds (v0.19.1) so builder-order mistakes — calling segment_easing(2, ...) before three stops have been added — surface during development.
  • .loop_mode(mode) — set loop behavior
  • .on_complete(fn) — callback when done
  • .reset(tick) — start/restart
  • .value(tick) -> f64 — sample current value
  • .is_done() -> bool — completion check (always false for looping modes)

Sequence

Chain multiple tween segments end-to-end into a single timeline.

use slt::anim::{Sequence, LoopMode, ease_linear, ease_out_quad, ease_in_cubic};

let mut seq = Sequence::new()
    .then(0.0, 100.0, 30, ease_out_quad)   // slide right
    .then(100.0, 100.0, 10, ease_linear)   // pause
    .then(100.0, 0.0, 20, ease_in_cubic)   // slide back
    .loop_mode(LoopMode::Repeat);

seq.reset(ui.tick());
let x = seq.value(ui.tick());

Key methods:

  • Sequence::new() — constructor
  • .then(from, to, duration_ticks, easing) — append a segment
  • .loop_mode(mode) — set loop behavior
  • .on_complete(fn) — callback when done
  • .reset(tick) — start/restart
  • .value(tick) -> f64 — sample current value
  • .is_done() -> bool — completion check

Stagger

Apply the same tween to N items with a fixed delay between each start.

use slt::anim::{Stagger, LoopMode, ease_out_quad};

let items = vec!["Alpha", "Beta", "Gamma", "Delta"];
let mut stagger = Stagger::new(0.0, 1.0, 20)
    .easing(ease_out_quad)
    .delay(5)
    .items(items.len())
    .loop_mode(LoopMode::Once);

stagger.reset(ui.tick());

for (i, label) in items.iter().enumerate() {
    let opacity = stagger.value(ui.tick(), i);
    let gray = (255.0 * opacity) as u8;
    ui.text(*label).fg(Color::Rgb(gray, gray, gray));
}

Key methods:

  • Stagger::new(from, to, duration_ticks) — constructor
  • .easing(fn) — easing for each item's tween
  • .delay(ticks) — ticks between consecutive item starts
  • .items(count) — set item count (inferred from usage if not set)
  • .loop_mode(mode) — set loop behavior
  • .on_complete(fn) — callback when done
  • .reset(tick) — start/restart
  • .value(tick, item_index) -> f64 — sample value for a specific item
  • .is_done() -> bool — true if the most recently sampled item finished
  • .is_all_done(tick, item_count) -> bool — true once every item has finished, computed from pure tick arithmetic (v0.19.1). Use this when you need a single completion signal that does not depend on which item happened to be sampled last. With LoopMode::Repeat / PingPong it only reports true for the first cycle (loops re-enter after completion).

Easing functions

All easing functions have signature fn(f64) -> f64, mapping [0, 1] to [0, 1].

FunctionCurveUse case
ease_linearConstant rateDefault, mechanical motion
ease_in_quadSlow startAccelerating elements
ease_out_quadSlow endDecelerating, natural stops
ease_in_out_quadSlow both endsSmooth transitions
ease_in_cubicSlower startStronger acceleration
ease_out_cubicSlower endStronger deceleration
ease_in_out_cubicSlower both endsEmphasis on middle speed
ease_out_elasticOvershoot + oscillateAttention-grabbing, playful
ease_out_bounceBouncing ballLanding, drop effects

Helper: lerp(a, b, t) — linear interpolation, not clamped. Apply easing to t before calling.

use slt::anim::{lerp, ease_out_quad};

let t = ease_out_quad(0.5);
let value = lerp(0.0, 100.0, t); // ~75.0

LoopMode

Controls what happens when an animation reaches its end.

ModeBehavior
LoopMode::OncePlay once, hold at final value. is_done() returns true.
LoopMode::RepeatRestart from the beginning each cycle.
LoopMode::PingPongAlternate forward and backward each cycle.

Applies to Keyframes, Sequence, and Stagger. Tween always plays once (use Sequence with LoopMode::Repeat for looping tweens). Spring has no loop mode -- it settles naturally.

Common patterns

Fade-in on mount

let mut fade = Tween::new(0.0, 1.0, 30).easing(ease_out_quad);
let mut started = false;

slt::run(|ui: &mut Context| {
    if !started {
        fade.reset(ui.tick());
        started = true;
    }
    let alpha = fade.value(ui.tick());
    let g = (255.0 * alpha) as u8;
    ui.text("Welcome").fg(Color::Rgb(g, g, g));
});

Button hover spring

let mut spring = Spring::new(0.0, 0.3, 0.8);

slt::run(|ui: &mut Context| {
    let btn = ui.button("Click me");
    spring.set_target(if btn.hovered { 2.0 } else { 0.0 });
    spring.tick();

    let pad = spring.value() as u16;
    ui.text(">>").ml(pad);
});

Staggered list entry

let items = vec!["One", "Two", "Three", "Four", "Five"];
let mut stagger = Stagger::new(0.0, 1.0, 15)
    .easing(ease_out_cubic)
    .delay(3)
    .items(items.len());

let mut started = false;

slt::run(|ui: &mut Context| {
    if !started {
        stagger.reset(ui.tick());
        started = true;
    }
    for (i, item) in items.iter().enumerate() {
        let t = stagger.value(ui.tick(), i);
        let brightness = (255.0 * t) as u8;
        ui.text(*item).fg(Color::Rgb(brightness, brightness, brightness));
    }
});

Loading sequence (multi-phase)

let mut loading = Keyframes::new(120)
    .stop(0.0, 0.0)     // idle
    .stop(0.25, 100.0)  // fill bar
    .stop(0.5, 100.0)   // hold
    .stop(0.75, 0.0)    // reset
    .stop(1.0, 0.0)     // idle
    .easing(ease_in_out_quad)
    .loop_mode(LoopMode::Repeat);

loading.reset(ui.tick());
let pct = loading.value(ui.tick());
ui.text(format!("[{:>3.0}%]", pct));

Chained transitions

let mut chain = Sequence::new()
    .then(0.0, 50.0, 20, ease_out_quad)    // move to center
    .then(50.0, 50.0, 30, ease_linear)     // hold
    .then(50.0, 100.0, 20, ease_in_cubic); // move to end

chain.reset(ui.tick());
let pos = chain.value(ui.tick()) as u16;
ui.text("->").ml(pos);

API quick reference

Tween

ConstructorTween::new(from, to, duration_ticks)
Builder.easing(fn), .on_complete(fn)
Control.reset(tick)
Sample.value(tick) -> f64
Done.is_done() -> bool

Spring

ConstructorSpring::new(initial, stiffness, damping)
Builder.on_settle(fn)
Control.set_target(value), .tick()
Sample.value() -> f64
Done.is_settled() -> bool

Keyframes

ConstructorKeyframes::new(duration_ticks)
Builder.stop(pos, val), .easing(fn), .segment_easing(idx, fn), .loop_mode(mode), .on_complete(fn)
Control.reset(tick)
Sample.value(tick) -> f64
Done.is_done() -> bool

Sequence

ConstructorSequence::new()
Builder.then(from, to, ticks, easing), .loop_mode(mode), .on_complete(fn)
Control.reset(tick)
Sample.value(tick) -> f64
Done.is_done() -> bool

Stagger

ConstructorStagger::new(from, to, duration_ticks)
Builder.easing(fn), .delay(ticks), .items(count), .loop_mode(mode), .on_complete(fn)
Control.reset(tick)
Sample.value(tick, item_index) -> f64
Done.is_done() -> bool (last-sampled item), .is_all_done(tick, item_count) -> bool (every item)