Skip to content

UI Animation and Transitions

Mike Strobel edited this page Jun 26, 2026 · 1 revision

Animation & transitions

Cursorial.UI's animation layer drives property values over time — fading an Opacity, sliding a render offset, pulsing a color. It sits on top of the pure Animation engine (IAnimation<T>: elapsed → value) and adds the orchestration WPF/Avalonia authors expect: a frame-aligned scheduler, imperative BeginAnimation, reusable storyboards you ignite from styles, and implicit transitions that fade a property whenever its base value changes. Reach for it whenever a value should move to its new state instead of snapping.

If you've used WPF, the vocabulary maps cleanly: BeginAnimation/Storyboard/AnimationTrack (≈ WPF's Timeline), BeginStoryboard/StopStoryboard triggers, and Avalonia-style implicit Transitions.

How the clock works

You don't tick anything yourself. UIApplication's frame loop owns a thread-ambient AnimationScheduler and a FrameClock that is frozen once per frame — every animation sampled in a frame sees the exact same timestamp, so a storyboard's parallel tracks stay in lockstep. An idle UI (no running animations, no pending input) parks and burns no CPU; the scheduler keeps the loop awake exactly while animations are live.

That means the animation API is only meaningful inside a running UIApplication. Begin/stop are UI-thread operations (they assert thread affinity).

Imperative animation: BeginAnimation

The simplest entry point is the BeginAnimation extension on any UIObject. You hand it a styled property and an IAnimation<T> (the two-point DoubleAnimation/ColorAnimation/Int32Animation, a keyframe animation, or your own); it returns an AnimationHandle.

using Cursorial.Animation;   // DoubleAnimation, Easings
using Cursorial.UI;          // BeginAnimation extension, AnimationStartOptions

// Fade a panel from invisible to fully opaque over 250 ms.
AnimationHandle handle = panel.BeginAnimation(
    UIElement.OpacityProperty,
    new DoubleAnimation(from: 0.0, to: 1.0, duration: TimeSpan.FromMilliseconds(250), Easings.CubicOut));

The animation writes at Animation priority — above local values, styles, and bindings — so it visibly wins for as long as it runs. Beginning a new animation on the same (target, property) retires the running one first (no fight). AnimationStartOptions tunes the run:

  • BeginTime — a stagger before the animation starts touching the property.
  • FillFillBehavior.HoldEnd (default; the end value holds at Animation priority, costing nothing per frame) or FillBehavior.Stop (retract at the end so the underlying base value resurfaces).
  • Handoff — how a re-begin combines with a still-running run (SnapshotAndReplace is the only policy).
panel.BeginAnimation(UIElement.OpacityProperty,
    new DoubleAnimation(1.0, 0.0, TimeSpan.FromMilliseconds(180)),
    new AnimationStartOptions(BeginTime: TimeSpan.FromMilliseconds(50), Fill: FillBehavior.Stop));

The handle

AnimationHandle is your control surface for the live run:

  • Stop() — retract immediately; the base value resurfaces. (No Completed.)
  • Pause() / Resume() — hold the value; resume from where it paused, with no time jump.
  • Seek(TimeSpan offset) — jump to a point on the animation's own timeline (works while paused).
  • SkipToEnd() — snap to the end value and complete (throws for a perpetual animation).
  • Completed — an event raised at most once, on the UI thread, when a finite animation reaches its end naturally. It does not fire on Stop(), on detach, or on shutdown.
  • State — the lifecycle (DelayedRunningHolding/Completed/Stopped, or Paused).

To stop without a handle, target.StopAnimation(property) retracts whatever is running on that property.

Storyboards: many tracks, one timeline

A Storyboard is a reusable, declarative bundle of AnimationTracks that play in parallel against a common frozen clock. Each track targets one property; the storyboard is a description you can share — beginning it creates a fresh per-igniter instance, so two elements (or two rules) using the same storyboard resource never interfere.

var slideIn = new Storyboard();

// Slide a render offset in, easing to rest.
slideIn.Children.Add(new DoubleTrack
{
    TargetProperty = UIElement.RenderOffsetColumnProperty,
    To = 0.0,                                    // From unset ⇒ snapshots the current value at start
    Duration = TimeSpan.FromMilliseconds(500),
    Easing = Easings.CubicOut,
});

// ...and fade it in, as part of the SAME group.
slideIn.Children.Add(new DoubleTrack
{
    TargetProperty = UIElement.OpacityProperty,
    From = 0.0,
    To = 1.0,
    Duration = TimeSpan.FromMilliseconds(400),
});

StoryboardHandle h = slideIn.Begin(card);   // begin on a scope element

