Skip to content

Commit 6bd6613

Browse files
authored
fix(core): preserve velocity across retargets for decay animations (#2516)
1 parent c220465 commit 6bd6613

5 files changed

Lines changed: 48 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@react-spring/core': patch
3+
---
4+
5+
Preserve `config.velocity` across `to`/`from` retargets when `config.decay` is set. Previously, `SpringValue._update` unconditionally reset `config.velocity` to `0` whenever a new goal was provided, which silently broke gesture-driven decay flows that retarget mid-throw (e.g. mouse-flick decay). Also clarifies the `decay` JSDoc and docs-site reference to explain that decay decelerates from an initial velocity and does not ease toward `to`. Closes #1843.

docs/app/data/fixtures.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,11 @@ export const configData: CellData[][] = [
268268
label: 'number | boolean',
269269
content: (
270270
<p>
271-
If <code>true</code>, default value is <code>0.998</code>.
271+
Decelerates from an initial <code>velocity</code>. Requires a non-zero{' '}
272+
<code>config.velocity</code> — the <code>to</code> value is not a goal
273+
for decay animations. Typically paired with gesture velocity (see the
274+
rocket-decay example). Pass <code>true</code> for the default
275+
exponential factor (<code>0.998</code>).
272276
</p>
273277
),
274278
},

packages/core/src/AnimationConfig.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,21 @@ export class AnimationConfig {
129129
bounce?: number
130130

131131
/**
132-
* "Decay animations" decelerate without an explicit goal value.
133-
* Useful for scrolling animations.
132+
* "Decay animations" decelerate from an initial velocity. They do **not**
133+
* ease toward a `to` value — `to` is ignored by the decay integration.
134+
*
135+
* Requires a non-zero `config.velocity` (otherwise the animation produces
136+
* no movement). Typically paired with gesture libraries that supply
137+
* velocity, e.g. `@use-gesture/react`'s `useDrag`:
138+
*
139+
* ```ts
140+
* api.start({ pos, config: { velocity: [vx, vy], decay: true } })
141+
* ```
134142
*
135143
* Use `true` for the default exponential decay factor (`0.998`).
136144
*
137145
* When a `number` between `0` and `1` is given, a lower number makes the
138-
* animation slow down faster. And setting to `1` would make an unending
146+
* animation slow down faster. Setting to `1` would make an unending
139147
* animation.
140148
*
141149
* @default false

packages/core/src/SpringValue.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,29 @@ function describeConfigProp() {
533533
spring.start({ to: 200 })
534534
expect(config.velocity).toBe(0)
535535
})
536+
it('preserves velocity across "to" updates when decay is set', () => {
537+
const spring = new SpringValue(0)
538+
spring.start({ to: 100, config: { velocity: 10, decay: true } })
539+
540+
const { config } = spring.animation
541+
expect(config.velocity).toBe(10)
542+
543+
// Retargeting must NOT wipe velocity for decay animations.
544+
spring.start({ to: 200, config: { decay: true } })
545+
expect(config.velocity).toBe(10)
546+
})
547+
it('decay continues animating after retarget (#1843)', async () => {
548+
const spring = new SpringValue(0)
549+
spring.start(100, { config: { velocity: 10, decay: 0.48 } })
550+
await global.advanceUntilIdle()
551+
const firstRest = spring.get()
552+
expect(firstRest).toBeGreaterThan(1)
553+
554+
// Simulate a mid-gesture retarget with a new velocity.
555+
spring.start(200, { config: { velocity: 5, decay: 0.48 } })
556+
await global.advanceUntilIdle()
557+
expect(spring.get()).toBeGreaterThan(firstRest)
558+
})
536559
describe('when "damping" is 1.0', () => {
537560
it('should prevent bouncing', async () => {
538561
const spring = new SpringValue(0)

packages/core/src/SpringValue.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,10 @@ export class SpringValue<T = any> extends FrameValue<T> {
698698
const { decay, velocity } = config
699699

700700
// Reset to default velocity when goal values are defined.
701-
if (hasToProp || hasFromProp) {
701+
// Skip the reset when `decay` is configured — decay animations are
702+
// driven by velocity, so wiping it on every retarget breaks gesture-driven
703+
// throws (e.g. mouse-flick decay). See #1843.
704+
if ((hasToProp || hasFromProp) && !config.decay) {
702705
config.velocity = 0
703706
}
704707

0 commit comments

Comments
 (0)