animations in goo

every animated value in goo lives in one of two lanes, split by a single question: who owns the value?

there is no third lane. the framework doesn't track animations: no transition property beyond those two, no @keyframes. when in doubt, you own the value.

the authored lane in four steps

  1. store state on the panel as a damper field.
  2. mutate the target when input changes. call Rebuild().
  3. advance the damper in your Tick(dt) override and return its Tick(dt) result. true means "still moving, rebuild me".
  4. sample .Current inside Build and feed it into the tree.

one demo, the whole surface

Code/Demo/StylishHud/StylishHudUI.cs is the verified capstone: a combat HUD exercising every primitive on this page in one component. when a section below feels abstract, that file is the working reference:

primitive where it appears
DecayFloat hit heat: spiked to 1 on every hit, decays to drive a gauge glow
SpringFloat grade letter punch: _letterScale.Velocity += 6f on grade-up
SmoothColor HUD-wide palette crossfade between grade accent colors
SmoothFloat score readout rolling toward the real total, never snapping
SpringVector2 + AnimationSet floating score popups bursting outward from center
Timeline.Sequence + SegmentIndex kill banner choreography: slam, shake, hold, exit
AgePhase kill feed slide-in / hold / fade-out, popup fades
LayoutTransition keyed feed rows gliding when the list restacks
the \| chain one Tick advancing four dampers plus an AnimationSet

the damper types

all are mutable record struct (zero-allocation, copy semantics):

type backing configured by tracks velocity? when to use
DecayFloat MathX.ExponentialDecay Halflife no simple geometric approach toward a target. no overshoot, no momentum.
DecayColor per-channel ExponentialDecay Halflife no color crossfades, no momentum
DecayVector2 per-axis ExponentialDecay Halflife no 2D position/size lerps, no momentum
SmoothFloat MathX.SmoothDamp SmoothTime yes UI that needs to respond to rapid target changes mid-flight (snappy feedback)
SmoothColor per-channel SmoothDamp SmoothTime yes color crossfades with snap-back support
SmoothVector2 per-axis SmoothDamp SmoothTime yes 2D position/size with snap-back support
SpringFloat MathX.SpringDamp Frequency + Damping yes bouncy/overshoot motion - pops, springs, elastic snaps
SpringColor per-channel SpringDamp Frequency + Damping yes color bounce/overshoot effects
SpringVector2 per-axis SpringDamp Frequency + Damping yes bouncy 2D position/size - drag-release, elastic UI

hover-pulse on a container

using Goo;
using Goo.Animation;

public class HoverButtonUI : GooPanel<Container>
{
    DecayFloat _hover = new(initial: 0f, halflife: 0.06f);

    protected override bool Tick(float dt) => _hover.Tick(dt);

    protected override Container Build() => new Container
    {
        Padding         = 12,
        BackgroundColor = Color.Lerp(Color.Gray, Color.Cyan, _hover.Current),
        OnMouseEnter    = _ => { _hover.Target = 1f; Rebuild(); },
        OnMouseLeave    = _ => { _hover.Target = 0f; Rebuild(); },
        Children        = { new Text("hover me") },
    };
}

every damper has Tick(dt): it advances the value and returns true while still moving. GooPanel calls your Tick override every frame before the build gate; returning true requests a rebuild, returning false lets the panel idle. once the damper settles, no more rebuilds happen until the next input event.

(Update(dt) and IsSettled still exist individually for cases where you need them apart. Tick(dt) is just Update plus !IsSettled in one call.)

chaining gotcha: when one Tick override drives several dampers, combine with bitwise |, never short-circuiting ||:

// good: every damper advances every frame
protected override bool Tick(float dt) =>
    _hover.Tick(dt) | _press.Tick(dt) | _open.Tick(dt);

// bad: once _hover settles, || stops evaluating and the rest freeze mid-flight
protected override bool Tick(float dt) =>
    _hover.Tick(dt) || _press.Tick(dt) || _open.Tick(dt);

the shipped shape of the chain, from the stylish HUD's Tick (Code/Demo/StylishHud/StylishHudUI.cs): four dampers and an AnimationSet in one | chain, then non-damper liveness (a running timeline clock, aging list entries) OR'd on top:

bool moving = _hitHeat.Tick(dt)
            | _letterScale.Tick(dt)
            | _palette.Tick(dt)
            | _shownScore.Tick(dt)
            | _popupAnimators.UpdateAll(dt);

