A complete motion system for WGPUI — implicit CSS-style transitions, physical springs, keyframe timelines, gesture variants and exit animations, rebuilt from first principles as a modular effects architecture.
use wgpui_motion::prelude::*;
// That's the whole animation. Change styles between renders;
// everything tweens automatically.
div()
.id("panel")
.motion("panel")
.duration(240.ms())
.curve(ease_out())
.variant_when(open, |s| s.w(relative(0.35)).bg(ACCENT))
.variant_when(!open, |s| s.w(px(48.)).bg(SURFACE))Pinned against upstream WGPUI by git hash (includes the tiny additive patch that makes Background/TextColor internals public, enabling transmute-free color interpolation):
[dependencies]
log = "0.4"
wgpui = { package = "gpui-ce", git = "https://github.com/Far-Beyond-Pulsar/WGPUI", rev = "afcb6bf3c73ed5a33077dc8544d7b6db9cb90619" }
wgpui-motion = { git = "https://github.com/TridentForU/WGPUI-Motion" }You never say "animate this". You style elements differently as state changes, and the orchestrator diffs the targets each render:
div().id("card")
.motion("card")
.duration(180.ms()) // shorthand: default ease-out
.when(selected, |m| m.variant_when(true, |s| s.bg(SELECTED_BG)))
.variant_when(dragging, |s| s.opacity(0.7))Every numeric difference between last frame's target and this frame's target becomes an effect. Interruptions are free: a retargeting tween restarts from the current displayed value, exactly like CSS. .when / .unless are FluentBuilder-style build-time conditionals on the wrapper itself; .variant_when overlays animate in and out automatically.
.transition(Transition::spring(Spring::bouncy()))Springs are integrated per-scalar with real velocity state (fixed substeps ≤ 1/240 s). Interrupt one mid-flight and momentum carries into the new target — velocity is estimated by finite differences across frames and handed to the fresh spring. Presets:
| Preset | k / c | Feel |
|---|---|---|
Spring::DEFAULT |
170 / 26 | Framer Motion's default |
Spring::GENTLE |
120 / 14 | soft settle, panels |
Spring::SNAPPY |
300 / 22 | quick, hint of overshoot |
Spring::BOUNCY |
180 / 12 | playful, visible bounce |
Spring::STIFF |
420 / 32 | near-critical, press feedback |
Spring::RUBBER |
140 / 11 | slow and rubbery |
Or derive from physics: Spring::damping_ratio(0.8, 260.), or full control with Spring::mass_spring(mass, ratio, stiffness).
div().id("btn").motion("btn")
.transition(Transition::spring(Spring::snappy()))
.while_hovered(|s| s.mb(px(-2.)).bg(HOVER_BG)) // builder convention
.while_pressed(|s| s.opacity(0.75))
.child("Save") // children after the wrapGesture flags live in a GC'd global store keyed by element id; handlers attach to the wrapped element (on_hover, on_mouse_down/up/out) and call window.refresh() — no manual state plumbing in your view.
// @keyframes, in Rust
Timeline::new(900.ms())
.curve(sine_in_out())
.looping() // or .repeat(n) / .alternate_looping()
.key(0.0, Params::new().opacity(0.25))
.key(0.5, Params::new().opacity(1.0))
.key(1.0, Params::new().opacity(0.25))Attach with .timeline(...) (plays on mount), replay on demand with .replay_timeline_when(condition) — it fires on every false → true edge.
Keep an element mounted while it animates out; remove it from your model when motion settles:
div().id(("toast", i)).motion(("toast-motion", i))
.exit_when(leaving, |s| s.opacity(0.).h(px(0.)).mt(px(-4.)))
.on_exit_complete(cx.listener(move |this, _: &(), window, cx| {
this.toasts.remove(i); // removal happens HERE
cx.notify();
})).exiting(cond)marks an element as leaving; every style diff toward your collapsed pose animates exactly like any other transition..exit_when(cond, pose)is sugar for.exiting(cond)+ a matching variant overlay..on_exit_complete(cb)fires once per arming after all effects settle. It is deferred to the end of the effect cycle, so mutating state and callingcx.notify()inside is safe. Flip the condition back before settling to cancel..on_settle(cb)fires on every non-zero → zero animation transition, exits or not.- Callbacks are listener-shaped (
Fn(&(), &mut Window, &mut App)), socx.listener(...)closures drop straight in. - Exit presets ship alongside entrances:
fade_out,pop_out,slide_out_up/down/left/right.
Multi-number properties expose their sub-fields individually — animate just the alpha of a background like you'd animate background-color's alpha on the web:
Params::new()
.opacity(0.8) // single number
.width_px(220.) // one axis of size
.radius_top_left(24.) // ONE corner
.margin_bottom(-16.) // ONE edge
.shadow_blur(30.) // one field of box_shadow[0]
.shadow_at(1, |s| s.blur(24.).alpha(0.4)) // SECOND shadow
.stop_at(1, |s| s.hue(0.9).position(0.8)) // ONE gradient stop + offset
.bg_alpha(0.4) // ONE channel of HSLA
.bg_hue(0.9) // hue wraps along the shortest arcThe full surface is split one family per module behind extension traits (LayoutParams, SpacingParams, RadiusParams, ColorParams, ShadowParams, VisualParams, TextParams). Downstream crates add their own families by writing scalars through Params::raw(scalar, amount).
Capability warnings: applying a param whose target isn't wired up in this build (currently only ScrollbarWidth, LineHeight) logs a one-time warning instead of silently doing nothing.
Every shadow in the list has its own address. Shadows beyond the base style's list materialize on demand — seeded transparent when none exist, duplicated from the last existing one otherwise — so layered glows can grow from nothing:
// Base carries one crisp shadow; layer an animated glow behind it.
.shadow(vec![crisp_shadow])
.shadow_at(1, |s| s.color(0.56, 0.72, 0.60, 0.0).blur(0.))
// ...then retarget anywhere (hover, variant, timeline):
.shadow_at(1, |s| s.alpha(0.55).blur(48.))Animating a box through fractional heights can shimmer at the edges — the layout engine rounds position and size independently, so a nominally anchored edge (a chart bar's baseline) flips between adjacent pixels while the value slides through fractions. .snap_px() rounds every animated position/layout/spacing value to whole logical pixels each frame; combined with integer-pixel targets, edges stay on exact pixel boundaries for the entire animation:
.h(px((value * TRACK_H).round())) // integer targets …
.motion(("bar", i)).snap_px() // … and integral mid-flight valuesGradients decompose fully: per-stop colors, stop positions and the linear-gradient angle are independent animation targets:
.stop_at(1, |s| s.position(pct)) // slide a stop along the line
.stop_at(0, |s| s.hue(h)) // shortest-arc hue per stop
Params::new().raw(Scalar::GradientAngle, degrees) // rotate the gradientAnimating the flat bg_* channels keeps its documented convergence behavior — every stop drifts toward the animated tint together. Text gradients expose the same per-stop control through .text_stop_at(...).
Named motion feels, shared across an app like color tokens. Each token expands to a spring expressed through damping ratios, so the character is legible at a glance (1.0 = critically damped, < 1.0 = overshoots):
| Token | Character | Use for |
|---|---|---|
SpatialFast |
critical, very quick | press feedback, toggles, chips |
SpatialNormal |
slight yield | cards and panels moving between positions |
SpatialSlow |
slower, visible settle | large surfaces, dramatic repositions |
EffectsFast |
near-instant | hover fades, pressed opacity |
EffectsNormal |
default effect feel | color shifts, shadow blooms, radii |
EffectsSlow |
slow drift | ambient/background state changes |
Expressive |
playful overshoot | badges popping, celebrations |
.transition(Transition::token(MotionToken::SpatialNormal))
// Anywhere a Spring fits:
.transition(Transition::spring(Spring::token(MotionToken::Expressive)))Timing follows CSS intuition — group properties move together unless you override them:
| Channel | Covers |
|---|---|
Position |
inset offsets |
Layout |
size, min/max, aspect ratio, flex factors |
Spacing |
margin, padding, gap, border widths |
Border |
border color |
Fill |
background colors, gradient stops/positions/angles |
Text |
font size/weight, letter spacing, text colors, text-gradient stops |
Effects |
opacity, corner radii, shadows (all of them), blurs |
Transition::new(140.ms()) // everything else
.layout(Duration::from_millis(140))
.colors(Duration::from_millis(900)) // slow drift
.effects(Spring::snappy()) // or spring one channelEverything above is built from public primitives you can compose yourself:
pub trait Effect {
fn tick(&mut self, dt: Duration, out: &mut ValueMap) -> Progress;
fn reset(&mut self);
}- [
Tween] — captured-start → target on any curve - [
Springy] — independent physical springs per scalar,with_velocitiesfor momentum-preserving retargets - [
Timeline] / [TimelineEffect] — sampled keyframes with hold/wrap/alternate semantics - Combinators: [
Delay], [Sequence], [Forever], [Repeated]
They're pure: tick(dt, out) writes numbers, touches no UI. That's why the test suite drives them deterministically.
Motion advances effects during element layout. Because retained rendering may repaint without re-entering layout, a global ticker guarantees continuation: it wakes only the views that own animating elements at ~120 Hz while any effect is alive (via App::notify, GPUI's own view-invalidation path — no whole-window refreshes), and drops to a cheap idle poll (~31 Hz atomic check, zero refreshes) when nothing animates. request_animation_frame is also requested opportunistically. Frame deltas are clamped to 33 ms so a hidden tab can't produce garbage frames.
Timing uses web-time, so the engine is WASM-ready; only platform event-loop differences remain.
These behaviors are pinned by the regression suite (tests/regression_suite.rs) and the capability suite (tests/roadmap_capabilities.rs):
- Interruptions start from displayed values — tweens restart full-duration from where you see them; springs additionally keep velocity.
- Hue blends take the shortest arc around the wheel — including per-stop gradient hues; achromatic colors don't spin randomly.
- Cross-unit changes flip discretely at the midpoint (px ↔ fraction), matching CSS discrete transitions.
- Delay boundary: the frame that consumes the last of a delay reports
Active; progress begins next tick. - Finite timelines hold their final pose after finishing; infinite ones wrap; alternate modes reverse on odd iterations.
- Gradient convergence: animating legacy
bg_*channels converges all stops toward the animated tint — while per-stop targets animate their stop alone. - Shadows are addressed by index; animating an undeclared index materializes it (transparent seed / duplicate-last).
- Exit callbacks fire once per arming, only after settling; flipping back before settling cancels them.
- Replay edges require a false sample first — holding
truewon't refire. - Removals snap: scalars that disappear from the target (e.g. length → auto) change discretely, like CSS.
| Function | Kind | Notes |
|---|---|---|
fade_in(d) / fade_out(d) |
enter / exit | opacity only |
pop_in(d) / pop_out(d) |
enter / exit | fade + blur on back curves |
slide_up/down/left/right(d) |
entrance | margin-based, expo-out |
slide_out_up/down/left/right(d) |
exit | margin-based, expo-in |
shake(d) |
feedback | horizontal jitter; pair with .replay_timeline_when |
pulse(min, max, period) |
ambient | breathing opacity |
glow_pulse(a₀, a₁, b₀, b₁, period) |
ambient | shadow alpha/blur ping-pong |
stagger(i, step) |
helper | per-index delay |
play(t) / loop_forever(t) / play_times(n, t) |
hosts | timelines as plain effects |
Fifteen focused examples — every one is a small, complete application:
| Example | Showcases |
|---|---|
gallery |
The four mental models in one tour: implicit transitions, spring gestures, channel timing, easing zoo |
shell |
App shell: collapsible sidebar, staggered nav entrances, error shake |
timelines |
Loaders, breathing glow, pop-in with click-to-replay |
buttons |
Button zoo: variants, toggle chips, loading button, hold-to-confirm |
modals |
Dialog entrance/exit with scrim fade — full AnimatePresence lifecycle |
toasts |
Stacked notifications: auto-dismiss countdown bars, exit collapse |
lists |
Dynamic list: add/remove with enter/exit animations, springy reordering |
inputs |
Form micro-interactions: focus bloom, error shake, submit success morph |
navigation |
Sliding tab indicator chasing selection, segmented control, breadcrumbs |
charts |
Bar chart with springy data updates and staggered draw-in |
loaders |
Loader collection: typing dots, indeterminate bar, skeleton pulse, overshooting progress |
cards |
Hover lift + layered glow, expandable card, selectable grid |
physics_lab |
Springs side by side: velocity carry under rapid retargets, mass effects |
theme_switch |
Whole-palette light/dark cross-fade via implicit color transitions |
dashboard |
Composite mini-app: everything above working together |
Run any of them:
cargo run --example gallery
cargo run --example modals
# ...and so onThe gallery and dashboard support WGPUI_MOTION_AUTO_DEMO=1 (self-driving toggles/shuffles; the dashboard quits after ~10 s so it can run unattended). Two stderr probes exist for debugging: WGPUI_MOTION_DEBUG=1 prints one animated value per frame per element, and WGPUI_MOTION_BOUNDS=<substring> prints the painted bounds of matching motion elements — ground truth when distinguishing layout issues from render issues.
- Inline unit tests cover the numeric core (blending, springs, curves, combinators, exit-lifecycle state machine, design tokens).
tests/adds black-box integration suites:regression_suite.rspermanently encodes every bug found during development — theForeverhang, the "all springs feel identical" trap, the default-curve-isn't-linear discovery, delay-boundary semantics, hue wrapping, and more. If you touch the engine, that file is the contract.roadmap_capabilities.rspins the newer capabilities: multi-shadow addressing and growth semantics, per-stop gradient decomposition/writing, angle animation, text-gradient stops, token expansion, mass springs, exit presets.
Run everything:
cargo testDelivered since the first release:
- ✅ View-scoped invalidation — the ticker notifies owning views, not whole windows
- ✅ Exit animations (AnimatePresence-style deferred removal)
- ✅ Multi-shadow addressing (
box_shadow[n]) - ✅ Gradient-aware interpolation (per-stop colors, positions, angles)
- ✅ Design-token spring presets (
MotionToken) - ✅ WASM-safe timing (
web-time)
Still open:
- Scrollbar-width and line-height animation (modeled scalars awaiting WGPUI support)
- Layout-keyed shared-element transitions across views
- Path/transform effects (rotate/translate as first-class scalars)
MIT © Tristan Poland (Trident_For_U)