Animation and Motion

Motion can help someone follow a change: a new note arrives, a panel settles into place, or a list closes the space left by a removed item. Pax lets you describe that motion alongside the interface, with Rust controlling the state and actions that start it.

This chapter builds on Properties, Events, and Layout. Start with a property timeline, then give several tracks a shared playhead. Later sections cover enter/exit transitions and moving siblings in a Stacker.

For a hands-on preview, try Transition Grid: insert, remove, and reorder tiles while comparing their motion policies.

Timelines

A timeline is a sequence of property values at positions in time. Pax samples between those values as its playhead moves. Here, a small marker brightens and fades over a repeating 1.2-second cycle:

<Ellipse x=24px y=24px width=20px height=20px fill=rgb(56, 100, 78)
    opacity=@timeline {
        duration: 1.2s,
        loop: true,
        0%: 0.3, InOutQuad,
        50%: 1.0, InOutQuad,
        100%: 0.3,
    }
/>

This can go in a component's template with use pax_kit::*; in its Rust file. The inline timeline supplies the Ellipse's opacity. Each keyframe gives a marker, a value, and optionally an easing curve for the segment that leaves that keyframe. The first segment brightens from 0.3 to 1; the second fades back to 0.3.

Markers, duration, and loops

Write an explicit duration so the timeline's scale is clear:

DurationClock
300ms or 0.3sElapsed milliseconds from the chassis's monotonic clock
18 or 18fRuntime frame count

Use seconds or milliseconds when an interaction should take a particular amount of time across devices. A frame-based animation lasts for that many runtime ticks; its wall-clock duration depends on how those ticks arrive. Pax uses a nominal 60 frames per second when converting mixed authoring units, which is a conversion rule rather than a rendering-rate promise.

Markers can likewise be percentages, frame positions, or time values: 50%, 12, 12f, 150ms, or 0.15s. Percent markers make it easy to change the overall duration while preserving the rhythm. For time-based tracks, prefer percentages or matching time units; a bare 12 marker still means frame 12.

Ordinary timelines loop by default. Use loop: false to clamp the playhead to the range and keep the final value after the end. For a seamless loop, give the first and last keyframes matching values, as above. Frame loops include both frame zero and the final frame.

Without an explicit playhead, ordinary timelines sample the application's $frames or $millis clock. Inserting an element later does not give that ordinary timeline a fresh local clock. Use an enter transition for motion that should start when the element appears, or supply a playhead you control.

Easing

Easing changes how a value moves between two keyframes. A missing curve uses Linear.

CurveCharacter
LinearConstant progress through the segment
HoldKeep the first value until the segment ends
InQuadBegin slowly, then accelerate
OutQuadBegin quickly, then settle
InOutQuadAccelerate and then settle
InBack, OutBack, InOutBackAnticipation, overshoot, or both

OutQuad is a useful starting point for a small arriving surface; InOutQuad can soften a movement between two resting positions. Back curves can exceed the interval between the endpoints, so leave room for the overshoot and avoid applying it indiscriminately to bounded values such as opacity.

Interpolation also depends on the property type. Numeric values, sizes, rotations, and colors have interpolation behavior; arbitrary data does not necessarily have a meaningful in-between value. Path command lists do not currently interpolate their coordinates automatically. To change a shape, animate numeric parameters and derive its geometry from them, as in the mouse-animation example. For a reveal, animate a Path's draw_end; see Drawing.

Share a playhead

A named timeline can coordinate several properties on elements selected by ID or class. A shared playhead lets a button, a slider, or application logic drive the whole sequence together.

Here is a complete small playback example. In src/lib.rs:

#![allow(unused)]
fn main() {
use pax_kit::*;

#[pax]
#[main]
#[file("lib.pax")]
pub struct Example {
    pub playhead: Property<f64>,
}

impl Example {
    pub fn replay(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        self.playhead.cancel_transitions();
        self.playhead.set(0.0);
        self.playhead.ease_to(
            100.0,
            Duration::Milliseconds(900.into()),
            EasingCurve::Linear,
        );
    }

    pub fn pause(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        self.playhead.cancel_transitions();
    }
}
}

In src/lib.pax:

<Button x=24px y=24px width=110px height=40px label="Replay"
    @button_click=self.replay />
<Button x=148px y=24px width=110px height=40px label="Pause"
    @button_click=self.pause />
<Slider x=24px y=84px width=280px height=32px
    min=0.0 max=100.0 step=1.0 value=bind:self.playhead />

<Rectangle id=marker y=156px width=40px height=40px
    corner_radius=8 fill=rgb(56, 100, 78) />
<Text id=caption x=24px y=224px width=280px height=40px
    text="Ready for another page"
    style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />

@settings {
    #marker { x: 24px }
}

@timeline reveal {
    duration: 100,
    playhead: {self.playhead},
    loop: false,
    #marker {
        x: {
            0%: {$base}, OutQuad,
            100%: {$base + 200px},
        },
        rotate: {
            0%: -12deg, OutQuad,
            100%: 0deg,
        },
    },
    #caption {
        opacity: {
            0%: 0,
            40%: 0, OutQuad,
            100%: 1,
        },
    },
}

Replay advances playhead from 0 to 100 over 900 milliseconds. The template maps that one value to the marker's position and rotation and the caption's opacity. Pause stops the Rust-side easing at its current value. Press Pause before dragging the slider to scrub without an active animation writing to the same property.

The timeline's duration: 100 establishes a 0–100 frame-position range. Because playhead is supplied explicitly, it acts here as a convenient sampling scale; the Rust easing determines how long playback takes.

A numeric playhead uses the track's clock units: frames for a frame-based duration and milliseconds for a time-based duration, including one written in seconds. A duration-valued playhead such as {(self.seconds)s} carries its units through conversion. A numeric playhead is not automatically normalized to 0–1.

Tracks inherit the enclosing timeline's duration, playhead, and loop setting unless overridden on the track. Multiple named timelines can bind to the same property when separate groups need the same progress. Naming the timeline organizes its tracks; playback comes from its clock or playhead, rather than an implicit Rust method named after it.

Relative values and property ownership

$base means the value underneath this setting or timeline layer. In the playback example, #marker { x: 24px } supplies the base position, so the track moves from 24px to 224px. Changing that base moves the whole animation without repeating the layout value in every keyframe.

It does not mean the previous animation frame or the parent's property. Keyframe expressions remain reactive, so changing a dependency can change the sampled motion. See PAXEL's base values for the general model.

Keep one clear owner for each animated value. For ordinary selector timelines, an inline assignment to that same property takes precedence: adding x=24px directly to the marker above would hide the timeline's x track. Put the base in a settings rule, as shown, or assign a property timeline inline. Lifecycle transitions have their own overlay behavior; do not generalize this ordinary-settings rule to @in and @out.

Also avoid animating a state property while a handler continually sets it, or trying to ease a derived value whose formula keeps recomputing. Animate an owned source property and let its dependents follow.

Imperative easing

Rust can animate a property directly with ease_to. It replaces that property's pending transition queue and begins from its current eased value. ease_to_later appends another segment. For a component with a Property<f64> named strength, this handler rises, holds, and settles:

#![allow(unused)]
fn main() {
pub fn pulse(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
    self.strength.ease_to(
        1.0, Duration::Milliseconds(180.into()), EasingCurve::OutQuad,
    );
    self.strength.ease_to_later(
        1.0, Duration::Milliseconds(120.into()), EasingCurve::Linear,
    );
    self.strength.ease_to_later(
        0.2, Duration::Milliseconds(300.into()), EasingCurve::InOutQuad,
    );
}
}

Bind strength to the visual property you want to animate. Triggering the handler again replaces the unfinished pulse; it does not accumulate an ever-longer queue.

Passing a plain number to either easing method means frames. Duration::Frames(18.into()) is explicit frame timing; Duration::Milliseconds(300.into()) and Duration::Seconds(0.3.into()) express elapsed time.

cancel_transitions() stops the active segment, clears the queue, and leaves the current eased value in place. Call it before set(...) when an immediate edit should take ownership. A plain set does not cancel a previously queued animation.

Rust also accepts EasingCurve::Custom with a function. That is a Rust API; arbitrary custom easing closures are not part of template timeline syntax. See the animation API and property API for the full method and value-type contracts.

Enter and exit transitions

Use @in and @out for motion tied to an instance entering or leaving the mounted tree. Their clocks start locally at the transition, and playback is finite. Newly entering content can animate while old content is still finishing its exit.

For a reusable note surface, declare this component in src/note_card.rs, then add pub mod note_card; and use note_card::NoteCard; to src/lib.rs:

#![allow(unused)]
fn main() {
use pax_kit::*;

#[pax]
#[file("note_card.pax")]
pub struct NoteCard {
    pub title: Property<String>,
}
}

In src/note_card.pax:

<Text x=16px y=16px width={100% - 32px} height={100% - 32px}
    text={self.title}
    style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />
<Rectangle width=100% height=100% corner_radius=12
    fill=rgb(237, 241, 226) />

@settings {
    @in: enter
    @out: exit
}

@timeline enter {
    duration: 300ms,
    self {
        opacity: { 0%: 0, OutQuad, 100%: {$base}, },
        y: { 0%: {$base + 16px}, OutQuad, 100%: {$base}, },
    },
}

@timeline exit {
    duration: 220ms,
    self {
        opacity: { 0%: {$base}, InQuad, 100%: 0, },
        y: { 0%: {$base}, InQuad, 100%: {$base - 12px}, },
    },
}

Here self targets the NoteCard instance itself, including its position within the calling template. The named timelines can also use #id or .class selectors for elements inside NoteCard's template.

Add a Property<bool> named show_note to a parent and toggle it from an event handler. This conditional placement gives the card something to enter and leave:

if self.show_note {
    <NoteCard x=24px y=96px width=280px height=84px title="Moss and rain" />
}

Retention and interrupted motion

Removing the card from the conditional starts its exit. Pax retains the mounted instance until its exit finishes, then unmounts it. The application state has already changed; the retained visual gives the change time to read. Cleanup tied to unmount happens at actual unmount, not at the first request to leave.

The runtime enforces a five-second exit timeout to avoid retaining a node indefinitely. An exit that exceeds that limit can be truncated with a warning. Keep lifecycle exits short; use ordinary playback for a longer presentation.

Toggle the same conditional back before the exit completes and Pax can rescue its still-mounted instance. The default interruption: Takeover starts the destination transition from the currently sampled property value. The destination's remaining keyframes, duration, and easing still apply. This preserves value continuity; it does not promise the velocity continuity of a physical spring.

Set interruption: Restart in the destination timeline when it should start from its authored first value instead. The option belongs beside duration in either a named or inline lifecycle timeline. It affects direct enter/exit reversals on the same mounted instance, without changing $base or ordinary timeline playback.

Conditional and route branches can reuse retained instances during a reversal. In repeated lists, stable keys make that identity explicit: reordering a retained item keeps its instance, and removing its key can start an exit. A newly allocated, unrelated instance has no earlier motion to take over. See Lists and identity and Routing.

Element-level transitions

For a local effect, put property tracks directly in an element's inline transition:

<Group x=24px y=24px width=280px height=84px
    @in=@timeline {
        duration: 300ms,
        opacity: { 0%: 0, OutQuad, 100%: 1, },
    }
    @out=@timeline {
        duration: 220ms,
        opacity: { 0%: 1, InQuad, 100%: 0, },
    }
>
    <Text x=16px y=16px width=248px height=52px text="A brief note"
        style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />
    <Rectangle width=100% height=100% corner_radius=12
        fill=rgb(237, 241, 226) />
