diff --git a/.changeset/decay-preserve-velocity-on-retarget.md b/.changeset/decay-preserve-velocity-on-retarget.md
new file mode 100644
index 0000000000..fc9e4aa387
--- /dev/null
+++ b/.changeset/decay-preserve-velocity-on-retarget.md
@@ -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.
diff --git a/docs/app/data/fixtures.tsx b/docs/app/data/fixtures.tsx
index 52ce9fa970..b18eb5eb84 100644
--- a/docs/app/data/fixtures.tsx
+++ b/docs/app/data/fixtures.tsx
@@ -268,7 +268,11 @@ export const configData: CellData[][] = [
label: 'number | boolean',
content: (
- If true, default value is 0.998.
+ Decelerates from an initial velocity. Requires a non-zero{' '}
+ config.velocity — the to value is not a goal
+ for decay animations. Typically paired with gesture velocity (see the
+ rocket-decay example). Pass true for the default
+ exponential factor (0.998).
),
},
diff --git a/packages/core/src/AnimationConfig.ts b/packages/core/src/AnimationConfig.ts
index aa21a49834..8ae45ac48a 100644
--- a/packages/core/src/AnimationConfig.ts
+++ b/packages/core/src/AnimationConfig.ts
@@ -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
diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts
index 46f0326cb6..b35297194f 100644
--- a/packages/core/src/SpringValue.test.ts
+++ b/packages/core/src/SpringValue.test.ts
@@ -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)
diff --git a/packages/core/src/SpringValue.ts b/packages/core/src/SpringValue.ts
index 9cc335cb2b..281f052cd8 100644
--- a/packages/core/src/SpringValue.ts
+++ b/packages/core/src/SpringValue.ts
@@ -698,7 +698,10 @@ export class SpringValue extends FrameValue {
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
}