Motion
gpui-base owns deterministic motion sampling and lifecycle while leaving every visual choice to the application. It provides stable keyed state, interruption, reversal, animation-frame requests, and reduced-motion behavior without imposing product timing or styling.
Run the interactive companion for this guide:
cargo run -p gpui-base-examples --bin motion
The example contains five separate demos. Use the tabs at the top to inspect one capability at a time.
#Capability map
| Demo | API | What it demonstrates |
|---|---|---|
| Sliding time | transition | Four independently rolling digits, 08:00–20:00, with targets changing faster than the transition settles |
| Spring | spring | A segmented-control indicator that preserves velocity when rapidly retargeted |
| Keyframes | Keyframes, Timing, animate_keyframes | A repeating multi-stop activity signal |
| Stagger | Stagger | Allocation-free timing offsets across a list |
| Presence | Presence | Exit animation that keeps content mounted until it becomes absent |
| Sequence | Sequence | Three chained steps — slide in, fill, rest then fade — each starting when the last one ends |
The library also exposes Easing, Discrete, MotionTransform, and MotionReveal. They compose with the same primitives rather than requiring separate animation runtimes.
#Target transitions
Use transition for a value moving toward a target over a known duration. Every independently animated value needs a stable ID.
let opacity = transition(
("save-dialog", "opacity"),
if open { 1.0 } else { 0.0 },
Transition::new(Duration::from_millis(180)).easing(Easing::EaseOut),
window,
cx,
);
Retargeting starts at the currently sampled value. Direct reversal shortens the return duration, so reversing early does not spend a full duration retracing a short distance. transition_with_status additionally returns Idle, Delayed, Running, or Finished.
Easing includes CSS keyword curves, cubic Bézier curves, all CSS step positions, and piecewise linear() stops. Invalid parameters return typed errors.
#Springs
Use spring when the target may change while moving. It preserves both position and velocity, which makes it suitable for selection indicators and settling spatial values.
let x = spring(
"selected-indicator",
selected_x,
Spring::new(Duration::from_millis(420)).with_damping(0.72),
window,
cx,
);
Do not make a pointer-controlled value chase the pointer through a spring. Set with_travel(false) during direct manipulation and restore travel after release.
with_damping requires a finite, non-negative ratio; with_epsilon requires a finite value greater than zero and interprets it in the target’s own units. The builders panic for invalid trusted constants. Use try_with_damping and try_with_epsilon for configuration or user-provided values. Normalized values normally keep the 0.001 default; pixel motion can use a coarser tolerance such as 0.1.
#Keyframes and timing
Keyframes describes validated value stops. Timing uses absolute elapsed time and supports signed delays, finite or infinite iterations, and normal, reverse, or alternating playback.
let frames = Keyframes::try_new([
Keyframe::new(0.0, 0.25),
Keyframe::new(0.45, 1.0).ease(Easing::EaseOut),
Keyframe::new(1.0, 0.25),
])?;
let opacity = animate_keyframes(
"activity",
&frames,
Timing::new(Duration::from_millis(1400))
.iterations(IterationCount::Infinite),
window,
cx,
).value;
Offsets must start at 0, end at 1, and be monotonic. Use Discrete when a value cannot be interpolated.
animate_keyframes retains its playback start time under the supplied stable ID. Re-rendering with the same ID continues the current sequence. To replay it, include an application-owned generation in the ID, such as ("notification-enter", generation), and increment that generation for each replay.
#Presence and stagger
Presence separates logical visibility from physical mounting. Its phases are entering, present, exiting, and absent. Render while should_render() is true and use progress for the chosen visual properties. Reopening during exit reverses from the current sample.
Stagger calculates a delay for an index from the first, last, center, or a chosen origin. It does not allocate a schedule or own list identity:
let stagger = Stagger::new(Duration::from_millis(80), StaggerOrigin::First);
let delay = stagger.delay(index, item_count);
#Sequences
Sequence chains transitions so each step starts when the previous one ends. It begins at from on the first frame it is sampled and plays once per ID; its sample reports the value, the step being played, and a MotionStatus that reads Finished only after the last step.
let opacity = Sequence::new(("toast", "opacity"), 0.0)
.with_step(1.0, Transition::new(Duration::from_millis(160)))
.with_step(0.0, Transition::new(Duration::from_millis(200)).delay(Duration::from_secs(3)))
.sample(window, cx);
div().opacity(*opacity.value())
A step ends at an absolute instant and the next one starts there, not on the frame that noticed it, so a skipped frame does not start a step late. Zero-duration steps complete within one frame. Changing the target of the step being played restarts the sequence from its first step at the value sampled at that instant; steps not yet reached are read when the sequence gets to them. To replay, put an application-owned generation in the ID. Reduced motion adopts the last target at once with no pending frame.
Stagger composes with a sequence as a delay on its first step:
Sequence::new(("row", index), px(12.))
.with_step(px(0.), Transition::new(Duration::from_millis(120)).delay(stagger.delay(index, count)))
.sample(window, cx)
#Measured reveal
MotionReveal measures a child at its natural size and clips its visible height by progress. Collapsible::motion_id(...) is the convenient control-level facade. Without a motion ID, the control keeps immediate mount/unmount behavior.
#Reduced motion and performance
Transitions, springs, keyframes, presence, and reveal-compatible controls honor GPUI’s reduced-motion preference. Finite motion snaps to the target, synchronizes retained state, and leaves no pending animation frame. Motion must never be the only way state is communicated.
The preference is the operating system’s. gpui_base::init (and so gpui_component::init) reads the system setting into App::set_reduce_motion — macOS’s “Reduce motion” (NSWorkspace.accessibilityDisplayShouldReduceMotion), Windows’ “Animation effects” (SPI_GETCLIENTAREAANIMATION, off means reduce), and on Linux the XDG desktop portal’s org.freedesktop.appearance reduced-motion key, which arrives over D-Bus a moment after init and is then followed as it changes. Other targets, wasm included, leave the flag alone. An application that calls cx.set_reduce_motion(...) itself owns the flag from then on: Base only writes it while it still holds what Base last wrote. macOS and Windows are read once, at init; call gpui_base::apply_system_reduce_motion(cx) to read them again.
The pure steady sampling paths measured by the benchmark—timing/easing, keyframe lookup, analytic spring integration, and stagger delay calculation—are allocation-free. Keyed transition, spring, presence, and reveal lifecycles are covered by GPUI retained-state and frame-request tests because those updates belong to the framework lifecycle rather than the pure sampler. Sampling uses absolute elapsed time, and keyframe lookup uses binary search. Run the release benchmark with:
cargo bench -p gpui-base --bench motion
Choose the smallest suitable primitive: transition for duration-based targets, spring for changing spatial targets, keyframes for authored sequences, Presence for exit-before-unmount, Sequence for steps that follow one another, and Stagger for list choreography.
#Benchmark results
Measured on Linux x86_64 with a release build, 31 batches, and 200 iterations per batch:
| Workload | Median | P95 | Worst | Allocations |
|---|---|---|---|---|
| 1,000 scalar timing + easing samples | 26.490 µs | 26.567 µs | 27.290 µs | 0 |
| 1,000 keyframe samples, 2 frames | 21.656 µs | 21.707 µs | 21.729 µs | 0 |
| 1,000 keyframe samples, 8 frames | 25.197 µs | 25.251 µs | 25.269 µs | 0 |
| 1,000 keyframe samples, 32 frames | 27.932 µs | 27.969 µs | 27.971 µs | 0 |
| 1,000 analytic spring integration samples | 6.042 µs | 6.106 µs | 6.216 µs | 0 |
| 1,000 stagger delay calculations | 0.574 µs | 0.583 µs | 0.587 µs | 0 |
The scalar timing/easing workload remains below its 100 µs median budget. These figures are a reproducible development baseline rather than a cross-platform guarantee; run the benchmark on each target platform when platform-specific performance matters.