moving |= _bannerClock >= 0f && _bannerClock < BannerTimeline.Duration;
moving |= _feed.Count > 0;

return moving;

anything that should keep the panel rebuilding belongs in this flag: dampers report it themselves, clocks and lifecycled lists you report by hand.

AnimationSet: multiple animators per panel

a panel holding more than one or two animators turns into a ceremony. tick each one, ask each one if it's settled, rebuild if any isn't. AnimationSet collapses that boilerplate.

register each animator as a TickFn that advances it and returns true if it's still in motion. tick the whole set once per frame, if anything is still running, rebuild.

using Goo;
using Goo.Animation;

public class MultiAnimUI : GooPanel<Container>
{
    SmoothFloat  _scale  = new(initial: 1f, smoothTime: 0.08f);
    SpringFloat  _bounce = new(initial: 0f, frequency: 6f, damping: 0.5f);
    AnimationSet _anims  = new();

    protected override void OnEnabled()
    {
        base.OnEnabled();
        _anims.Clear();
        _anims.Add(dt => _scale.Tick(dt));
        _anims.Add(dt => _bounce.Tick(dt));
    }

    protected override bool Tick(float dt) => _anims.UpdateAll(dt);
}

a few notes on the shape:

// SlotCount and _slotBounce are illustrative. substitute your own array and length.
// Example: const int SlotCount = 4; SpringFloat[] _slotBounce = new SpringFloat[SlotCount];
for (int i = 0; i < SlotCount; i++)
{
    int k = i;
    _anims.Add(dt => _slotBounce[k].Tick(dt));
}

transient animators (spawned per entry rather than declared per panel) follow the same registration shape with one twist: close over a class instance so the closure ticks the same damper Build reads. a damper stored in a struct or captured by value would advance a copy. from the stylish HUD's score popups (Code/Demo/StylishHud/StylishHudUI.cs):

sealed class Popup
{
    public int           Id;
    public string        Text = "";
    public float         Age;
    public SpringVector2 Spring;
}

void SpawnPopup(string text)
{
    var p = new Popup
    {
        Id     = _nextPopupId++,
        Text   = text,
        Spring = new SpringVector2(Vector2.Zero, 5f, 0.6f) { Target = burstOffset },
    };
    _popups.Add(p);
    _popupAnimators.Add(dt => p.Spring.Tick(dt));
}

an expired popup's tick function keeps returning false harmlessly, so the set never needs surgical removal; clear it wholesale when the list empties to bound its growth:

if (_popups.Count == 0 && _popupAnimators.Count > 0) _popupAnimators.Clear();

the set itself is a thin wrapper around a List<TickFn>. once you've outgrown a single damper, this is the next stop.

a panel rebuilds for two reasons, and GooPanel already handles both around this set, so there is no separate gate to wire up:

easings

Goo.Animation.Easing re-exports the 16 engine easings as static fields:

Easing.Linear      Easing.Ease         Easing.EaseIn       Easing.EaseOut
Easing.EaseInOut   Easing.ExpoIn       Easing.ExpoOut      Easing.ExpoInOut
Easing.BounceIn    Easing.BounceOut    Easing.BounceInOut
Easing.SineIn      Easing.SineOut      Easing.SineInOut
Easing.StepStart   Easing.StepEnd

CSS-name lookup via Easing.FromName("ease-in-out"). this returns a nullable Sandbox.Utility.Easing.Function?, so check for null before using it. an unrecognised name returns null rather than throwing.

two factories: Easing.Steps(count, atStart) and Easing.CubicBezier(x1, y1, x2, y2).

important: the two factories return closures (engine-internal capture). cache them once in a static readonly field. never call them inside Build() or Tick. calling per-frame allocates a delegate per call and will blow the framework's per-Rebuild allocation budget.

the recommended pattern is to wrap the factory result in a Tween at declaration time, matching how Code/Demos/AnimationShowcase/Sections/EasingsSection.cs handles it:

// good: cache factory result inside a Tween at the static field level
static readonly Tween TSnap = new Tween(Easing.CubicBezier(0.8f, 0f, 0.2f, 1f), duration: 1.2f);

// bad: allocates a closure every frame
protected override Container Build() => new Container { ... uses Easing.CubicBezier(...) ... };

