Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/decay-preserve-velocity-on-retarget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@react-spring/core': patch
---

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.
6 changes: 5 additions & 1 deletion docs/app/data/fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,11 @@ export const configData: CellData[][] = [
label: 'number | boolean',
content: (
<p>
If <code>true</code>, default value is <code>0.998</code>.
Decelerates from an initial <code>velocity</code>. Requires a non-zero{' '}
<code>config.velocity</code> — the <code>to</code> value is not a goal
for decay animations. Typically paired with gesture velocity (see the
rocket-decay example). Pass <code>true</code> for the default
exponential factor (<code>0.998</code>).
</p>
),
},
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/AnimationConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,21 @@ export class AnimationConfig {
bounce?: number

/**
* "Decay animations" decelerate without an explicit goal value.
* Useful for scrolling animations.
* "Decay animations" decelerate from an initial velocity. They do **not**
* ease toward a `to` value — `to` is ignored by the decay integration.
*
* Requires a non-zero `config.velocity` (otherwise the animation produces
* no movement). Typically paired with gesture libraries that supply
* velocity, e.g. `@use-gesture/react`'s `useDrag`:
*
* ```ts
* api.start({ pos, config: { velocity: [vx, vy], decay: true } })
* ```
*
* Use `true` for the default exponential decay factor (`0.998`).
*
* When a `number` between `0` and `1` is given, a lower number makes the
* animation slow down faster. And setting to `1` would make an unending
* animation slow down faster. Setting to `1` would make an unending
* animation.
*
* @default false
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/SpringValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,29 @@ function describeConfigProp() {
spring.start({ to: 200 })
expect(config.velocity).toBe(0)
})
it('preserves velocity across "to" updates when decay is set', () => {
const spring = new SpringValue(0)
spring.start({ to: 100, config: { velocity: 10, decay: true } })

const { config } = spring.animation
expect(config.velocity).toBe(10)

// Retargeting must NOT wipe velocity for decay animations.
spring.start({ to: 200, config: { decay: true } })
expect(config.velocity).toBe(10)
})
it('decay continues animating after retarget (#1843)', async () => {
const spring = new SpringValue(0)
spring.start(100, { config: { velocity: 10, decay: 0.48 } })
await global.advanceUntilIdle()
const firstRest = spring.get()
expect(firstRest).toBeGreaterThan(1)

// Simulate a mid-gesture retarget with a new velocity.
spring.start(200, { config: { velocity: 5, decay: 0.48 } })
await global.advanceUntilIdle()
expect(spring.get()).toBeGreaterThan(firstRest)
})
describe('when "damping" is 1.0', () => {
it('should prevent bouncing', async () => {
const spring = new SpringValue(0)
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/SpringValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,10 @@ export class SpringValue<T = any> extends FrameValue<T> {
const { decay, velocity } = config

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

Expand Down
Loading