SIGN IN SIGN UP

motion: Add spring transitions and use them where a target changes mid-flight (#2811)

GPUI gained spring animations in zed-industries/zed#62778, twelve hours
after
the commit this repo pinned. This bumps gpui and builds a `spring()`
counterpart
to `gpui_base::transition()` on top of it, then applies it where it
earns its
keep.

## Why a spring

`transition()` already owns keyed state, reduced-motion handling, and
reversal
from the currently sampled value. The one thing it cannot do is carry
*velocity*
across a target change: it restarts its easing from the value sampled at
that
instant, so a value reversed mid-flight jumps to the new curve's initial
speed.
A spring turns it around instead.

So the rule applied throughout: **spring where the target changes faster
than
the motion completes, transition where the target is set once and runs
to
completion.** One-shot enters — dialog, sheet, popover — are
deliberately left
as they are.

## What moved

| | Before | After |
|---|---|---|
| Tab indicator | keyed on an epoch that incremented per switch, so
every switch replayed a full 200ms slide from the tab it left | two
springs hold the indicator's own position |
| Toast stack | four fixed-duration transitions, retargeted on every
arrival and dismissal | sprung; `ToastMotion::duration` now sets the
response, so the existing knob still works and no public field is added
|
| Drop placeholder | keyed on the drop epoch, so crossing several zones
in one drag replayed the walk from the drag source at each one | the
rect chases the drop continuously |
| Dock open/close | a closed left/right dock returned an empty element,
so the centre snapped across the width it had held | the dock's size is
sprung, so it slides |
| Accordion panel | 200ms ease-out | reverses from its current height
when toggled mid-flight |
| Slider press ring | 150ms ease-out, and a click reverses it mid-grow |
sprung |

## Two of these were not visible at all

- **Switch**: `SwitchThumb`'s `checked` style set `left`, which outranks
the
instance style the animation wrote by the documented precedence — so
only the
travel back to the *off* position ever played. Position is geometry, so
the
  spring owns it outright now and the semantic style keeps only colour.
- **Checkbox**: the mark's path was mounted on `checked`, so clearing
the box
unmounted the glyph before the fade could run. The path now stays while
the
  spring is still fading it out.

Both also retire a per-toggle spawned timer that existed only to drive
the
previous animation's keyed restart.

## `Spring::with_travel`

The dock's resize handle drives the dock's size from the pointer and is
drawn at
the dock's own edge, so springing it during a drag would leave the
handle
trailing the cursor. `with_travel(false)` suspends travel and pins the
retained
state to the target, so the value passes straight through for the length
of the
drag and travel resumes from where the drag released it.

A zero response resolves the same way, as a zero duration does for a
transition,
and is defined so the degenerate input does not divide by its own
period. It is
not the way to say this, though: a policy swapped out for the length of
a drag
has to restate or discard the response, damping and tolerance the
original one
carried, and it trades a fluent builder for a branch at the call.

## Deliberately not done

- **Slider thumb** stays unsprung: it must track the pointer exactly,
and
removing a dragged target's lag needs `SpringConfig::step_ramp`, which
takes a
  target velocity this API does not carry.
- **The base resize handle's active colour** and
**`ResizablePanel::visible`**
are painted in `gpui-base`, and `crates/ui` re-exports `ResizablePanel`
unchanged, so there is no skin seam to put motion in. Giving either one
motion
means the `ScrollbarMotion` treatment — a zero default with the styled
layer
  projecting timing — which is a design decision, not a swap.
- **Dock zoom** swaps whole subtrees rather than moving a value, so it
needs a
  crossfade rather than a spring.
- The drop placeholder no longer flies in from the dragged tab on its
first
appearance, because a spring adopts its first target at rest. Restoring
it
  needs an origin entry point (gpui spells it `SpringAnimation::from`).

## What a resting spring costs

A read and two comparisons. `spring` returns before it builds a
`SpringConfig`
when the value is already at rest on its target, which is the state
almost every
spring is in on almost every frame.

It did not, at first, and that showed up as drag lag somewhere else
entirely: a
drag re-renders whole subtrees, so every checkbox, switch and slider
ring inside
a resizing panel was integrating a spring — two square roots, an
exponential and
a sin_cos — on every frame of the drag, to be told it had not moved. The
symptom
was a stuttering resize; the cost was in leaves that had nothing to do
with it.

## Notes

## API surface

Nothing existing is removed or changed. `Transition`, `transition()`,
`Interpolate`, `TransitionId` and every component's builder are
untouched, so
this is additive for consumers.

The additions are held to what shipped code calls — five items in
`gpui-base`,
plus the re-export line:

```rust
pub struct Spring;                                        // opaque; no public fields
impl Spring {
    pub const fn new(response: Duration) -> Self;         // critically damped
    pub const fn with_damping(mut self, ratio: f32) -> Self;
    pub const fn with_epsilon(mut self, epsilon: f32) -> Self;
    pub const fn with_travel(mut self, travel: bool) -> Self;
}
pub fn spring<T: SpringTarget>(id, target: T, policy: Spring, window, cx) -> T::Output;
```

Two earlier revisions carried more. `from_config`, the `config` /
`epsilon` /
`travel` readers, and `SMOOTH` / `SNAPPY` / `BOUNCY` had no caller
outside this
module's own tests, and `from_config` was the only thing exposing GPUI's
`SpringConfig` — a public-field struct — across the seam; all are gone,
and the
config is now derived on use rather than stored.

`new` also took a damping ratio that eight of nine call sites answered
`1.0`, so
it defaults to critical damping and `with_damping` carries the
exception. The
ratio stays configurable rather than fixed: `Transition::ease` accepts
any easing
curve including an overshooting one, so a critically-damped-only spring
would be
the less expressive of the two, and it would be base deciding a motion
character
that belongs to the layer above it.

`new` took the response as `response_seconds: f32`, spelling the unit in
the
name because the type could not carry it, while every other time value
here —
`Transition::new`, `ToastMotion` — is a `Duration`. It is a `Duration`
now. The
name stays `response` rather than `duration`: a spring has no end to
schedule,
so `Spring::new(Duration::from_millis(200))` is at about 98.6% of the
way there
at 200ms and settles the rest inside its tolerance.

The names went a round of their own. `response_seconds: f32` spelled its
unit
because the type could not carry it; a `Duration` carries it, which is
what
`Transition::new` and `ToastMotion` already use. The damping field was
then the
one name spelled out in full next to three short ones, and shortening it
shortened its `with_<field>` builder along with it. That is now written
into the
Coding Guides as **Let the enclosing name carry the context**: a field
is read
inside its type and a parameter inside its method, so neither repeats
what
encloses it — with the limit that `damping` means the ratio in a
`Spring` and the
coefficient in GPUI's `SpringConfig`, which the doc comment settles at
the call
rather than a longer identifier hinting at it.
- The gpui bump is `Cargo.lock` only, pinned to the spring merge commit
(`8b1497d`) rather than tracking HEAD. `cargo check --workspace
--all-targets`
  is clean with no new warnings.
- `bottom_stack_reflow_moves_up_without_an_opposite_direction_jump` took
its
baseline 400ms after mount, when the stack-height spring was still half
a pixel
short of the height measured in prepaint. It now reads baseline and
result
  settled.
- Reduced motion is honoured by `spring()` the same way `transition()`
honours
  it.
- `docs/STYLING-AND-MOTION.md` documents when to reach for which,
`SpringTarget`,
  and `with_travel`.

This PR was written with Claude Code; every hunk is AI-generated and
human-reviewed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
J
Jason Lee committed
cc86f8d48783ef84d7ae3a1aba263855774460ef
Parent: 7d0aaf5
Committed by GitHub <noreply@github.com> on 8/24/2026, 6:18:42 AM