Skip to content

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

Merged
huacnlee merged 13 commits into
mainfrom
worktree-spring-animation
Aug 24, 2026
Merged

motion: Add spring transitions and use them where a target changes mid-flight#2811
huacnlee merged 13 commits into
mainfrom
worktree-spring-animation

Conversation

@huacnlee

@huacnlee huacnlee commented Aug 24, 2026

Copy link
Copy Markdown
Member

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:

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

huacnlee and others added 8 commits August 24, 2026 10:44
GPUI gained spring animations (zed-industries/zed#62778), which our pinned
gpui predated by twelve hours. Bump it and expose the physics through a
`spring()` counterpart to `transition()`.

The two differ in what survives a target change. A transition restarts its
easing from the value sampled at that instant, which is continuous in position
but not in velocity, so a value reversed mid-flight jumps to the new curve's
initial speed. A spring carries velocity across the retarget and turns the
value around instead.

Apply it where the target changes faster than the motion completes:

- The tab indicator was keyed on an epoch that incremented on every switch, so
  each switch mounted fresh element state and replayed a full 200ms slide from
  the tab it left. The springs hold the indicator's own position, which also
  retires the `from_left`/`from_width` half of the animation tuple.
- The accordion panel now reverses from where it is when toggled mid-flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Continue applying `spring()` where the target changes faster than the motion
completes.

The toast stack reflows on every arrival and dismissal, so all four of its
layout values are sprung. `ToastMotion::duration` keeps its meaning and now
sets the spring's response, so the existing tuning knob still works and no
public field is added. Geometry settles in pixels and the fade in a normalized
range, so they carry different tolerances. The enter and exit lifecycle stays a
timed state machine — it sequences mounting, not interpolation.

Two of these swaps fix motion that was not visible at all:

- The switch thumb's `checked` style set `left`, which outranks the instance
  style the animation wrote, so only the travel back to the off position ever
  played. Position is geometry, so the spring now owns it outright and the
  semantic style keeps only color.
- The checkbox 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.

`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 both the baseline and the
result settled.

The slider thumb itself is deliberately not sprung: it must track the pointer
exactly, and removing a dragged target's lag needs `SpringConfig::step_ramp`,
which takes a target velocity we do not track. Only the press ring, which a
click reverses mid-grow, is sprung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dock had no open and close motion at all: a closed left or right dock
returned an empty element, so the centre area snapped across the width the dock
had been holding. Its size is now sprung, and the closed bottom dock's strip
becomes just another target rather than a branch in the builder.

That needs one addition to the spring. The resize handle drives the same 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. `Spring::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 — not from where the spring was when the drag began.

The drop placeholder had the tab indicator's problem: its rect was keyed on the
drop epoch, so crossing several drop zones in one drag replayed the walk from
the drag source at every one. Springing the rect directly also retires the outer
frame that existed only to hold the destination while an inner element walked
toward it.

One deliberate loss: the placeholder no longer flies in from the dragged tab on
its first appearance, because a spring adopts its first target at rest. The
motion it gains is the one the interaction spends its time in.

The base resize handle's active colour is left alone. It is painted by base, so
giving it motion means the `ScrollbarMotion` treatment — a zero default with the
styled layer projecting timing — not a spring at the call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spring landed with eleven public items; five of them had no caller.

`from_config` was reached only by `new`, and was the one thing exposing GPUI's
`SpringConfig` — a struct with public fields — across the seam. The `config`,
`epsilon` and `travel` readers had no reader: `spring()` sits in the same module
and takes the fields directly. `SMOOTH` and `BOUNCY` were used only by this
module's own tests, and `SNAPPY` by a single call site that reads no worse
stating its own damping, next to six siblings that already build their springs
with `new`.

What is left is `Spring`, `new`, `with_epsilon`, `with_travel`, and `spring`,
each with a caller in shipped code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Spring::new` took a damping ratio, and eight of its nine call sites passed
`1.0`. That argument carried no information at those sites — a reader has to
know the parameter order to see that a bare `1.` means "does not overshoot" —
while the one site that wanted overshoot looked no different from the ones that
did not.

`new` now takes only the response time and is critically damped, with
`with_damping` for the exception. Every comment at those eight sites already
argued for critical damping: a height must not exceed its measured content, an
opacity clips at 1 and flickers, the switch thumb would leave its track, an
overshooting dock would push the centre area past the window edge.

The ratio stays configurable rather than fixed. `Transition::ease` accepts any
easing curve including an overshooting one, so a spring that could only be
critically damped would be the less expressive of the two, and it would be base
deciding a motion character that belongs to the layer above it.

`Spring` now stores the response and ratio and derives the `SpringConfig` on
use. Recovering a ratio from a built config needs a square root, which a
`const fn` cannot call, and the builders have to stay `const` for the call
sites that are `const` themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`response_seconds: f32` spelled its unit in the name, which is what you do when
the type cannot carry it — and every other time value in this codebase is a
`Duration`, `Transition::new` and `ToastMotion` included. Two sibling motion
policies in one module should not measure time differently.

The suffix goes with it, and so does the conversion the toast stack was doing to
feed its own `Duration` in.

The name stays `response` rather than becoming `duration`, because a spring has
no end to schedule. It is the period one full oscillation would take without
damping — the scale the motion is felt at, not the moment it stops. Calling it a
duration would promise `Spring::new(Duration::from_millis(200))` finishes in
200ms; it is at about 98.6% there, and settles the rest of the way inside
whatever tolerance `with_epsilon` sets. The doc comment says so at the call.

`Duration::as_secs_f32` is not `const`, so it joins the square root in
`config()`, which was already derived on use for that reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Coding Guides pair a non-boolean builder with its field — `with_<field>`,
`with_size` / `size` — so `with_damping` setting `damping_ratio` did not match
anything. It is `with_damping_ratio` now.

The field keeps the longer name rather than the builder taking the shorter one.
GPUI's `SpringConfig::damping` is already the coefficient $c = 2 \zeta \omega_0$,
so `damping` alone names a different quantity in this codebase; `damping ratio`
is what GPUI itself calls $\zeta$. The doc comment says which one this is.

The parameter is `ratio`: the method name has already said which ratio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A field is read inside its type and a parameter inside its method, so neither
should repeat what encloses it. The rule that follows is that one type's fields
stay at the same level of abbreviation: a single field spelled out in full
becomes the odd one out and sends a reader looking for the distinction that made
it different. Because a builder is named `with_<field>`, shortening a field
shortens its builder with it.

Stated with the vocabulary the surrounding section already uses, rather than
whichever type prompted it — a guide that argues from one live case reads like
that case got a rule of its own, and it dates as soon as the type changes.

The limit is worth writing down too: a short form can be the established term
for a different quantity elsewhere in the ecosystem. That belongs in the doc
comment, which is read at the call, rather than in a longer identifier that
could only hint at the distinction.

Synced to the Chinese guide and the vendored skill copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@huacnlee
huacnlee force-pushed the worktree-spring-animation branch from 2daebf4 to 23081dd Compare August 24, 2026 05:42
huacnlee and others added 4 commits August 24, 2026 13:44
`config` is private and sat between two builders, so the public surface read as
two runs with a helper wedged in the middle. It goes last, which is also where
`Transition` keeps `sample` and `progress`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Springing the stack layout made the field mean two different quantities.
`ToastManager` still reads it as a deadline — a toast is present once it has
elapsed — but the layout reads it as a spring response, which is a time scale
and not a finish line. Its doc comment still said "duration of stack expansion
and collapse", which the reflow now outlives.

Also record why a settled spring may leave `updated_at` stale: the next retarget
steps a zero displacement at zero velocity across that gap, so an arbitrarily
old clock cannot move the value. It is a safe path, but not one a reader should
have to re-derive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`with_travel` was the one per-frame mode on a type otherwise holding durable
physics: a `const SPRING` could not express it, so the dock rebuilt its policy
every frame to say whether a drag was in progress. It also invented a second
spelling for something the codebase had already settled. From
`docs/STYLING-AND-MOTION.md`:

> A zero duration always means "adopt the target now", which is also how
> reduced motion and always-visible scrollbars reach the same code path.

A zero response is that same idea for a spring, and it is what the physics says
too — an infinitely stiff spring has nowhere to travel. `spring` takes the
target before it reaches `config`, so the division that a zero response would
otherwise do never happens.

`Spring` loses a field with the builder, and its unused `Debug` and `PartialEq`
derives go with them; `Copy` stays, since the call sites are `const` and the
value is captured by closures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suspending travel says at the call that the motion is suspended, and says it
without disturbing the response, damping or tolerance the spring carries. The
`Spring::new(Duration::ZERO)` I swapped it for silently dropped the dock's own
epsilon on the branch that took it — harmless while snapping, but a reader has to
work that out — and it traded a fluent builder for an imperative branch.

The argument I removed it on does not survive either: `travel` being a per-frame
mode on a type of durable physics is just as true of `Transition::new(
Duration::ZERO)`, which is the convention I was appealing to. It distinguishes
nothing.

A zero response stays defined as adopting the target, so the degenerate input
resolves rather than dividing by its own period, with a test to hold that. The
`Debug` and `PartialEq` derives stay dropped, and the test fixture keeps holding
its policy in a cell — both were independent of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@huacnlee
huacnlee enabled auto-merge (squash) August 24, 2026 06:08
@huacnlee
huacnlee disabled auto-merge August 24, 2026 06:10
`spring` stepped first and asked whether the result had settled second, so every
spring that was not moving still built a `SpringConfig` and integrated it —
two square roots, an exponential and a sin_cos — to be told it had not moved.
That is per spring per frame, and a drag re-renders whole subtrees of them: every
checkbox, switch and slider ring inside a resizing panel paid it on every frame
of the drag.

A spring already at rest on its target has nothing to advance and no frame to
ask for, and every branch below returns that same target and writes nothing, so
it can leave at the top. The reasoning about a resting spring's stale clock moves
with it, since resting is now the case that returns early.

Two more from the same review: the dock's tolerance goes to a whole pixel, the
coarsest here, because it is a layout width and every frame of it re-lays out the
entire dock subtree. And the toast stack records why a bottom-anchored item can
pass its settled position by a fraction of a pixel — two springs sharing a config
stay proportional only while they also share a start, and the stack height learns
its target a frame after the offsets do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@huacnlee
huacnlee merged commit cc86f8d into main Aug 24, 2026
4 checks passed
@huacnlee
huacnlee deleted the worktree-spring-animation branch August 24, 2026 06:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant