@zakkster/lite-physics 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Physics.d.ts +8 -0
- package/Physics.js +546 -0
- package/README.md +131 -0
- package/package.json +47 -0
package/Physics.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface SpringStyleInstance { set(target: number): void; snap(value: number): void; readonly value: number; readonly settled: boolean; destroy(): void; }
|
|
2
|
+
export function springStyle(element: HTMLElement | string, property: string, options?: { stiffness?: number; damping?: number; initialValue?: number; min?: number; max?: number; unit?: string; template?: (value: number) => string }): SpringStyleInstance;
|
|
3
|
+
export interface SpringCardInstance { nudgeTo(x: number, y: number): void; snapTo(x: number, y: number): void; destroy(): void; }
|
|
4
|
+
export function springCard(element: HTMLElement | string, options?: { stiffness?: number; damping?: number; strength?: number }): SpringCardInstance;
|
|
5
|
+
export interface DraggableCardInstance { destroy(): void; }
|
|
6
|
+
export function draggableSpringCard(element: HTMLElement | string, options?: { stiffness?: number; damping?: number; rotationFactor?: number; onRelease?: (info: { x: number; y: number; vx: number; vy: number }) => void }): DraggableCardInstance;
|
|
7
|
+
export interface SwipeCardInstance { readonly swiped: boolean; destroy(): void; }
|
|
8
|
+
export function swipeCard(element: HTMLElement | string, options?: { stiffness?: number; damping?: number; swipeThreshold?: number; velocityThreshold?: number; escapeDistance?: number; rotationFactor?: number; onSwipeLeft?: (el: HTMLElement) => void; onSwipeRight?: (el: HTMLElement) => void; removeOnSwipe?: boolean }): SwipeCardInstance;
|
package/Physics.js
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-physics — Spring-Based DOM Animation
|
|
3
|
+
*
|
|
4
|
+
* The "Apple-like fluid physics" library. Provides spring-animated CSS
|
|
5
|
+
* properties, magnetic hover cards, draggable cards with velocity handoff,
|
|
6
|
+
* and Tinder-style swipe cards.
|
|
7
|
+
*
|
|
8
|
+
* Zero rAF spam: all springs share a single Ticker.
|
|
9
|
+
* Zero external deps: composes lite-ui (Spring), lite-ticker, lite-pointer-tracker, lite-lerp.
|
|
10
|
+
*
|
|
11
|
+
* Depends on:
|
|
12
|
+
* @zakkster/lite-ui (Spring class)
|
|
13
|
+
* lite-ticker (shared RAF loop)
|
|
14
|
+
* lite-pointer-tracker (unified pointer input)
|
|
15
|
+
* @zakkster/lite-lerp (clamp)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Spring } from '@zakkster/lite-ui';
|
|
19
|
+
import { Ticker } from '@zakkster/lite-ticker';
|
|
20
|
+
import { PointerTracker } from 'lite-pointer-tracker';
|
|
21
|
+
import { clamp } from '@zakkster/lite-lerp';
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────
|
|
25
|
+
// SHARED TICKER (ref-counted, same pattern as lite-timeline)
|
|
26
|
+
// ─────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
let _sharedTicker = null;
|
|
29
|
+
let _sharedRefs = 0;
|
|
30
|
+
|
|
31
|
+
function acquireTicker() {
|
|
32
|
+
if (!_sharedTicker) {
|
|
33
|
+
_sharedTicker = new Ticker();
|
|
34
|
+
_sharedTicker.start();
|
|
35
|
+
}
|
|
36
|
+
_sharedRefs++;
|
|
37
|
+
return _sharedTicker;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function releaseTicker() {
|
|
41
|
+
_sharedRefs--;
|
|
42
|
+
if (_sharedRefs <= 0 && _sharedTicker) {
|
|
43
|
+
_sharedTicker.destroy();
|
|
44
|
+
_sharedTicker = null;
|
|
45
|
+
_sharedRefs = 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
// ─────────────────────────────────────────────────────────
|
|
51
|
+
// VELOCITY TRACKER
|
|
52
|
+
// Computes drag deltas and release velocity from raw
|
|
53
|
+
// PointerEvents. Stores last 5 positions over 80ms.
|
|
54
|
+
// PointerTracker gives us raw events — we do the math.
|
|
55
|
+
// ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function createVelocityTracker() {
|
|
58
|
+
let startX = 0, startY = 0;
|
|
59
|
+
let lastX = 0, lastY = 0;
|
|
60
|
+
const history = []; // { x, y, time } — last 5 samples
|
|
61
|
+
const MAX_HISTORY = 5;
|
|
62
|
+
const MAX_AGE = 80; // ms — only use recent samples
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
start(e) {
|
|
66
|
+
startX = e.clientX;
|
|
67
|
+
startY = e.clientY;
|
|
68
|
+
lastX = startX;
|
|
69
|
+
lastY = startY;
|
|
70
|
+
history.length = 0;
|
|
71
|
+
history.push({ x: startX, y: startY, time: performance.now() });
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
move(e) {
|
|
75
|
+
lastX = e.clientX;
|
|
76
|
+
lastY = e.clientY;
|
|
77
|
+
history.push({ x: lastX, y: lastY, time: performance.now() });
|
|
78
|
+
if (history.length > MAX_HISTORY) history.shift();
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/** Drag distance from start point. */
|
|
82
|
+
get dragX() { return lastX - startX; },
|
|
83
|
+
get dragY() { return lastY - startY; },
|
|
84
|
+
|
|
85
|
+
/** Release velocity in px/s, computed from recent position history. */
|
|
86
|
+
computeVelocity() {
|
|
87
|
+
const now = performance.now();
|
|
88
|
+
// Find the oldest sample within MAX_AGE
|
|
89
|
+
let oldest = null;
|
|
90
|
+
for (let i = 0; i < history.length; i++) {
|
|
91
|
+
if (now - history[i].time < MAX_AGE) {
|
|
92
|
+
oldest = history[i];
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!oldest || history.length < 2) return { vx: 0, vy: 0 };
|
|
97
|
+
|
|
98
|
+
const newest = history[history.length - 1];
|
|
99
|
+
const dt = (newest.time - oldest.time) / 1000; // seconds
|
|
100
|
+
if (dt < 0.001) return { vx: 0, vy: 0 };
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
vx: (newest.x - oldest.x) / dt,
|
|
104
|
+
vy: (newest.y - oldest.y) / dt,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
// ─────────────────────────────────────────────────────────
|
|
112
|
+
// DOM HELPERS
|
|
113
|
+
// ─────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function resolveEl(selectorOrEl, name) {
|
|
116
|
+
if (!selectorOrEl) return null;
|
|
117
|
+
const el = typeof selectorOrEl === 'string' ? document.querySelector(selectorOrEl) : selectorOrEl;
|
|
118
|
+
if (!el) console.warn(`@zakkster/lite-physics [${name}]: element not found`);
|
|
119
|
+
return el;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const NOOP = Object.freeze({ set() {}, snap() {}, destroy() {} });
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
// ═══════════════════════════════════════════════════════════
|
|
126
|
+
// springStyle — Animate a single CSS property with spring physics
|
|
127
|
+
// ═══════════════════════════════════════════════════════════
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Animate a CSS property with spring physics.
|
|
131
|
+
*
|
|
132
|
+
* @param {HTMLElement|string} element
|
|
133
|
+
* @param {string} property CSS property name (e.g. 'opacity', 'transform')
|
|
134
|
+
* @param {Object} [options]
|
|
135
|
+
* @param {number} [options.stiffness=170]
|
|
136
|
+
* @param {number} [options.damping=26]
|
|
137
|
+
* @param {number} [options.initialValue=0]
|
|
138
|
+
* @param {number} [options.min=-Infinity]
|
|
139
|
+
* @param {number} [options.max=Infinity]
|
|
140
|
+
* @param {string} [options.unit=''] CSS unit suffix (e.g. 'px', 'deg', '%', '')
|
|
141
|
+
* @param {Function} [options.template] Custom template: (value) => CSS string.
|
|
142
|
+
* Overrides unit. E.g. v => `scale(${v})`
|
|
143
|
+
*/
|
|
144
|
+
export function springStyle(element, property, {
|
|
145
|
+
stiffness = 170,
|
|
146
|
+
damping = 26,
|
|
147
|
+
initialValue = 0,
|
|
148
|
+
min = -Infinity,
|
|
149
|
+
max = Infinity,
|
|
150
|
+
unit = '',
|
|
151
|
+
template,
|
|
152
|
+
} = {}) {
|
|
153
|
+
const el = resolveEl(element, 'springStyle');
|
|
154
|
+
if (!el) return NOOP;
|
|
155
|
+
|
|
156
|
+
const spring = new Spring(initialValue, { stiffness, damping });
|
|
157
|
+
const ticker = acquireTicker();
|
|
158
|
+
let isAnimating = false;
|
|
159
|
+
let removeFn = null;
|
|
160
|
+
|
|
161
|
+
const update = (dt) => {
|
|
162
|
+
const val = clamp(spring.update(dt / 1000), min, max);
|
|
163
|
+
|
|
164
|
+
if (template) {
|
|
165
|
+
el.style[property] = template(val);
|
|
166
|
+
} else {
|
|
167
|
+
el.style[property] = unit ? `${val}${unit}` : val;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (spring.settled) {
|
|
171
|
+
if (removeFn) { removeFn(); removeFn = null; }
|
|
172
|
+
isAnimating = false;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function wake() {
|
|
177
|
+
if (isAnimating) return;
|
|
178
|
+
isAnimating = true;
|
|
179
|
+
removeFn = ticker.add(update);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
set(targetValue) {
|
|
184
|
+
spring.set(targetValue);
|
|
185
|
+
wake();
|
|
186
|
+
},
|
|
187
|
+
snap(value) {
|
|
188
|
+
spring.snap(value);
|
|
189
|
+
const clamped = clamp(value, min, max);
|
|
190
|
+
el.style[property] = template ? template(clamped) : (unit ? `${clamped}${unit}` : clamped);
|
|
191
|
+
},
|
|
192
|
+
get value() { return spring.value; },
|
|
193
|
+
get settled() { return spring.settled; },
|
|
194
|
+
destroy() {
|
|
195
|
+
if (removeFn) { removeFn(); removeFn = null; }
|
|
196
|
+
isAnimating = false;
|
|
197
|
+
releaseTicker();
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
// ═══════════════════════════════════════════════════════════
|
|
204
|
+
// springCard — 2D magnetic hover (Apple TV style)
|
|
205
|
+
// ═══════════════════════════════════════════════════════════
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Magnetic hover card. Element springs toward cursor position,
|
|
209
|
+
* springs back to center on mouse leave.
|
|
210
|
+
*
|
|
211
|
+
* @param {HTMLElement|string} element
|
|
212
|
+
* @param {Object} [options]
|
|
213
|
+
* @param {number} [options.stiffness=220]
|
|
214
|
+
* @param {number} [options.damping=24]
|
|
215
|
+
* @param {number} [options.strength=0.2] How much the element follows the cursor (0–1)
|
|
216
|
+
*/
|
|
217
|
+
export function springCard(element, {
|
|
218
|
+
stiffness = 220,
|
|
219
|
+
damping = 24,
|
|
220
|
+
strength = 0.2,
|
|
221
|
+
} = {}) {
|
|
222
|
+
const el = resolveEl(element, 'springCard');
|
|
223
|
+
if (!el) return NOOP;
|
|
224
|
+
|
|
225
|
+
const sx = new Spring(0, { stiffness, damping });
|
|
226
|
+
const sy = new Spring(0, { stiffness, damping });
|
|
227
|
+
const ticker = acquireTicker();
|
|
228
|
+
let isAnimating = false;
|
|
229
|
+
let removeFn = null;
|
|
230
|
+
|
|
231
|
+
el.style.willChange = 'transform';
|
|
232
|
+
|
|
233
|
+
const update = (dt) => {
|
|
234
|
+
const dtSec = dt / 1000;
|
|
235
|
+
const x = sx.update(dtSec);
|
|
236
|
+
const y = sy.update(dtSec);
|
|
237
|
+
|
|
238
|
+
el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
|
|
239
|
+
|
|
240
|
+
if (sx.settled && sy.settled) {
|
|
241
|
+
el.style.transform = '';
|
|
242
|
+
if (removeFn) { removeFn(); removeFn = null; }
|
|
243
|
+
isAnimating = false;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
function wake() {
|
|
248
|
+
if (isAnimating) return;
|
|
249
|
+
isAnimating = true;
|
|
250
|
+
removeFn = ticker.add(update);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const ac = new AbortController();
|
|
254
|
+
const signal = ac.signal;
|
|
255
|
+
|
|
256
|
+
el.addEventListener('mouseenter', () => wake(), { signal });
|
|
257
|
+
el.addEventListener('mousemove', (e) => {
|
|
258
|
+
const rect = el.getBoundingClientRect();
|
|
259
|
+
const dx = e.clientX - (rect.left + rect.width / 2);
|
|
260
|
+
const dy = e.clientY - (rect.top + rect.height / 2);
|
|
261
|
+
sx.set(dx * strength);
|
|
262
|
+
sy.set(dy * strength);
|
|
263
|
+
wake();
|
|
264
|
+
}, { signal });
|
|
265
|
+
el.addEventListener('mouseleave', () => {
|
|
266
|
+
sx.set(0);
|
|
267
|
+
sy.set(0);
|
|
268
|
+
}, { signal });
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
nudgeTo(x, y) { sx.set(x); sy.set(y); wake(); },
|
|
272
|
+
snapTo(x, y) {
|
|
273
|
+
sx.snap(x); sy.snap(y);
|
|
274
|
+
el.style.transform = x === 0 && y === 0 ? '' : `translate3d(${x}px, ${y}px, 0)`;
|
|
275
|
+
},
|
|
276
|
+
destroy() {
|
|
277
|
+
ac.abort();
|
|
278
|
+
if (removeFn) { removeFn(); removeFn = null; }
|
|
279
|
+
isAnimating = false;
|
|
280
|
+
el.style.willChange = '';
|
|
281
|
+
el.style.transform = '';
|
|
282
|
+
releaseTicker();
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
// ═══════════════════════════════════════════════════════════
|
|
289
|
+
// draggableSpringCard — Drag with velocity handoff
|
|
290
|
+
// The user drags 1:1. On release, their swipe momentum
|
|
291
|
+
// is injected into the springs. The card overshoots and
|
|
292
|
+
// bounces back naturally.
|
|
293
|
+
// ═══════════════════════════════════════════════════════════
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Draggable card with spring-based velocity handoff.
|
|
297
|
+
*
|
|
298
|
+
* @param {HTMLElement|string} element
|
|
299
|
+
* @param {Object} [options]
|
|
300
|
+
* @param {number} [options.stiffness=150]
|
|
301
|
+
* @param {number} [options.damping=18] Lower = bouncier on release
|
|
302
|
+
* @param {number} [options.rotationFactor=0.05] Rotation per pixel of X drag
|
|
303
|
+
* @param {Function} [options.onRelease] Called with { x, y, vx, vy } on release
|
|
304
|
+
*/
|
|
305
|
+
export function draggableSpringCard(element, {
|
|
306
|
+
stiffness = 150,
|
|
307
|
+
damping = 18,
|
|
308
|
+
rotationFactor = 0.05,
|
|
309
|
+
onRelease,
|
|
310
|
+
} = {}) {
|
|
311
|
+
const el = resolveEl(element, 'draggableSpringCard');
|
|
312
|
+
if (!el) return NOOP;
|
|
313
|
+
|
|
314
|
+
const sx = new Spring(0, { stiffness, damping });
|
|
315
|
+
const sy = new Spring(0, { stiffness, damping });
|
|
316
|
+
const ticker = acquireTicker();
|
|
317
|
+
const vel = createVelocityTracker();
|
|
318
|
+
let isAnimating = false;
|
|
319
|
+
let isDragging = false;
|
|
320
|
+
let removeFn = null;
|
|
321
|
+
|
|
322
|
+
el.style.willChange = 'transform';
|
|
323
|
+
|
|
324
|
+
const render = (x, y) => {
|
|
325
|
+
const rot = x * rotationFactor;
|
|
326
|
+
el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) rotate(${rot.toFixed(2)}deg)`;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const update = (dt) => {
|
|
330
|
+
if (isDragging) return;
|
|
331
|
+
const dtSec = dt / 1000;
|
|
332
|
+
const x = sx.update(dtSec);
|
|
333
|
+
const y = sy.update(dtSec);
|
|
334
|
+
render(x, y);
|
|
335
|
+
|
|
336
|
+
if (sx.settled && sy.settled) {
|
|
337
|
+
el.style.transform = '';
|
|
338
|
+
if (removeFn) { removeFn(); removeFn = null; }
|
|
339
|
+
isAnimating = false;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
function wake() {
|
|
344
|
+
if (isAnimating || isDragging) return;
|
|
345
|
+
isAnimating = true;
|
|
346
|
+
removeFn = ticker.add(update);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const tracker = new PointerTracker(el, {
|
|
350
|
+
onStart(e) {
|
|
351
|
+
isDragging = true;
|
|
352
|
+
vel.start(e);
|
|
353
|
+
// Pause spring animation, user has control
|
|
354
|
+
if (removeFn) { removeFn(); removeFn = null; isAnimating = false; }
|
|
355
|
+
el.classList.add('is-dragging');
|
|
356
|
+
},
|
|
357
|
+
onMove(e) {
|
|
358
|
+
vel.move(e);
|
|
359
|
+
// Move 1:1 with pointer
|
|
360
|
+
render(vel.dragX, vel.dragY);
|
|
361
|
+
// Keep springs synced so they don't jump on release
|
|
362
|
+
sx.value = vel.dragX;
|
|
363
|
+
sy.value = vel.dragY;
|
|
364
|
+
sx.settled = false;
|
|
365
|
+
sy.settled = false;
|
|
366
|
+
},
|
|
367
|
+
onEnd() {
|
|
368
|
+
isDragging = false;
|
|
369
|
+
el.classList.remove('is-dragging');
|
|
370
|
+
|
|
371
|
+
// Velocity handoff: inject swipe momentum into springs
|
|
372
|
+
const { vx, vy } = vel.computeVelocity();
|
|
373
|
+
sx.velocity = vx;
|
|
374
|
+
sy.velocity = vy;
|
|
375
|
+
|
|
376
|
+
if (onRelease) onRelease({ x: vel.dragX, y: vel.dragY, vx, vy });
|
|
377
|
+
|
|
378
|
+
// Spring back to center
|
|
379
|
+
sx.set(0);
|
|
380
|
+
sy.set(0);
|
|
381
|
+
wake();
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
destroy() {
|
|
387
|
+
tracker.destroy();
|
|
388
|
+
if (removeFn) { removeFn(); removeFn = null; }
|
|
389
|
+
isAnimating = false;
|
|
390
|
+
el.style.willChange = '';
|
|
391
|
+
el.style.transform = '';
|
|
392
|
+
el.classList.remove('is-dragging');
|
|
393
|
+
releaseTicker();
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
// ═══════════════════════════════════════════════════════════
|
|
400
|
+
// swipeCard — Tinder-style swipe with velocity projection
|
|
401
|
+
// If the user drags far enough OR flicks fast enough,
|
|
402
|
+
// the card is thrown off-screen. Otherwise it snaps back.
|
|
403
|
+
// ═══════════════════════════════════════════════════════════
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Swipe-to-dismiss card with velocity projection.
|
|
407
|
+
*
|
|
408
|
+
* @param {HTMLElement|string} element
|
|
409
|
+
* @param {Object} [options]
|
|
410
|
+
* @param {number} [options.stiffness=150]
|
|
411
|
+
* @param {number} [options.damping=20]
|
|
412
|
+
* @param {number} [options.swipeThreshold=100] Drag distance to commit (px)
|
|
413
|
+
* @param {number} [options.velocityThreshold=500] Flick speed to commit (px/s)
|
|
414
|
+
* @param {number} [options.escapeDistance=1500] How far off-screen it flies
|
|
415
|
+
* @param {number} [options.rotationFactor=0.08] Rotation per pixel of X drag
|
|
416
|
+
* @param {Function} [options.onSwipeLeft] Called with element when swiped left
|
|
417
|
+
* @param {Function} [options.onSwipeRight] Called with element when swiped right
|
|
418
|
+
* @param {boolean} [options.removeOnSwipe=true] Remove element from DOM after swipe
|
|
419
|
+
*/
|
|
420
|
+
export function swipeCard(element, {
|
|
421
|
+
stiffness = 150,
|
|
422
|
+
damping = 20,
|
|
423
|
+
swipeThreshold = 100,
|
|
424
|
+
velocityThreshold = 500,
|
|
425
|
+
escapeDistance = 1500,
|
|
426
|
+
rotationFactor = 0.08,
|
|
427
|
+
onSwipeLeft,
|
|
428
|
+
onSwipeRight,
|
|
429
|
+
removeOnSwipe = true,
|
|
430
|
+
} = {}) {
|
|
431
|
+
const el = resolveEl(element, 'swipeCard');
|
|
432
|
+
if (!el) return NOOP;
|
|
433
|
+
|
|
434
|
+
const sx = new Spring(0, { stiffness, damping });
|
|
435
|
+
const sy = new Spring(0, { stiffness, damping });
|
|
436
|
+
const ticker = acquireTicker();
|
|
437
|
+
const vel = createVelocityTracker();
|
|
438
|
+
let isAnimating = false;
|
|
439
|
+
let isDragging = false;
|
|
440
|
+
let hasSwiped = false;
|
|
441
|
+
let removeFn = null;
|
|
442
|
+
|
|
443
|
+
el.style.willChange = 'transform, opacity';
|
|
444
|
+
|
|
445
|
+
const render = (x, y) => {
|
|
446
|
+
const rot = x * rotationFactor;
|
|
447
|
+
const opacity = Math.max(0, 1 - Math.abs(x) / (swipeThreshold * 3));
|
|
448
|
+
el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) rotate(${rot.toFixed(2)}deg)`;
|
|
449
|
+
el.style.opacity = opacity;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const update = (dt) => {
|
|
453
|
+
if (isDragging) return;
|
|
454
|
+
const dtSec = dt / 1000;
|
|
455
|
+
const x = sx.update(dtSec);
|
|
456
|
+
const y = sy.update(dtSec);
|
|
457
|
+
render(x, y);
|
|
458
|
+
|
|
459
|
+
if (sx.settled && sy.settled) {
|
|
460
|
+
if (removeFn) { removeFn(); removeFn = null; }
|
|
461
|
+
isAnimating = false;
|
|
462
|
+
|
|
463
|
+
if (hasSwiped && removeOnSwipe) {
|
|
464
|
+
el.remove();
|
|
465
|
+
} else if (!hasSwiped) {
|
|
466
|
+
el.style.transform = '';
|
|
467
|
+
el.style.opacity = '';
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
function wake() {
|
|
473
|
+
if (isAnimating || isDragging) return;
|
|
474
|
+
isAnimating = true;
|
|
475
|
+
removeFn = ticker.add(update);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const tracker = new PointerTracker(el, {
|
|
479
|
+
onStart(e) {
|
|
480
|
+
if (hasSwiped) return;
|
|
481
|
+
isDragging = true;
|
|
482
|
+
vel.start(e);
|
|
483
|
+
if (removeFn) { removeFn(); removeFn = null; isAnimating = false; }
|
|
484
|
+
el.classList.add('is-dragging');
|
|
485
|
+
},
|
|
486
|
+
onMove(e) {
|
|
487
|
+
if (hasSwiped) return;
|
|
488
|
+
vel.move(e);
|
|
489
|
+
render(vel.dragX, vel.dragY);
|
|
490
|
+
sx.value = vel.dragX;
|
|
491
|
+
sy.value = vel.dragY;
|
|
492
|
+
sx.settled = false;
|
|
493
|
+
sy.settled = false;
|
|
494
|
+
},
|
|
495
|
+
onEnd() {
|
|
496
|
+
if (hasSwiped) return;
|
|
497
|
+
isDragging = false;
|
|
498
|
+
el.classList.remove('is-dragging');
|
|
499
|
+
|
|
500
|
+
const x = vel.dragX;
|
|
501
|
+
const y = vel.dragY;
|
|
502
|
+
const { vx, vy } = vel.computeVelocity();
|
|
503
|
+
|
|
504
|
+
sx.velocity = vx;
|
|
505
|
+
sy.velocity = vy;
|
|
506
|
+
|
|
507
|
+
// Decision: did they drag far enough OR flick fast enough?
|
|
508
|
+
const isRight = x > swipeThreshold || vx > velocityThreshold;
|
|
509
|
+
const isLeft = x < -swipeThreshold || vx < -velocityThreshold;
|
|
510
|
+
|
|
511
|
+
if (isRight) {
|
|
512
|
+
hasSwiped = true;
|
|
513
|
+
sx.set(escapeDistance);
|
|
514
|
+
sy.set(y + vy * 0.2);
|
|
515
|
+
if (onSwipeRight) onSwipeRight(el);
|
|
516
|
+
} else if (isLeft) {
|
|
517
|
+
hasSwiped = true;
|
|
518
|
+
sx.set(-escapeDistance);
|
|
519
|
+
sy.set(y + vy * 0.2);
|
|
520
|
+
if (onSwipeLeft) onSwipeLeft(el);
|
|
521
|
+
} else {
|
|
522
|
+
// Not enough — snap back
|
|
523
|
+
sx.set(0);
|
|
524
|
+
sy.set(0);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
wake();
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
/** Whether the card has been swiped away. */
|
|
533
|
+
get swiped() { return hasSwiped; },
|
|
534
|
+
|
|
535
|
+
destroy() {
|
|
536
|
+
tracker.destroy();
|
|
537
|
+
if (removeFn) { removeFn(); removeFn = null; }
|
|
538
|
+
isAnimating = false;
|
|
539
|
+
el.style.willChange = '';
|
|
540
|
+
el.style.transform = '';
|
|
541
|
+
el.style.opacity = '';
|
|
542
|
+
el.classList.remove('is-dragging');
|
|
543
|
+
releaseTicker();
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @zakkster/lite-physics
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@zakkster/lite-physics)
|
|
4
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-physics)
|
|
5
|
+
[](https://www.npmjs.com/package/@zakkster/lite-physics)
|
|
6
|
+
[](https://www.npmjs.com/package/@zakkster/lite-physics)
|
|
7
|
+

|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
|
|
10
|
+
Spring-based DOM animation with velocity handoff. Magnetic hover, draggable cards, Tinder-style swipe.
|
|
11
|
+
|
|
12
|
+
**100 springs share 1 RAF loop. Zero rAF spam. Zero GC.**
|
|
13
|
+
|
|
14
|
+
## Why lite-physics?
|
|
15
|
+
|
|
16
|
+
| Feature | lite-physics | Framer Motion | React Spring | GSAP Draggable |
|
|
17
|
+
|---|---|---|---|---|
|
|
18
|
+
| **Shared Ticker** | **Yes (1 RAF for all)** | No | No | No |
|
|
19
|
+
| **Velocity handoff** | **Yes (measured)** | Approximate | No | No |
|
|
20
|
+
| **Swipe-to-dismiss** | **Yes (built-in)** | Manual | No | Manual |
|
|
21
|
+
| **Framework-free** | **Yes** | React only | React only | jQuery-era |
|
|
22
|
+
| **Spring physics** | **Yes** | Yes | Yes | No |
|
|
23
|
+
| **Zero-GC** | **Yes** | No | No | No |
|
|
24
|
+
| **Bundle size** | **< 3KB** | ~30KB | ~20KB | ~25KB |
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @zakkster/lite-physics
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
### Magnetic Hover Card
|
|
35
|
+
|
|
36
|
+
```javascript
|
|
37
|
+
import { springCard } from '@zakkster/lite-physics';
|
|
38
|
+
|
|
39
|
+
const card = springCard('.product-card', {
|
|
40
|
+
stiffness: 220,
|
|
41
|
+
damping: 24,
|
|
42
|
+
strength: 0.2, // 20% follow cursor
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Later: card.destroy()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Animate a CSS Property
|
|
49
|
+
|
|
50
|
+
```javascript
|
|
51
|
+
import { springStyle } from '@zakkster/lite-physics';
|
|
52
|
+
|
|
53
|
+
const opacity = springStyle('#modal', 'opacity', { stiffness: 200, damping: 30, initialValue: 0 });
|
|
54
|
+
const scale = springStyle('#modal', 'transform', { stiffness: 180, damping: 22, initialValue: 0.8, template: v => `scale(${v})` });
|
|
55
|
+
|
|
56
|
+
opacity.set(1);
|
|
57
|
+
scale.set(1);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Draggable Card with Velocity Handoff
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
import { draggableSpringCard } from '@zakkster/lite-physics';
|
|
64
|
+
|
|
65
|
+
const draggable = draggableSpringCard('.card', {
|
|
66
|
+
stiffness: 150,
|
|
67
|
+
damping: 18,
|
|
68
|
+
rotationFactor: 0.05,
|
|
69
|
+
onRelease: ({ vx, vy }) => console.log('Flick speed:', vx),
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Tinder-Style Swipe
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
import { swipeCard } from '@zakkster/lite-physics';
|
|
77
|
+
|
|
78
|
+
const swipe = swipeCard('.dating-card', {
|
|
79
|
+
swipeThreshold: 100,
|
|
80
|
+
velocityThreshold: 500,
|
|
81
|
+
onSwipeLeft: (el) => console.log('Nope'),
|
|
82
|
+
onSwipeRight: (el) => console.log('Like!'),
|
|
83
|
+
removeOnSwipe: true,
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Recipes
|
|
88
|
+
|
|
89
|
+
<details>
|
|
90
|
+
<summary><strong>Stack of Swipe Cards</strong></summary>
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
const cards = document.querySelectorAll('.card');
|
|
94
|
+
cards.forEach((card, i) => {
|
|
95
|
+
card.style.zIndex = cards.length - i;
|
|
96
|
+
swipeCard(card, {
|
|
97
|
+
onSwipeRight: () => handleLike(i),
|
|
98
|
+
onSwipeLeft: () => handleDislike(i),
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
</details>
|
|
104
|
+
|
|
105
|
+
<details>
|
|
106
|
+
<summary><strong>Spring-Animated Counter</strong></summary>
|
|
107
|
+
|
|
108
|
+
```javascript
|
|
109
|
+
const counter = springStyle('#price', 'textContent', {
|
|
110
|
+
stiffness: 120, damping: 20, initialValue: 0,
|
|
111
|
+
template: v => `$${Math.round(v)}`,
|
|
112
|
+
});
|
|
113
|
+
counter.set(99);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
</details>
|
|
117
|
+
|
|
118
|
+
## API
|
|
119
|
+
|
|
120
|
+
| Export | Description |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `springStyle(el, prop, options)` | Animate any CSS property with spring physics |
|
|
123
|
+
| `springCard(el, options)` | 2D magnetic hover (Apple TV style) |
|
|
124
|
+
| `draggableSpringCard(el, options)` | Drag with velocity handoff on release |
|
|
125
|
+
| `swipeCard(el, options)` | Tinder-style swipe-to-dismiss |
|
|
126
|
+
|
|
127
|
+
All return `{ destroy() }` for SPA cleanup.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zakkster/lite-physics",
|
|
3
|
+
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Spring-based DOM animation with velocity handoff. Magnetic hover, draggable cards, Tinder-style swipe. Shared ticker, zero rAF spam.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "Physics.js",
|
|
8
|
+
"types": "Physics.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./Physics.d.ts",
|
|
12
|
+
"import": "./Physics.js",
|
|
13
|
+
"default": "./Physics.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"Physics.js",
|
|
18
|
+
"Physics.d.ts",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-physics#readme",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/PeshoVurtoleta/lite-physics.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/PeshoVurtoleta/lite-physics/issues",
|
|
29
|
+
"email": "shinikchiev@yahoo.com"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@zakkster/lite-ui": "^1.0.0",
|
|
33
|
+
"@zakkster/lite-ticker": "^1.0.0",
|
|
34
|
+
"lite-pointer-tracker": "^1.0.0",
|
|
35
|
+
"@zakkster/lite-lerp": "^1.0.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"spring",
|
|
39
|
+
"physics",
|
|
40
|
+
"magnetic",
|
|
41
|
+
"draggable",
|
|
42
|
+
"swipe",
|
|
43
|
+
"velocity-handoff",
|
|
44
|
+
"zero-gc",
|
|
45
|
+
"animation"
|
|
46
|
+
]
|
|
47
|
+
}
|