tweens (time-based playback)

dampers are velocity-driven and settle asymptotically. Goo.Animation.Tween is the opposite: time-driven, stateless, runs on a known schedule. you own the clock. the tween is a pure function of elapsed seconds.

var tween = new Tween(Easing.EaseOut, duration: 0.4f);
float t = tween.Eval(elapsedSec);  // returns 0..1, clamps past the end
Color faded = Color.Lerp(Color.Black, Color.White, t);

six extension methods chain off the value, each returning a new immutable Tween:

method what it does
.Loop() repeats forever
.Times(n) repeats n times then clamps to the natural end value
.PingPong() each iteration is forward then backward (cycle = 2 * duration)
.Scale(speed) > 1 faster, < 1 slower, <= 0 freezes at the start
.WithDelay(s) shifts start by s seconds
.Reverse() inverts the curve (start at 1, end at 0)

a continuous breathing pulse using a ping-pong loop:

static readonly Tween s_pulse = new Tween(Easing.SineInOut, duration: 0.8f).PingPong().Loop();

protected override bool Tick(float dt) => true; // looping pulse never settles

protected override Container Build() => new Container
{
    BackgroundColor = Color.Lerp(BaseColor, HighlightColor, s_pulse.Eval(Time.Now)),
};

Tween is a readonly record struct. Eval is allocation-free and consumes one cached Easing.Function delegate. the same factory-cache rule from the easings section above applies: don't construct Easing.Steps or Easing.CubicBezier inside Eval's caller per frame.

tweens from a curve

Tween.FromCurve(curve, duration, delay) bridges a designer-authored Sandbox.Curve into a Tween. the curve is assumed to be authored over [0, 1]. the engine presets Curve.Linear, Curve.Ease, Curve.EaseIn, and Curve.EaseOut all match that domain.

static readonly Tween s_settle = Tween.FromCurve(Curve.EaseOut, duration: 0.6f);

protected override Container Build() => new Container
{
    Left = s_settle.Eval(_elapsed) * TravelDistance,
};

FromCurve allocates one delegate per call (the engine Curve struct is copied into the delegate target). cache the result in a static readonly field the same way you would cache Easing.CubicBezier. the returned tween supports the full extension chain. Tween.FromCurve(Curve.Ease, 1f).PingPong().Loop() works identically to a tween built from a coded easing.

animators (owned clock, pause/resume/seek/restart)

Tween is stateless. the caller passes in elapsed seconds and gets back a value. when you want a clock attached to a tween, one that pauses, resumes, restarts, or jumps to a position, reach for Goo.Animation.Animator.

Animator _anim = new Animator(new Tween(Easing.EaseOut, duration: 0.4f));

protected override bool Tick(float dt)
{
    _anim.Update(dt);
    return !_anim.IsFinished;
}

protected override Container Build() => new Container
{
    Left = _anim.Value * TravelDistance,
    OnClick = _ => { _anim.Restart(); Rebuild(); },
};

Animator is a record struct matching the damper convention. hold it in a field, advance it with Update(dt), sample Value inside Build. allocation-free.

member what it does
Update(dt) advances the internal clock when not paused, scaled by Speed
Pause() / Resume() freezes / unfreezes the clock without resetting it
Restart() resets elapsed to 0 and clears paused
Seek(t) jumps to a specific elapsed time, independent of paused state
Tween the tween this animator was constructed with (readable, e.g. _anim.Tween.Duration)
Elapsed seconds since last Restart(), adjusted by Speed
Paused readable bool: true when paused, false when running
Value shortcut for Tween.Eval(Elapsed) (property, not a method)
IsFinished true once Elapsed is past the tween's natural end, always false for Loop() (property)

Speed is a runtime-tweakable playback multiplier independent of Tween.Scale(n). both compose. the tween's bake-time scale and the animator's runtime speed multiply together.

like dampers, Animator lives in a field. storing animators in a List<Animator> and trying to mutate via index (list[i].Pause()) does nothing. that's a value-type copy. the field-resident pattern is the supported one.

timelines (multi-segment choreography)

when a single Tween isn't enough (say, "fade in, hold, fade out" or "slide in then bounce"), wrap several Tweens in a Timeline. the result is still a value-sampling primitive: timeline.Eval(elapsedSec) returns a TimelineSample{int SegmentIndex, float Value}. the caller routes the value (same posture as Tween).

