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:
| Duration | Clock |
|---|---|
300ms or 0.3s | Elapsed milliseconds from the chassis's monotonic clock |
18 or 18f | Runtime 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.
| Curve | Character |
|---|---|
Linear | Constant progress through the segment |
Hold | Keep the first value until the segment ends |
InQuad | Begin slowly, then accelerate |
OutQuad | Begin quickly, then settle |
InOutQuad | Accelerate and then settle |
InBack, OutBack, InOutBack | Anticipation, 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:
| Value | Behavior |
|---|---|
| Numbers, sizes, rotations, colors | Continuous interpolation; colors use RGBA channels |
| Solid fills | Continuous RGBA interpolation |
| Gradient paints on fills and strokes, including solid↔gradient | Crossfade sampled paints in premultiplied RGBA; geometry and stop lists may differ |
TextStyle | Font size and fill interpolate; font selection, weight, style, underline, and alignment switch immediately |
CornerRadii | Each corner interpolates independently; zero is an angular corner |
| Vectors | Equal-length vectors interpolate element by element; a changed length switches immediately |
| Other data | Uses 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:
| Setting | Choices |
|---|---|
exit_mode | Flow 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.kind | Snap 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.