motion: Add spring transitions and use them where a target changes mid-flight - #2811
Merged
Conversation
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
force-pushed
the
worktree-spring-animation
branch
from
August 24, 2026 05:42
2daebf4 to
23081dd
Compare
`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
enabled auto-merge (squash)
August 24, 2026 06:08
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
GPUI gained spring animations in zed-industries/zed#62778, twelve hours after
the commit this repo pinned. This bumps gpui and builds a
spring()counterpartto
gpui_base::transition()on top of it, then applies it where it earns itskeep.
Why a spring
transition()already owns keyed state, reduced-motion handling, and reversalfrom 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
ToastMotion::durationnow sets the response, so the existing knob still works and no public field is addedTwo of these were not visible at all
SwitchThumb'scheckedstyle setleft, which outranks theinstance 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.
checked, so clearing the boxunmounted 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_travelThe 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 retainedstate 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
removing a dragged target's lag needs
SpringConfig::step_ramp, which takes atarget velocity this API does not carry.
ResizablePanel::visibleare painted in
gpui-base, andcrates/uire-exportsResizablePanelunchanged, so there is no skin seam to put motion in. Giving either one motion
means the
ScrollbarMotiontreatment — a zero default with the styled layerprojecting timing — which is a design decision, not a swap.
crossfade rather than a spring.
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.
springreturns before it builds aSpringConfigwhen 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,TransitionIdand every component's builder are untouched, sothis is additive for consumers.
The additions are held to what shipped code calls — five items in
gpui-base,plus the re-export line:
Two earlier revisions carried more.
from_config, theconfig/epsilon/travelreaders, andSMOOTH/SNAPPY/BOUNCYhad no caller outside thismodule's own tests, and
from_configwas the only thing exposing GPUI'sSpringConfig— a public-field struct — across the seam; all are gone, and theconfig is now derived on use rather than stored.
newalso took a damping ratio that eight of nine call sites answered1.0, soit defaults to critical damping and
with_dampingcarries the exception. Theratio stays configurable rather than fixed:
Transition::easeaccepts any easingcurve 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.
newtook the response asresponse_seconds: f32, spelling the unit in thename because the type could not carry it, while every other time value here —
Transition::new,ToastMotion— is aDuration. It is aDurationnow. Thename stays
responserather thanduration: a spring has no end to schedule,so
Spring::new(Duration::from_millis(200))is at about 98.6% of the way thereat 200ms and settles the rest inside its tolerance.
The names went a round of their own.
response_seconds: f32spelled its unitbecause the type could not carry it; a
Durationcarries it, which is whatTransition::newandToastMotionalready use. The damping field was then theone 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 theCoding 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
dampingmeans the ratio in aSpringand thecoefficient in GPUI's
SpringConfig, which the doc comment settles at the callrather than a longer identifier hinting at it.
Cargo.lockonly, pinned to the spring merge commit(
8b1497d) rather than tracking HEAD.cargo check --workspace --all-targetsis clean with no new warnings.
bottom_stack_reflow_moves_up_without_an_opposite_direction_jumptook itsbaseline 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.
spring()the same waytransition()honoursit.
docs/STYLING-AND-MOTION.mddocuments 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