// Same property, multi-phase: caller uses sample.Value, ignores SegmentIndex.
static readonly Timeline FadePulse = Timeline.Sequence(
    new Tween( Easing.EaseOut, duration: 0.6f ),                // 0..1
    new Tween( t => 1f,        duration: 0.4f ),                // hold at 1 (degenerate easing)
    new Tween( Easing.EaseIn,  duration: 0.6f ).Reverse()       // 1..0
);

// In Tick:
_elapsed += dt;
var s = FadePulse.Eval( _elapsed );
opacity = s.Value;

for explicit timing (gaps are allowed, overlaps throw), use At:

static readonly Timeline Stagger = Timeline.At(
    ( 0.0f, fadeIn ),
    ( 0.7f, slideIn ),    // 0.3s gap after fadeIn ends, HOLD-LAST during the gap
    ( 1.4f, bounce )
);

rules:

multi-property choreography uses SegmentIndex. the shipped example is the stylish HUD's kill banner (Code/Demo/StylishHud/StylishHudUI.cs): four segments, each valued 0..1, mapped to scale, shake, and opacity inside Build:

static readonly Timeline BannerTimeline = Timeline.Sequence(
    new Tween(Easing.ExpoOut,   duration: 0.12f),                       // 0: slam in
    new Tween(Easing.SineInOut, duration: 0.10f).PingPong().Times(2),   // 1: shake
    new Tween(Easing.Linear,    duration: 0.60f),                       // 2: hold
    new Tween(Easing.EaseIn,    duration: 0.25f)                        // 3: exit
);

// In Build:
var sample = BannerTimeline.Eval(_bannerClock);
float v    = sample.Value;

float scale   = sample.SegmentIndex == 0 ? v
              : sample.SegmentIndex == 3 ? 1f - v * 0.3f
              : 1f;
float opacity = sample.SegmentIndex == 3 ? 1f - v : 1f;
float shakeX  = sample.SegmentIndex == 1 ? (v - 0.5f) * 12f : 0f;

the clock is a plain float field advanced in Tick and reset to a sentinel (-1f) past BannerTimeline.Duration; while it runs, the Tick motion flag ORs in _bannerClock >= 0f so the panel keeps rebuilding. derivation stays stateless: each property is a pure function of the sample, so restarting the banner is just _bannerClock = 0f.

concurrent tracks - hold N Timelines as fields and sample each independently. there's no Parallel primitive. the user is the parallel composer.

TimelineAnimator (owned clock over a Timeline)

Timeline is stateless. when you want a clock attached to one (pause, resume, restart, seek), reach for Goo.Animation.TimelineAnimator. mirrors Animator exactly except .Sample returns a TimelineSample (not a float).

static readonly Timeline Choreography = Timeline.Sequence( ... );
TimelineAnimator _anim = new( Choreography );

protected override bool Tick( float dt )
{
    _anim.Update( dt );
    return !_anim.IsFinished;
}

protected override Container Build()
{
    var s = _anim.Sample;
    return new Container
    {
        Opacity = s.SegmentIndex == 0 ? s.Value : (s.SegmentIndex > 0 ? 1f : 0f),
        OnClick = _ => { _anim.Restart(); Rebuild(); },
    };
}
member what it does
Update(dt) advances the internal clock when not paused, scaled by Speed
Pause() / Resume() freezes / unfreezes the clock without resetting it
Restart() resets elapsed to 0 and clears paused
Seek(t) jumps to a specific elapsed time, independent of paused state
Sample shortcut for Timeline.Eval(Elapsed) (property, returns TimelineSample, not a float)
IsFinished true once Elapsed >= Duration * Iterations, always false for Loop() (property)

sample-and-derive over sample-and-cache. when a Timeline drives multiple properties, derive each from Sample at Build time rather than caching _opacity / _xOffset / _scale fields and dispatching switch ( s.SegmentIndex ) inside Update. the cached pattern means Restart() doesn't fully reset the visual. each cached field has to be zeroed manually, and the reset list scales with property count. stateless derivation makes Restart() a true one-liner. pattern:

float PhaseValue( TimelineSample s, int phase )
{
    if ( s.SegmentIndex <  phase ) return 0f;   // not started yet
    if ( s.SegmentIndex == phase ) return s.Value;
    return 1f;                                   // past this phase, hold at end
}

