diff --git a/README.md b/README.md
index bf48d6e..0a3ead2 100644
--- a/README.md
+++ b/README.md
@@ -194,8 +194,25 @@ Spring animations use a physics-based model for natural-feeling motion. Great fo
| `damping` | `number` | `15` | Friction — higher values reduce oscillation |
| `stiffness` | `number` | `120` | Spring constant — higher values mean faster animation |
| `mass` | `number` | `1` | Mass of the object — higher values mean slower, more momentum |
+| `velocity` | `number` | `0` | Initial velocity in property units per second (native only) |
| `delay` | `number` | `0` | Delay in milliseconds before the animation starts |
+`velocity` gives the spring a head start, the way `Animated.spring`'s `velocity` does — useful
+when the animation continues a gesture that was already moving. It is signed in value space, so a
+positive value means the property is already increasing:
+
+```tsx
+
+```
+
+Units follow the JS side — DIPs per second for `translateX`/`translateY`, degrees per second for
+`rotate`, and plain units per second for `scale` and `opacity`. It has no effect on web, where a
+spring compiles to a single normalized CSS easing curve that cannot express a per-property
+starting velocity.
+
Spring presets for common feels:
```tsx
@@ -575,6 +592,7 @@ Properties not specified in `animate` default to their identity values.
damping?: number; // default: 15
stiffness?: number; // default: 120
mass?: number; // default: 1
+ velocity?: number; // default: 0 (property units/s, native only)
delay?: number; // default: 0 (ms)
}
```
diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt
index 7815088..190c88f 100644
--- a/android/src/main/java/com/ease/EaseView.kt
+++ b/android/src/main/java/com/ease/EaseView.kt
@@ -59,6 +59,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
val damping: Float,
val stiffness: Float,
val mass: Float,
+ val velocity: Float,
val loop: String,
val delay: Long
)
@@ -86,6 +87,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
damping = configMap.getDouble("damping").toFloat(),
stiffness = configMap.getDouble("stiffness").toFloat(),
mass = configMap.getDouble("mass").toFloat(),
+ velocity = if (configMap.hasKey("velocity")) configMap.getDouble("velocity").toFloat() else 0f,
loop = configMap.getString("loop")!!,
delay = configMap.getInt("delay").toLong()
)
@@ -892,11 +894,23 @@ class EaseView(context: Context) : ReactViewGroup(context) {
val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass)))
.coerceAtLeast(0.01f)
+ // translationX/Y animate in pixels, so a DIP/s velocity has to be converted
+ // the same way the target value is in EaseViewManager. Every other property
+ // (scale, rotation, alpha) already shares its unit with the JS side.
+ val startVelocity = when (viewProperty) {
+ DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y ->
+ PixelUtil.toPixelFromDIP(config.velocity)
+ else -> config.velocity
+ }
+
val spring = SpringAnimation(this, viewProperty).apply {
spring = SpringForce(toValue).apply {
this.dampingRatio = dampingRatio
this.stiffness = config.stiffness
}
+ if (startVelocity != 0f) {
+ setStartVelocity(startVelocity)
+ }
addUpdateListener { _, _, _ ->
// First update — enable hardware layer
if (activeAnimationCount == 0) {
diff --git a/docs/docs/api-reference.mdx b/docs/docs/api-reference.mdx
index 85e26bc..9711d6f 100644
--- a/docs/docs/api-reference.mdx
+++ b/docs/docs/api-reference.mdx
@@ -64,6 +64,8 @@ A `View` that animates property changes using native platform APIs.
damping?: number;
stiffness?: number;
mass?: number;
+ /** Property units per second, signed in value space. Native only. */
+ velocity?: number;
delay?: number;
}
```
diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx
index 9b538d3..1d18fbe 100644
--- a/docs/docs/usage.mdx
+++ b/docs/docs/usage.mdx
@@ -59,8 +59,25 @@ Available easing curves:
| `damping` | `number` | `15` | Friction — higher values reduce oscillation |
| `stiffness` | `number` | `120` | Spring constant — higher values mean faster animation |
| `mass` | `number` | `1` | Mass of the object — higher values mean slower, more momentum |
+| `velocity` | `number` | `0` | Initial velocity in property units per second (native only) |
| `delay` | `number` | `0` | Delay in milliseconds before the animation starts |
+`velocity` gives the spring a head start, the way `Animated.spring`'s `velocity` does — useful when
+the animation continues a gesture that was already moving. It is signed in value space, so a
+positive value means the property is already increasing:
+
+```tsx
+
+```
+
+Units follow the JS side — DIPs per second for `translateX`/`translateY`, degrees per second for
+`rotate`, and plain units per second for `scale` and `opacity`. It has no effect on web, where a
+spring compiles to a single normalized CSS easing curve that cannot express a per-property starting
+velocity.
+
## Disabling animations
```tsx
diff --git a/example/src/demos/SpringVelocityDemo.tsx b/example/src/demos/SpringVelocityDemo.tsx
new file mode 100644
index 0000000..ca59587
--- /dev/null
+++ b/example/src/demos/SpringVelocityDemo.tsx
@@ -0,0 +1,68 @@
+import { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { EaseView } from 'react-native-ease';
+
+import { Section } from '../components/Section';
+import { Button } from '../components/Button';
+
+const SPRING = { damping: 14, stiffness: 120, mass: 1 } as const;
+
+const ROWS = [
+ { label: 'velocity: -600', velocity: -600 },
+ { label: 'velocity: 0', velocity: 0 },
+ { label: 'velocity: 600', velocity: 600 },
+];
+
+export function SpringVelocityDemo() {
+ const [moved, setMoved] = useState(false);
+ return (
+
+
+ Same spring, different initial velocity. Positive launches the box in
+ the direction it is already travelling, negative pulls it back first.
+
+ {ROWS.map((row) => (
+
+ {row.label}
+
+
+
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ caption: {
+ color: '#8a939e',
+ fontSize: 13,
+ marginBottom: 16,
+ },
+ row: {
+ marginBottom: 12,
+ },
+ label: {
+ color: '#8a939e',
+ fontSize: 12,
+ marginBottom: 4,
+ },
+ track: {
+ height: 40,
+ justifyContent: 'center',
+ },
+ box: {
+ width: 40,
+ height: 40,
+ backgroundColor: '#4a90d9',
+ borderRadius: 8,
+ },
+});
diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts
index 755ff05..6ec1cf2 100644
--- a/example/src/demos/index.ts
+++ b/example/src/demos/index.ts
@@ -27,6 +27,7 @@ import { TransformOriginDemo } from './TransformOriginDemo';
import { PerPropertyDemo } from './PerPropertyDemo';
import { ShadowDemo } from './ShadowDemo';
import { SpinDemo } from './SpinDemo';
+import { SpringVelocityDemo } from './SpringVelocityDemo';
import { UniwindDemo } from './uniwind/UniwindDemo';
interface DemoEntry {
@@ -66,6 +67,11 @@ export const demos: Record = {
section: 'Timing',
},
'delay': { component: DelayDemo, title: 'Delay', section: 'Timing' },
+ 'spring-velocity': {
+ component: SpringVelocityDemo,
+ title: 'Spring Velocity',
+ section: 'Timing',
+ },
'combined': { component: CombinedDemo, title: 'Combined', section: 'Timing' },
'styled-card': {
component: StyledCardDemo,
diff --git a/ios/EaseView.mm b/ios/EaseView.mm
index 55ef4db..2acab0b 100644
--- a/ios/EaseView.mm
+++ b/ios/EaseView.mm
@@ -87,6 +87,7 @@ static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY,
float damping;
float stiffness;
float mass;
+ float velocity;
std::string loop;
int delay;
};
@@ -113,6 +114,7 @@ static EaseTransitionConfig transitionConfigFromStruct(const T &src) {
config.damping = src.damping;
config.stiffness = src.stiffness;
config.mass = src.mass;
+ config.velocity = src.velocity;
config.loop = src.loop;
config.delay = src.delay;
return config;
@@ -294,7 +296,20 @@ - (CAAnimation *)createAnimationForKeyPath:(NSString *)keyPath
spring.damping = config.damping;
spring.stiffness = config.stiffness;
spring.mass = config.mass;
+ // config.velocity is in value units per second; CASpringAnimation
+ // normalizes initialVelocity against the from->to distance (1 means "the
+ // whole distance in one second"), which also flips the sign for
+ // decreasing animations.
spring.initialVelocity = 0;
+ if (config.velocity != 0 && [fromValue isKindOfClass:[NSNumber class]] &&
+ [toValue isKindOfClass:[NSNumber class]]) {
+ double delta = [(NSNumber *)toValue doubleValue] -
+ [(NSNumber *)fromValue doubleValue];
+ if (fabs(delta) > 1e-9) {
+ spring.initialVelocity = config.velocity / delta;
+ }
+ }
+ // settlingDuration accounts for initialVelocity, so read it after.
spring.duration = spring.settlingDuration;
return spring;
} else {
diff --git a/skills/react-native-ease-refactor/SKILL.md b/skills/react-native-ease-refactor/SKILL.md
index 51ff2e2..7fc3170 100644
--- a/skills/react-native-ease-refactor/SKILL.md
+++ b/skills/react-native-ease-refactor/SKILL.md
@@ -81,7 +81,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView:
| Reanimated / Animated Pattern | EaseView Equivalent |
| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `useSharedValue` + `useAnimatedStyle` + `withTiming` for opacity, translate, scale, rotate, borderRadius, backgroundColor | `animate={{ prop: value }}` + `transition={{ type: 'timing', duration, easing }}` |
-| `withSpring` | `transition={{ type: 'spring', damping, stiffness, mass }}` |
+| `withSpring` | `transition={{ type: 'spring', damping, stiffness, mass, velocity }}` |
| `entering={FadeIn}` / `FadeIn.duration(N)` | `initialAnimate={{ opacity: 0 }}` + `animate={{ opacity: 1 }}` + timing transition |
| `entering={FadeInDown}` / `FadeInUp` | `initialAnimate={{ opacity: 0, translateY: ±value }}` + `animate={{ opacity: 1, translateY: 0 }}` |
| `entering={SlideInLeft}` / `SlideInRight` | `initialAnimate={{ translateX: ±value }}` + `animate={{ translateX: 0 }}` |
@@ -116,6 +116,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView:
| `damping` | `10` | `15` | **Must set `damping: 10`** |
| `stiffness` | `100` | `120` | **Must set `stiffness: 100`** |
| `mass` | `1` | `1` | Same — omit |
+| `velocity` | `0` | `0` | Same — carry over only when set |
**Reanimated v4 defaults:**
@@ -124,6 +125,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView:
| `damping` | `120` | `15` | **Must set `damping: 120`** |
| `stiffness` | `900` | `120` | **Must set `stiffness: 900`** |
| `mass` | `4` | `1` | **Must set `mass: 4`** |
+| `velocity` | `0` | `0` | Same — carry over only when set |
Reanimated v4 changed to a critically damped, snappy spring (no bounce) as the default. The rationale was that the old physics-based defaults were too sensitive to start/end conditions. v4 recommends using `duration` + `dampingRatio` instead of raw physics params.
@@ -179,12 +181,19 @@ RN Animated uses `friction`/`tension` by default: `friction: 7, tension: 40`. Th
| stiffness (tension) | `40` | `120` | **Must set `stiffness: 40`** |
| damping (friction) | `7` | `15` | **Must set `damping: 7`** |
| mass | `1` | `1` | Same — omit |
+| velocity | `0` | `0` | Same — carry over only when set |
+
+`Animated.spring` also accepts `bounciness`/`speed` instead of `friction`/`tension`. Those go
+through `SpringConfig.fromBouncinessAndSpeed`, so convert with that function rather than mapping
+the numbers directly — the default `bounciness: 0, speed: 12` resolves to
+`{ stiffness: 342.1, damping: 36.93 }`.
### Unit Conversions
- **Rotation:** Reanimated uses `'45deg'` strings in transforms → EaseView uses `45` (number, degrees). Strip the `'deg'` suffix and parse to number.
- **Translation:** Both use DIPs (density-independent pixels). No conversion needed.
- **Scale:** Both use unitless multipliers. No conversion needed.
+- **Spring velocity:** Same units as the property being animated, per second — DIPs for translate, degrees for rotate, unitless for scale and opacity. No conversion needed, but note it is per-transition, so a `transform` transition shared by translate and scale can only carry one meaningful velocity. Split the categories if both need one.
---
@@ -416,6 +425,7 @@ transition={{
damping: 15, // default 15
stiffness: 120, // default 120
mass: 1, // default 1
+ velocity: 0, // property units/s, default 0, native only
delay: 0, // ms, default 0
}}
```
diff --git a/src/EaseView.tsx b/src/EaseView.tsx
index f039261..aadc03f 100644
--- a/src/EaseView.tsx
+++ b/src/EaseView.tsx
@@ -96,6 +96,7 @@ const DEFAULT_CONFIG: NativeTransitionConfig = {
damping: 15,
stiffness: 120,
mass: 1,
+ velocity: 0,
loop: 'none',
delay: 0,
};
@@ -133,6 +134,7 @@ function resolveSingleConfig(config: SingleTransition): NativeTransitionConfig {
const damping = config.type === 'spring' ? config.damping ?? 15 : 15;
const stiffness = config.type === 'spring' ? config.stiffness ?? 120 : 120;
const mass = config.type === 'spring' ? config.mass ?? 1 : 1;
+ const velocity = config.type === 'spring' ? config.velocity ?? 0 : 0;
const loop: string =
config.type === 'timing' ? config.loop ?? 'none' : 'none';
const delay =
@@ -146,6 +148,7 @@ function resolveSingleConfig(config: SingleTransition): NativeTransitionConfig {
damping,
stiffness,
mass,
+ velocity,
loop,
delay,
};
diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx
index f452d27..20963df 100644
--- a/src/EaseView.web.tsx
+++ b/src/EaseView.web.tsx
@@ -284,6 +284,9 @@ function resolveEasing(transition: SingleTransition | undefined): string {
const d = transition.damping ?? 15;
const s = transition.stiffness ?? 120;
const m = transition.mass ?? 1;
+ // `velocity` is not applied here: the easing is a single normalized 0->1
+ // curve shared by every property in the category, while an initial velocity
+ // only has meaning relative to each property's own from->to distance.
if (supportsLinearEasing()) {
return getSpringEasing(d, s, m).easing;
}
diff --git a/src/EaseViewNativeComponent.ts b/src/EaseViewNativeComponent.ts
index 4f22c1e..76e61fc 100644
--- a/src/EaseViewNativeComponent.ts
+++ b/src/EaseViewNativeComponent.ts
@@ -16,6 +16,7 @@ type NativeTransitionConfig = Readonly<{
damping: Float;
stiffness: Float;
mass: Float;
+ velocity: Float;
loop: string;
delay: Int32;
}>;
diff --git a/src/__tests__/EaseView.test.tsx b/src/__tests__/EaseView.test.tsx
index a53af59..073ec68 100644
--- a/src/__tests__/EaseView.test.tsx
+++ b/src/__tests__/EaseView.test.tsx
@@ -188,6 +188,37 @@ describe('EaseView', () => {
expect(t.defaultConfig.damping).toBe(15);
expect(t.defaultConfig.stiffness).toBe(120);
expect(t.defaultConfig.mass).toBe(1);
+ expect(t.defaultConfig.velocity).toBe(0);
+ });
+
+ it('passes spring velocity through to the native config', () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(getNativeProps().transitions.defaultConfig.velocity).toBe(2.5);
+
+ // Negative velocity means the value is already moving down.
+ rerender(
+ ,
+ );
+ expect(getNativeProps().transitions.defaultConfig.velocity).toBe(-2.5);
+ });
+
+ it('ignores velocity on timing transitions', () => {
+ render(
+ ,
+ );
+ expect(getNativeProps().transitions.defaultConfig.velocity).toBe(0);
});
it('passes none transition type to defaultConfig', () => {
diff --git a/src/types.ts b/src/types.ts
index b6dc605..6d37ef6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -33,6 +33,13 @@ export type SpringTransition = {
stiffness?: number;
/** Mass of the object — higher values mean slower, more momentum. @default 1 */
mass?: number;
+ /**
+ * Initial velocity, in animated-property units per second, signed in value
+ * space — positive means the value is already moving up. Units follow the JS
+ * side: DIPs for translateX/translateY, degrees for rotate, unitless for
+ * scale and opacity. Ignored on web. @default 0
+ */
+ velocity?: number;
/** Delay in milliseconds before the animation starts. @default 0 */
delay?: number;
};