rust-gpui-motion
Design motion, transitions, and bespoke graphics for native Rust gpui apps — the framework behind Zed and the pd-console operator console. Use for with_animation, easing curves, BoxShadow/glow, breath
Files7
View on GitHubSKILL.mdskills/rust-gpui-motion/SKILL.md
GitHubRust gpui Motion
Motion in gpui is not CSS with a different syntax. gpui 0.2.x has no fluent transform on div — no .scale(), no .translate(), no .rotate(), no transform property to interpolate, no spring on layout, no AnimatePresence, no compositor thread to offload to. Every "lift / slide / zoom / spring" you internalized from Framer Motion and View Transitions must be re-derived from the four primitives gpui actually ships: opacity, BoxShadow, hover/color interpolation, and animated layout fractions driven by the delta closure inside with_animation. A .repeat() animation is a standing invitation to re-render the whole window forever, and every re-render walks the entire element tree top-to-bottom — so the frame budget here is an architecture concern, not a styling concern. This skill is the field manual for doing that right in pd-console (native gpui on Metal). It is the gpui counterpart to the web-focused animation-system-architect.
Activation Triggers
Activate on: with_animation, Animation::new, gpui easing (ease_in_out, bounce, pulsating_between, linear), BoxShadow/glow, breathing/pulsing dots, pane expand / list reflow / tab-slide / fullscreen-zoom transitions, "lift/slide/zoom/spring in gpui", custom paint/canvas(), PathBuilder, Vello/wgpu surfaces, "animation never stops re-rendering", reduced-motion in a native Rust app, two owners fighting one element.
NOT for: web/React/Framer/GSAP/View-Transitions motion → animation-system-architect | terminal/CLI/TUI output → beautiful-cli-design | general GUI layout, color, theming, typography (the non-motion design system) → beautiful-gui-design.
Quick Start
- Classify the surface — a micro-interaction (glow, breathing dot, hover lift), a transition (a surface changes shape or identity), or bespoke graphics (the element tree genuinely can't express it). Different primitives, different owners.
- Gate on reduced-motion and frame budget FIRST. There is no
@mediafallback and no compositor thread. Decide the static/instant path before the animated one. - Animate compositor-friendly properties only —
opacity,BoxShadow/glow, color; neverwidth/height/px/insets in a hot render. Layout animation costs a full tree walk per frame. - One motion owner per surface. A transition state machine lives in the view and is retargetable mid-flight. Never let two
with_animations or arepeat()and a state machine drive the same element. - Audit the rendered frame, not the snippet. Build it, run it, watch for runaway re-renders (
.repeat()), label overlap, and motion that outlives its job.
Core Capabilities
| Domain | gpui primitive | Expert cue |
|---|---|---|
| One-shot micro-motion | with_animation(id, Animation::new(dur).with_easing(ease_in_out), delta_closure) |
The delta closure (0.0→1.0) is your only interpolator — map it onto opacity/shadow/layout-fraction by hand. |
| Looping life (breathing dot) | Animation::new(..).repeat().with_easing(pulsating_between(a, b)) |
A loop re-renders forever. Budget it; never stack two on one element. |
| Depth & emphasis | BoxShadow { blur_radius, spread_radius, color }, glow helpers |
Shadow + opacity is the compositor-friendly substitute for scale(). This is how you fake "lift". |
| Shape/identity change | An interruptible transition state machine in the view | spawn → expand → list → zoom → tab-slide: retarget mid-flight, don't restart. |
| Below the widget layer | canvas(), window.paint_quad(quad(..)), PathBuilder::fill()/stroke(), window.paint_path |
Drop here only when div().bg().shadow().child() truly can't express it (dither, shimmer, particles, beziers). |
| Heaviest surfaces | Vello / wgpu surface (ADR-0086) | Custom-render path for scanlines/playheads/particle fields; highest cost, last resort. |
Decision Points
flowchart TD
A[Need motion on a gpui surface] --> B{Reduced motion requested?}
B -->|Yes| C[Resolve to final state instantly; keep orientation cue, drop travel]
B -->|No| D{Does the surface change SHAPE or IDENTITY?}
D -->|"No — atom: glow, lift, breathing, hover"| E{Loops forever?}
E -->|"Yes — life/pulse"| F["Animation::new(..).repeat()<br/>pulsating_between — ONE owner, budgeted"]
E -->|"No — one-shot ack"| G["with_animation: map delta→opacity/BoxShadow"]
D -->|"Yes — pane expand / list reflow / tab slide / fullscreen zoom"| H{Can the element tree express it?<br/>opacity + shadow + layout-fraction}
H -->|Yes| I["Interruptible transition state machine in the view<br/>retarget mid-flight, one owner"]
H -->|"No — dither / shimmer / particles / beziers / scanlines"| J{How heavy?}
J -->|"Path / quad / shader-ish in one frame"| K["gpui paint API: canvas(), paint_quad, PathBuilder"]
J -->|"Continuous custom-rendered surface"| L["Vello / wgpu surface (ADR-0086) — last resort"]
Expert cue: if you reach for "I just need transform: scale()", stop — gpui has no such thing. Re-derive the intent ("emphasize this pane") as opacity + BoxShadow growth + an animated layout fraction. The transform vocabulary does not port; the intent does.
Core Rules
- No-transform constraint is law. There is no
scale/translate/rotateto interpolate. Compose every "lift/slide/zoom/spring" from opacity,BoxShadow, color, and thedeltaclosure. (references/02-the-no-transform-constraint.mdis the full translation table.) - Compositor-friendly only in a hot render. Animate
opacity,BoxShadow, and color. Animatingwidth/height/insets/pxforces a full element-tree re-layout every frame. If you must animate layout, animate a fraction with eyes open, never as a convenience default. - One owner per surface. Exactly one thing drives a given element's motion — one
with_animation, or one transition state machine. Two owners fighting one element is the native equivalent of the web ownership conflict and produces irreproducible snapping. - Reduced-motion-first, and it must preserve orientation. Decide the instant/static path before the animated one. Reducing motion means less travel, not deleted feedback — keep the orientation cue (final position, a fade, a state hint), drop only the journey.
- Frame budget is architecture, not polish. A
.repeat()animation re-renders the window forever; every re-render walks the whole tree. Budget loops deliberately, scope them to the smallest view, and never leave one running for a surface that's off-screen or idle. - Transitions are interruptible state machines that live in the view. A surface that changes shape/identity gets a retargetable state, not a fire-and-forget keyframe. Mid-flight interruptions retarget the current value; they don't restart from zero.
- Drop below the widget layer only when the tree can't express it.
paint/canvas/Vello are escape hatches with real cost. Earn them; don't default to them for thingsdiv().shadow()already does.
Failure Modes
Layout animation in a hot render
- Symptom: A pane "expands" by animating
width/height(orpx(...)insets) and the whole window stutters as more content loads. - Detection:
deltaclosure writes to size/inset fields inside a frequently-rendered view; frame time climbs with tree depth, not with the animated element. - Fix: Animate
opacity+BoxShadowgrowth for the "lift", and if shape must change, animate a single layout fraction at the smallest enclosing node — not raw pixels on every child.
repeat() that never stops re-rendering
- Symptom: CPU/GPU stays hot on an idle screen; the window re-renders continuously even with no input.
- Detection: An
Animation::new(..).repeat()(e.g. a breathing dot) mounted high in the tree or left running while its view is off-screen/inactive. - Fix: Scope the loop to the smallest leaf view that needs it; pause/unmount it when the surface is idle or hidden; never stack two loops on one element.
Reduced-motion deletes orientation
- Symptom: With reduced motion on, a transition just snaps and the user loses track of what moved where (which pane expanded, which tab is now active).
- Detection: The reduced-motion branch sets duration to 0 / removes the animation entirely with no replacement cue.
- Fix: Replace travel with a minimal fade or an explicit final-state hint. Reduced motion = smaller motion that still communicates, not silence.
Two owners fighting one element
- Symptom: Flicker, value snapping, stale shadow/opacity, "impossible" bugs only after an interrupted interaction.
- Detection: Both a
with_animationand a state-machine field (or twowith_animations) write the same property on the same element. - Fix: Make one owner authoritative. Let the transition state machine own the value and have everything else read it; remove the competing
with_animation.
Reaching below the widget layer too early
- Symptom: A hand-rolled
paint/Vello surface re-implements a glow or a rounded card thatBoxShadow+divalready does, now untestable and off the layout grid. - Detection: Custom paint code whose output is expressible as
div().bg().shadow().rounded(). - Fix: Return to the element tree. Reserve
paint/Vello for genuinely inexpressible work (dither, shimmer sweep, particles, anti-aliased causal-thread beziers, scanlines).
Easing that doesn't exist
- Symptom: Build fails or motion falls back to linear because you reached for a web curve name.
- Detection:
ease_out(not exported in 0.2.x) or other CSS-named curves inwith_easing(..). - Fix: Use confirmed gpui curves:
ease_in_out,bounce,pulsating_between,linear. For deceleration,ease_in_outreads like a heavy door settling on a good hinge — that's the "buttery" target.
Worked Example — pd-console spawn → fullscreen → diff, done right
Goal. An operator spawns an agent; the spawn card expands to advanced view, the fleet renders as a list, the selected pane zooms to fullscreen, and the diff slides in as a tab. Buttery, interruptible, reduced-motion-safe — with zero transform usage.
One owner. A single TransitionState field on the view drives the whole flow. It holds the current phase, a target phase, and a 0.0→1.0 progress value. Every frame reads from it; nothing else animates these elements.
// In the view struct — the single motion owner for this surface.
enum Phase { Spawn, Advanced, Fleet, Fullscreen { pane: PaneId }, Diff }
struct TransitionState {
from: Phase,
to: Phase,
progress: f32, // 0.0..=1.0, driven by with_animation's delta
}
spawn → advanced (expand). Do not animate the card's
width/height. Drive onewith_animationwhosedeltamaps onto: risingopacityfor the newly-revealed controls, a growingBoxShadow(blur + spread) to read as "lift", and a single layout fraction at the card's container if reflow is unavoidable. Easing:ease_in_out.advanced → fleet (list reflow). The fleet appears as a list. Stagger entries by feeding each row a phase-offset into the same
delta(row i starts ati * 0.04), animatingopacityonly. No per-row layout animation — the list lays out once; rows fade in over the settled layout.fleet → fullscreen (zoom). "Zoom" is faked: the chosen pane's container animates a layout fraction from its grid cell toward full bounds while siblings animate
opacitydown. The apparent scale comes from the fraction + shadow, neverscale().fullscreen → diff (tab-slide). The diff tab slides in by animating a single horizontal layout fraction (offset as a fraction of width, computed once), with
opacityrising. One axis, one owner.Interruption. If the operator clicks away mid-zoom, set
toto the new phase and retargetprogressfrom its current value — never restart at 0. Because one state owns it, the in-flight value is the new animation's start.Reduced motion. When reduced motion is on, the same state machine resolves
progressto1.0immediately and skips the loops — but it still applies the final opacity/shadow/fraction so the operator sees which pane expanded and which tab is active. Orientation preserved; travel dropped.
Why it's correct: one owner, interruptible, compositor-friendly (opacity + shadow + a single fraction), reduced-motion keeps orientation, and not one byte of transform. See references/03-transition-architecture.md for the full state-machine scaffold and references/01-gpui-animation-primitives.md for the with_animation signatures.
Quality Gates
- Zero
scale/translate/rotate/transformusage — all "lift/slide/zoom/spring" composed from opacity,BoxShadow, color, layout-fraction. - No
width/height/px/inset animation inside a hot render; any layout animation is a deliberate single fraction at the smallest node. - Exactly one motion owner per surface (one
with_animationor one transition state machine); no element written by two animators. - Reduced-motion path resolves to final state and preserves orientation (fade/hint), never a bare snap or deleted feedback.
- Every
.repeat()loop is scoped to its smallest leaf view and paused/unmounted when idle or off-screen. - Transitions are interruptible: mid-flight changes retarget the current value rather than restarting at 0.
- Only confirmed gpui easings used (
ease_in_out,bounce,pulsating_between,linear); noease_outor CSS curve names. -
paint/canvas/Vello/wgpu used only for work the element tree genuinely can't express; nothing reimplementsdiv().shadow(). - Built and run, not just read: confirmed no runaway re-render on idle, no label overlap, motion ends the instant its job is done.
Fork Guidance
- Fork to a subagent when the task is a repo-wide motion audit of a gpui app: inventory every
with_animation/repeat(), flag layout-in-hot-render and multi-owner elements, and check reduced-motion coverage across views. - Keep inline when choosing a primitive for one surface, designing a single transition, or reviewing one view's motion.
- Good subagent payload: target view/module, surface class (micro | transition | bespoke), the
Phases involved, whether reduced motion must be honored, and the frame-budget expectation (idle must be 0 re-renders).
Reference Map
The six grounding docs in references/ — all keyed to real pd-console/src code (gpui 0.2.x, Metal):
references/01-gpui-animation-primitives.md— the four primitives,with_animation/Animationsignatures, thedeltaclosure, easing names. Read first.references/02-the-no-transform-constraint.md— the translation table: how to re-derive lift/slide/zoom/spring withoutscale/translate/rotate. Read before composing any motion.references/03-transition-architecture.md— one owner per surface; the interruptible, retargetable transition state machine forspawn → expand → list → zoom → tab-slide.references/04-frame-budget-and-reduced-motion.md— why.repeat()re-renders forever, scoping loops, and the native reduced-motion discipline (no@media, no compositor thread).references/05-bespoke-graphics-vello-wgpu.md— the three escape hatches below the widget layer: gpuipaint/canvas,PathBuilder, and Vello/wgpu surfaces (ADR-0086).references/06-motion-aesthetics-and-vocabulary.md— the "splendid, retro-futuristic, buttery" vocabulary:swoosh,glow,hard_offset, breathing dots, control flash, and curves that decelerate like a heavy door settling.
Skill Bundle Index
Every file in this skill, and when to open it. Auto-generated; run scripts/index_references.py --fix.
references/
references/01-gpui-animation-primitives.md— gpui Animation Primitives — > Scope. This is the field manual for motion inpd-console— a native gpui > 0.2.2 app (core/pd-console/Cargo.toml:32: `gpui = {references/02-the-no-transform-constraint.md— 02 — The No-Fluent-Transform Constraint — > *gpui 0.2.x has noscale,translate, orrotateon elements.references/03-transition-architecture.md— Transition Architecture — > One motion owner per surface.references/04-frame-budget-and-reduced-motion.md— Frame Budget & Reduced Motion in gpui — > Target file:references/04-frame-budget-and-reduced-motion.md.references/05-bespoke-graphics-vello-wgpu.md— Bespoke Graphics in gpui — Custom Painting, Vello/wgpu Surfaces, and the Cassette-Futurism Layer — > Scope. When the element tree (div().bg().shadow().child()) genuinely cannot express what you need — ordered dithering, a shimmer swereferences/06-motion-aesthetics-and-vocabulary.md— Motion Aesthetics & Vocabulary — > "Splendid, retro-futuristic, buttery." Buttery is not a feeling you bolt on at the end — it is honest 60fps, motion that ends the instan