</Group>

Element bindings also accept names, such as @in=panel_enter. That timeline belongs to the containing component: self addresses the element that carries the binding, while selectors can reach other elements in the same containing template. A reusable component's own settings-level transition, like NoteCard's, targets its own template.

Container-owned motion

A child's enter/exit transition controls its visual motion. Stacker separately controls the space allocated to children and how that layout changes. There are two independent choices:

SettingChoices
exit_modeFlow keeps exiting children in normal layout until exit completes. Ghost holds their previous frames as overlays while active children lay out without them.
reflow_transition.kindSnap moves immediately to the new layout. Ease animates between layout frames.

The defaults are Flow and Snap. To let surviving notes move into an exiting note's space immediately, choose Ghost with Ease. For a parent whose notes property contains items with stable id and title fields:

<Stacker x=24px y=24px width=280px height=320px gutter=12px
    exit_mode=ContainerExitMode::Ghost
    reflow_transition={
        kind: ContainerReflowTransitionKind::Ease
        frames: 18
        curve: ContainerReflowCurve::OutQuad
        name: ""
    }>
    for note in self.notes key note.id {
        <NoteCard title={note.title} />
    }
</Stacker>

Stacker reflow currently uses frames, not a duration field. Its ContainerReflowCurve choices mirror the built-in easing names above. ContainerReflowTransitionKind::Named is reserved and is not implemented as a named-timeline lookup.

Try it: Transition Grid

Choose Remove to watch a tile leave. Switch from Flow to Ghost and remove another: compare when the remaining tiles begin moving into its space. Snap and Ease change how those remaining tiles reach their new positions.

Next, tap a tile to change its accent color and increment its counter, then choose Reverse. The loop uses key cell.id, so each tile keeps its identity as its position changes. A fifth tap replaces that tile with a new one at the end of the list. Insert adds a fresh tile at the beginning. The tile panel scrolls independently, keeping the policy controls in reach as the list grows.

The source tabs show the container policies in lib.pax, the child's enter and exit timelines in cell_button.pax, and the Rust handlers that update the collection. Restart reloads the whole example; Open standalone gives it a separate page.

Use keys and exercise rapid changes as well as settled states. A ghost may overlap its moving siblings while exiting, so consider clipping and visual order in the surrounding composition. Container reflow controls layout frames; animate a nested surface when you want a separate scale or flourish without making it responsible for the container's placement.

Paths, interaction, and motion choices

A playhead can come from more than a clock. A slider, pointer position, scroll position, or Rust simulation can supply the value that several tracks sample. Keep the mapping explicit and bounded: translate the input into the timeline's position range, then let the tracks own the visuals.

The marionette example uses shared and per-part playheads; pax-logo exposes progress for coordinated vector motion; and mouse-animation derives a moving shape from a parametric path. timeline-playground explores longer sequences and mixed duration units. These are deeper source references after the small examples here.

For handwriting or a drawn-line reveal, animate draw_start and draw_end on Path or Handwriter. Drawing owns the path geometry and reveal limits; Scrolling owns scroll positions and viewports. Animation supplies the changing value.

Choose motion that preserves the interface's meaning when paused or skipped. Keep long decorative sequences user-driven, and provide an application setting that can select a stable final state or a shorter transition when needed. Pax does not currently expose a unified reduced-motion preference in its public platform API; do not assume the examples automatically adapt to the operating system's preference.

The core timeline and property systems are shared by web, macOS, iOS, and iPadOS. The animated property still has its own renderer and native-control limits, and frame scheduling varies by target. Verify the finished interaction on the targets you ship rather than inferring visual parity from a successful web run.

Read more

Continue with Compositing and Effects for clipping, masking, and mixed native/rendered surfaces. Layout and the Stacker API cover ordinary container sizing. Events explains the handlers and lifecycle that drive state changes, while Accessibility and Native Controls covers keyboard and control behavior.