not in v1 (deferred until a real use case lands): Timeline.PingPong(), Timeline.Scale(speed), a Parallel / Track composer. for speed, multiply elapsed at the call site (_anim.Seek( elapsed * speed ) or scale via TimelineAnimator.Speed).

AgePhase: slide-in / hold / fade-out by age

list entries that spawn, sit, then expire (toasts, notification stacks, log lines) share one lifecycle: slide in while young, hold while fresh, fade out once idle. AgePhase is the pure projection for that shape. it is a value-type sibling of Tween: you give it an entry's age and idle time, it returns normalized slide / fade / opacity in 0..1. no clock, no stored state, you own the timing.

construct one with the three durations (optional easings), then call Project per entry per frame and route the result:

using Goo;
using Goo.Animation;
using PanelTransform = Goo.PanelTransform;

static readonly AgePhase s_entry = new(
    slideInDuration: 0.25f, holdTime: 3f, fadeOutDuration: 0.5f );

Container Entry( ToastModel toast ) // toast carries its own Age + IdleAge clocks
{
    var p = s_entry.Project( toast.Age, toast.IdleAge );
    return new Container
    {
        Opacity   = p.Opacity,
        Transform = PanelTransform.TranslateX( Px.Of( (1f - p.Slide) * 32f ) ),
        Children  = { new Text( toast.Message ) },
    };
}
member what it is
SlideInDuration seconds the slide-in ramp takes (driven by age)
HoldTime seconds held fully visible before fade begins (driven by idleAge)
FadeOutDuration seconds the fade-out ramp takes
SlideEasing / FadeEasing easing for each ramp; default Easing.EaseOut / Easing.EaseIn
Project(age, idleAge) returns an AgeProjection { Slide, Fade, Opacity }

age is seconds since the entry spawned (drives slide-in). idleAge is seconds since the entry last saw input (drives hold + fade-out). for entries with no separate idle concept, pass age for both. the returned AgeProjection.Opacity is the composite (1 - Fade) * Slide, ready to drop straight into Opacity; Slide is the raw slide ramp (feed it a TranslateX/Y offset) and Fade is the raw fade ramp, exposed for cases that drive them independently.

the stylish HUD's kill feed and score popups (Code/Demo/StylishHud/StylishHudUI.cs) both run on this shape: a static AgePhase per list, per-entry Age/IdleAge clocks advanced in Tick, projection sampled in Build. expiry gotcha: when removing entries once they've faded, guard the opacity test with the entry's age, because a newborn's projection also reads near-zero opacity (the slide-in hasn't ramped yet):

var proj = FeedPhase.Project(e.Age, e.IdleAge);
if (proj.Opacity < 0.005f && e.Age > FeedPhase.SlideInDuration) _feed.RemoveAt(i);

without the Age guard, every entry is culled on the frame it spawns.

declared transitions

Everything above is the authored lane: you own a value, advance it in Tick, sample it in Build. This section is the other lane from the top of the page. Two values are engine-resolved and invisible to Build at the right time, so they get declared transitions instead: you state a duration on the blob, and the engine side animates the change.

col.Children.AddRange( _feed, ( i, entry ) =>
{
    var proj = FeedPhase.Project( entry.Age, entry.IdleAge );
    return new Container
    {
        Key              = $"feed-{entry.Id}",                          // stable identity, never the index
        LayoutTransition = new LayoutTransition( 180f, Easing.EaseOut ), // engine glides the restack
        Opacity          = proj.Opacity,                                 // authored: AgePhase fade
        Position         = PositionMode.Relative,
        Left             = Px.Of( (1f - proj.Slide) * 40f ),             // authored: AgePhase slide-in
        // ...
    };
} );

A second layout change mid-glide retargets smoothly from the current visual position. The glide moves the layout rect itself, so children and hit-testing follow; it never writes the transform channel, and authored Transform animations (an exit slide, for example) compose freely on the same blob. Declare it per child, not on the parent, and avoid nesting gliding containers: a gliding child inside a gliding parent compounds the smoothing.

Changing the transition's own Ms or easing while a glide is in flight finishes the current glide on the original curve; the new timing applies to the next glide. In practice the config is a constant, so this only matters if you swap it live.

This pair is a deliberately closed set: a property gets a declared transition only when the author cannot own the value. Everything else animates through the authored lane.

see also