Skip to content

Commit f379aa3

Browse files
feat(web): use CSS linear() for spring animations
Replace the hardcoded cubic-bezier spring approximation with a real spring physics simulation that generates CSS linear() easing points. This matches native iOS/Android spring behavior much more closely. - Add damped spring simulator (120Hz, downsampled to 60fps output) - Cache generated linear() strings by spring params - Lazy feature detection with cubic-bezier fallback - Duration now derived from simulation settling time (uses stiffness)
1 parent 79f0898 commit f379aa3

1 file changed

Lines changed: 92 additions & 13 deletions

File tree

src/EaseView.web.tsx

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,80 @@ const EASING_PRESETS: Record<string, CubicBezier> = {
3030
easeInOut: [0.42, 0, 0.58, 1],
3131
};
3232

33+
// ---------------------------------------------------------------------------
34+
// Spring simulation → CSS linear() easing
35+
// ---------------------------------------------------------------------------
36+
37+
/** Simulate a damped spring from 0 → 1 and return settling duration + sample points. */
38+
function simulateSpring(
39+
damping: number,
40+
stiffness: number,
41+
mass: number,
42+
): { durationMs: number; points: number[] } {
43+
const dt = 1 / 120; // 120 Hz simulation
44+
const maxTime = 10; // 10s safety cap
45+
let x = 0;
46+
let v = 0;
47+
const samples: number[] = [0];
48+
let step = 0;
49+
50+
while (step * dt < maxTime) {
51+
const a = (-stiffness * (x - 1) - damping * v) / mass;
52+
v += a * dt;
53+
x += v * dt;
54+
step++;
55+
// Downsample to ~60 fps (every 2nd sample)
56+
if (step % 2 === 0) {
57+
samples.push(Math.round(x * 10000) / 10000);
58+
}
59+
// Settled?
60+
if (Math.abs(x - 1) < 0.001 && Math.abs(v) < 0.001) break;
61+
}
62+
63+
// Ensure last point is exactly 1
64+
samples[samples.length - 1] = 1;
65+
66+
return {
67+
durationMs: Math.round(step * dt * 1000),
68+
points: samples,
69+
};
70+
}
71+
72+
/** Cache for computed spring easing strings (keyed by damping-stiffness-mass). */
73+
const springCache = new Map<string, { duration: number; easing: string }>();
74+
75+
function getSpringEasing(
76+
damping: number,
77+
stiffness: number,
78+
mass: number,
79+
): { duration: number; easing: string } {
80+
const key = `${damping}-${stiffness}-${mass}`;
81+
let cached = springCache.get(key);
82+
if (cached) return cached;
83+
84+
const { durationMs, points } = simulateSpring(damping, stiffness, mass);
85+
const easing = `linear(${points.join(', ')})`;
86+
cached = { duration: durationMs, easing };
87+
springCache.set(key, cached);
88+
return cached;
89+
}
90+
91+
/** Detect CSS linear() support (lazy, cached). */
92+
let linearSupported: boolean | null = null;
93+
function supportsLinearEasing(): boolean {
94+
if (linearSupported != null) return linearSupported;
95+
try {
96+
const el = document.createElement('div');
97+
el.style.transitionTimingFunction = 'linear(0, 1)';
98+
linearSupported = el.style.transitionTimingFunction !== '';
99+
} catch {
100+
linearSupported = false;
101+
}
102+
return linearSupported;
103+
}
104+
105+
const SPRING_FALLBACK_EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
106+
33107
export type EaseViewProps = {
34108
animate?: AnimateProps;
35109
initialAnimate?: AnimateProps;
@@ -154,9 +228,19 @@ function resolvePerCategoryConfigs(
154228
}
155229

156230
function resolveEasing(transition: SingleTransition | undefined): string {
157-
if (!transition || transition.type !== 'timing') {
158-
return 'cubic-bezier(0.42, 0, 0.58, 1)';
231+
if (!transition || transition.type === 'none') {
232+
return 'linear';
233+
}
234+
if (transition.type === 'spring') {
235+
const d = transition.damping ?? 15;
236+
const s = transition.stiffness ?? 120;
237+
const m = transition.mass ?? 1;
238+
if (supportsLinearEasing()) {
239+
return getSpringEasing(d, s, m).easing;
240+
}
241+
return SPRING_FALLBACK_EASING;
159242
}
243+
// timing
160244
const easing = transition.easing ?? 'easeInOut';
161245
const bezier: CubicBezier = Array.isArray(easing)
162246
? easing
@@ -168,10 +252,11 @@ function resolveDuration(transition: SingleTransition | undefined): number {
168252
if (!transition) return 300;
169253
if (transition.type === 'timing') return transition.duration ?? 300;
170254
if (transition.type === 'none') return 0;
171-
const damping = transition.damping ?? 15;
172-
const mass = transition.mass ?? 1;
173-
const tau = (2 * mass) / damping;
174-
return Math.round(tau * 4 * 1000);
255+
// Spring: use simulation-derived duration (incorporates stiffness)
256+
const d = transition.damping ?? 15;
257+
const s = transition.stiffness ?? 120;
258+
const m = transition.mass ?? 1;
259+
return getSpringEasing(d, s, m).duration;
175260
}
176261

177262
/** Counter for unique keyframe names. */
@@ -237,13 +322,7 @@ export function EaseView({
237322
})
238323
.map((key) => {
239324
const cfg = categoryConfigs[key];
240-
const springEasing =
241-
cfg.type === 'spring'
242-
? 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
243-
: null;
244-
return `${CSS_PROP_MAP[key]} ${cfg.duration}ms ${
245-
springEasing ?? cfg.easing
246-
}`;
325+
return `${CSS_PROP_MAP[key]} ${cfg.duration}ms ${cfg.easing}`;
247326
})
248327
.join(', ') || 'none';
249328

0 commit comments

Comments
 (0)