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.

A property change applies immediately unless you give it an explicit animation or transition. Native static text follows the same rule: changing a theme's text color does not request an extra Core Animation crossfade on iOS or macOS. Pax samples the values for explicit timelines, including opacity and movement on entering or leaving components.

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_kit::runnable! { root: crate::Example }

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

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

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

Reset and ease through the same local view so the transition starts at the new value immediately. A shared Property::set publication reaches the local view at the next UI settlement boundary; mixing that reset with an immediate local ease_to can start from the previous position.

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.

Automatic settings transitions

An ImportSettings instance can ease changes to the settings it exports. For example, with LightTheme and DarkTheme components that export .surface rules and a boolean is_dark property on the host:

<ImportSettings transition=SettingsTransition::Ease(400ms, TransitionCurve::InOutQuad)>
    if self.is_dark {
        <DarkTheme />
    } else {
        <LightTheme />
    }
</ImportSettings>
<Rectangle class="surface" width=100% height=100% />

Use use pax_kit::*; in the Rust module. The default is SettingsTransition::None, which applies changes immediately. Ease accepts the same time or frame durations and built-in curves as other Pax motion, with the curve written as TransitionCurve::Linear, TransitionCurve::OutQuad, and so on. Zero, negative, and non-finite durations apply the target immediately.

The first resolved appearance snaps into place, including components inserted by input, lifecycle handlers, or a clock-driven conditional. Their imported settings resolve during mounting, so an entrance fade uses the current theme from its first frame rather than fading from primitive defaults. After that, changing a provider's reactive values, replacing a provider, or changing selector membership animates each affected receiver property toward its new effective value. A change hidden by a later settings layer does not start motion. Interrupting motion samples its current value and starts a fresh transition with the full duration. An unchanged target does not restart it. Receivers keep independent playback state, even when they share a theme provider.

The winning import supplies the policy. Removing its setting uses the outgoing policy to return to the surviving local value or default. An incoming import with None applies immediately, and switching an active import to None finishes its motion immediately. Changing only a nonzero duration or curve affects the next target change. The outer import's policy also applies to settings exported through its transitive providers.

Interpolation follows each property's Interpolatable implementation:

ValueBehavior
Numbers, sizes, rotations, colorsContinuous interpolation; colors use RGBA channels
Solid fillsContinuous RGBA interpolation
Gradient paints on fills and strokes, including solid↔gradientCrossfade sampled paints in premultiplied RGBA; geometry and stop lists may differ
TextStyleFont size and fill interpolate; font selection, weight, style, underline, and alignment switch immediately
CornerRadiiEach corner interpolates independently; zero is an angular corner
VectorsEqual-length vectors interpolate element by element; a changed length switches immediately
Other dataUses the type's interpolation implementation, which may be discrete

Paint crossfades preserve each endpoint's gradient instead of pairing stops. Transparent colors mix with their alpha premultiplied, then the resulting paint is composited once. This is separate from group opacity and blend modes. Paint mix weights stay between zero and one, including with overshooting curves; colors retain the renderer's existing RGB encoding rather than switching to HSL or linear-light interpolation. Existing gradient limits still apply: the GPU uses at most eight stops per endpoint. Radial gradients resolve the same focal geometry in GPU color fills, painted masks, and the Piet browser fallback.

An interrupted crossfade starts with the visible mixture. Repeated toggles between a fixed set of themes combine repeated endpoints, so their paint cost stays bounded by that set. Continually introducing distinct gradients before motion completes retains those visible endpoints; sampling cost grows with their count until a transition finishes. WebGPU mixes them in the paint shader. The Piet browser fallback accumulates them in a reusable canvas limited to the current surface, then clips and draws the resulting paint once.

examples/paxflix demonstrates the 400 ms policy on every visual component's CinemaTheme import. Open the profile menu and toggle Dark/Light mode; text, surfaces, outlines, artwork fades, and the hero's radial highlight transition while the catalog and scroll positions remain mounted. Toggle again during a fade to reverse it continuously.

Common layout settings also participate. Unset positions, padding, rotation, scale, and opacity interpolate using their layout defaults. Switching width or height between a value and intrinsic sizing is discrete. Native text still uses the chassis's text rendering; glyph shapes do not morph, and existing platform font and fill restrictions continue to apply.

An inline value, two-way binding, explicit property timeline, or lifecycle transition retains ownership of its property. Avoid imperatively easing the same receiver property while automatic settings motion owns it. Animate a separate source or use an explicit timeline when you need that control.

This API applies one transition to all changed properties. Imported named timelines, per-property transition policies, and an @change lifecycle hook are not implemented. Receiver-owned playback and preserved settings provenance leave room for those additions without sharing clocks between theme consumers. The behavior is the same in debug and release cartridges; release builds do not gain hot reload from this feature.

The canonical runtime-settings-themes example lets you switch color, typography, and corner providers independently and interrupt their motion.

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.local().ease_to(
        1.0, Duration::Milliseconds(180.into()), EasingCurve::OutQuad,
    );
    self.strength.local().ease_to_later(
        1.0, Duration::Milliseconds(120.into()), EasingCurve::Linear,
    );
    self.strength.local().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.

Animate around viewport crossings

A local @viewport_proximity_change handler can compare previous/current intersection heights and ease a property bound to a named timeline's playhead. This supports first-visible reveals, an early departure at 40px remaining, and restoration when direction reverses. Bind @viewport_proximity_enter and @viewport_proximity_exit for preparation and cleanup.

Observe a stable wrapper and animate its contents to avoid feeding animated bounds back into the threshold. These events observe mounted geometry; they do not start structural @in/@out transitions or retain unmounted nodes. See viewport proximity for the runnable viewport-proximity example, sampling limits, and handler code.

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 (its reusable AnimatedPaxLogo also accepts background_mode=LogoBackgroundMode::Light or Dark to invert the signboard against the surrounding surface); 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.

The reusable logo enables click_to_replay by default: clicking or tapping restarts its 1440ms motion through the same progress property, even mid-play. Use progress=bind:logo_progress when a parent scrubber should stay synchronized. Set click_to_replay=false when an external timeline owns playback, or for an inert finished pose:

<AnimatedPaxLogo width=920.72px height=463.95px
    progress=1.0 click_to_replay=false/>

AnimatedPaxLogo and LogoBackgroundMode are in pax-std and are re-exported by pax_kit; use pax_kit::*; makes them available to a component's template. Scale an enclosing Group from the original 920.72 × 463.95 coordinate system. Click replay does not start animation on mount; the parent owns initial playback. For a settled drawing without animation masks or click behavior, use StaticPaxLogo with the same size and palette properties. The pax-logo starter owns only the surrounding scene and controls; no example-crate dependency is needed.

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.

Appearance stack transitions

Fill and stroke stacks transition independently and match entries by position. Equal-length stacks interpolate each pair's paint, material, opacity, and stroke width; cap and join switch at the end. Changing the list length switches to the destination immediately, matching other Vec transitions. Reordering changes the contents of positions, without matching stable layer identities.

Paint crossfades remain one sampled paint, including linear-to-radial changes and unequal stop counts. They are not additional source-over layers. GPU paint/material/opacity edits reuse geometry. Visible strokes and captured alpha-mask source strokes use the same retained geometry for reveal progress; a changing mask reveal redraws its texture without rebuilding CPU outlines. Width/cap/join changes may require new tessellation. See Paint layers for the full value model.

Experimental shader containers expose ordinary properties for effect parameters. A phase property can drive surface-anchored brush motion; stopping that phase does not stop changes in the captured source. Filters do not introduce a separate animation clock.