Track types are sealed leaves per value type: DoubleTrack, Int32Track, ColorTrack, BrushTrack, RectTrack, SizeTrack, MarginsTrack. Each is an AnimationTrack<T> and exposes:

  • TargetProperty — required; a StyledProperty<T> whose type matches the track's T.
  • TargetName — optional; resolves a named descendant in the begin scope (template parts included). Null means the scope element itself.
  • From / To — the two-point form. From is Optional<T>: leave it unset to snapshot the property's current value when the track starts (so a hover-in/hover-out pair picks up wherever the value is now). To is required for the two-point form.
  • Duration and Easing (null ⇒ linear).
  • BeginTime — a per-track stagger inside the storyboard.
  • Repeat (a RepeatBehaviorOnce, RepeatBehavior.Count(n), or RepeatBehavior.Forever) and AutoReverse (play forward then backward each iteration).
  • FillHoldEnd (default) or Stop.
  • Escape hatches: Keyframes (an IList<Keyframe<T>>) or a code-built Source animation, each mutually exclusive with the two-point form.

A storyboard seals on first Begin (or when a referencing Style seals) — validation runs then, and mutating Children afterwards throws. StoryboardHandle mirrors AnimationHandle: Stop/Pause/Resume/Seek/SkipToEnd/Completed/IsCompleted. Storyboard.Stop(scope) stops the imperatively-keyed instance.

Igniting a storyboard from a style

Styles carry edge actions that fire when a rule activates (Style.Enter) or deactivates (Style.Exit) — e.g. when a :pointerover rule starts or stops matching. BeginStoryboard and StopStoryboard are the two animation edge actions. They are do/undo: a BeginStoryboard in Enter begins; the same action placed in Exit (or a matching StopStoryboard) stops.

var pulse = new Storyboard();
pulse.Children.Add(new DoubleTrack
{
    TargetProperty = UIElement.OpacityProperty,
    From = 1.0,
    To = 0.35,
    Duration = TimeSpan.FromMilliseconds(550),
    Easing = Easings.SineInOut,
    Repeat = RepeatBehavior.Forever,
    AutoReverse = true,
});

var hover = new Style(Selectors.Class("alert"));     // a :pointerover rule, say
hover.Enter.Add(new BeginStoryboard { Storyboard = pulse });  // hover-in  ⇒ begin the perpetual pulse
hover.Exit.Add(new StopStoryboard  { Storyboard = pulse });   // hover-out ⇒ stop it; base Opacity resurfaces

Edge-ignited begins are no-throw: an unresolvable target/property routes to AnimationDiagnostics and the failing track is skipped, so siblings still play. (Imperative Storyboard.Begin throws instead — you asked for it directly.) Each (action, element) pair gets its own instance, so the same storyboard resource shared across many cards never tangles.

Implicit transitions

A Transition fades a property automatically whenever its base value changes — a style flip, a local set, a binding update. You don't begin it; you attach it once and it watches. This is Avalonia's implicit- animation model: declare "animate Opacity over 220 ms" and any change to Opacity fades instead of snapping.

Transition.SetTransitions(card, new TransitionCollection
{
    new DoubleTransition(UIElement.OpacityProperty)
    {
        Duration = TimeSpan.FromMilliseconds(220),
        Easing = Easings.CubicInOut,
    },
});

Now a rule like .card:pointerover { Opacity: 1.0 } over a resting Opacity: 0.6 produces a smooth fade in on hover-enter and back out on leave — the transition turns each base change into a short Animation-priority run from the old value to the new (and from the live interpolated value if a fade is already in flight, so rapid hover in/out reverses cleanly rather than jumping).

Transition leaves match the track types: DoubleTransition, Int32Transition, ColorTransition, BrushTransition, MarginsTransition. Each takes the StyledProperty<T> it animates and exposes Duration (default 150 ms), Delay, and Easing (default linear). Attach them as a TransitionCollection through the attached Transition.TransitionsProperty (SetTransitions / GetTransitions) — it's style-settable, so a theme can declare hover fades centrally. The collection seals when armed.

One rule worth knowing: the initial application of a value doesn't transition. A transition fades changes, not the element's first appearance — a freshly-shown control snaps to its resting look and fades only on subsequent changes.

Reduced motion

AnimationScheduler.AnimationsEnabled is the global motion switch (default true). Set it to false and:

  • new finite animations snap straight to their end value;
  • new perpetual animations are born stopped;
  • a live true → false flip collapses everything in flight at the next frame; transitions stop igniting.
AnimationScheduler.Current.AnimationsEnabled = false;   // honor a "reduce motion" preference

Because transitions consult this flag before each ignite, flipping it off makes every implicit fade an instant swap with no further code.

See also

  • Animation — the pure, time-free engine underneath: IAnimation<T>, easings, keyframes, and the interpolator registry.
  • Styling & themes — selectors, pseudo-classes, and the Style.Enter/Exit edges that ignite storyboards.
  • Input & focus — where :pointerover/:focus state changes originate.
  • Controls — controls like the indeterminate ProgressBar whose looks ride these primitives.

Clone this wiki locally