@@ -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+
33107export type EaseViewProps = {
34108 animate ?: AnimateProps ;
35109 initialAnimate ?: AnimateProps ;
@@ -154,9 +228,19 @@ function resolvePerCategoryConfigs(
154228}
155229
156230function 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