Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

WGPUI-Motion

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))

Installation

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" }

The four mental models

1 · Implicit transitions (CSS-style)

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.

2 · Springs, not durations

.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).

3 · Gestures & variants (Framer-Motion style)

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 wrap

Gesture 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.

4 · Keyframe timelines

// @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.

Exit animations (AnimatePresence-style)

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 calling cx.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)), so cx.listener(...) closures drop straight in.
  • Exit presets ship alongside entrances: fade_out, pop_out, slide_out_up/down/left/right.

Sub-field targeting, web-style

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 arc

The 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.

Multi-shadow addressing

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.))

Pixel-snapped box animation

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 values

Gradient-aware interpolation

Gradients 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 gradient

Animating 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(...).

Design tokens

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 channels

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 channel

Effects as standalone modules

Everything 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_velocities for 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.

Frame driving, honestly explained

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.

Semantics you can rely on

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 true won't refire.
  • Removals snap: scalars that disappear from the target (e.g. length → auto) change discretely, like CSS.

Presets

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

Examples

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 on

The 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.

Testing

  • 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.rs permanently encodes every bug found during development — the Forever hang, 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.rs pins 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 test

Roadmap

Delivered 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)

License

MIT © Tristan Poland (Trident_For_U)

About

State-driven transitions and animations for WGPUI

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages