@impractical-ai/motion 0.1.6 → 0.1.7

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.
@@ -0,0 +1,438 @@
1
+ // Universal shape morph — the shared-element architecture (how Framer
2
+ // Motion's layoutId / iOS matchedGeometryEffect / Flutter Hero do it):
3
+ // NO snapshots, NO carrier. Both REAL elements ride one interpolated box
4
+ // path (position + size + corner radius, with an arc lift); each is scaled
5
+ // to the path's box from its own natural geometry and they crossfade
6
+ // mid-flight, so each side is only visible while near its natural shape —
7
+ // distortion is never seen, and the target that lands IS the live element.
8
+ //
9
+ // Why this beats the snapshot-carrier approach it replaced:
10
+ // · the content that arrives is the real element — "part of the morph"
11
+ // by construction, not a bitmap dissolve
12
+ // · real box-shadows ride along (nothing goes flat mid-flight)
13
+ // · a spring ease rides the shared path — box, corners, and travel ring
14
+ // together with no delayed settle
15
+ // · the target's children are live and arrive individually (staggered)
16
+ //
17
+ // Determinism: geometry measured at build; per-frame updates are pure
18
+ // functions of one proxy tween; all end states are exact identities.
19
+ import { resolveTarget, layoutRect } from './kits'
20
+
21
+ type Position = number | string | undefined
22
+ type Rect = { left: number; top: number; width: number; height: number }
23
+ type Corners = { tl: number; tr: number; br: number; bl: number }
24
+
25
+ const EASE = 'ease-in-out-cubic'
26
+
27
+ function cornersOf(el: HTMLElement | null, rect: Rect): Corners {
28
+ if (!el) return { tl: 0, tr: 0, br: 0, bl: 0 }
29
+ const s = getComputedStyle(el)
30
+ const parse = (v: string): number => {
31
+ const n = parseFloat(v)
32
+ if (v.trim().endsWith('%')) return (n / 100) * Math.min(rect.width, rect.height)
33
+ return isNaN(n) ? 0 : n
34
+ }
35
+ const cap = Math.min(rect.width, rect.height) / 2
36
+ return {
37
+ tl: Math.min(cap, parse(s.borderTopLeftRadius)),
38
+ tr: Math.min(cap, parse(s.borderTopRightRadius)),
39
+ br: Math.min(cap, parse(s.borderBottomRightRadius)),
40
+ bl: Math.min(cap, parse(s.borderBottomLeftRadius)),
41
+ }
42
+ }
43
+
44
+ export type MorphOpts = {
45
+ /** 'apple' = expo-out flight (~90% of the change in the first ~15% of the
46
+ * time, long soft landing). 'spring' = the reference bounce: expo-out
47
+ * flight, then a LIGHT damped sine rides the same path — calibrated from
48
+ * the dnyxstudios AE expression (amp .04·v, freq 1.8Hz, decay e^-3t,
49
+ * eased envelope). One motion, no delayed settle, ~one visible overshoot. */
50
+ feel?: 'apple' | 'spring'
51
+ /** Tune the 'spring' bounce. `amp` = first overshoot as a fraction of the
52
+ * travel (default 0.025 — light), `freq` Hz (default 1.8), `decay` per
53
+ * second (default 3), `flight` = fraction of `duration` spent on the main
54
+ * move before the bounce tail (default 0.55). */
55
+ bounce?: { amp?: number; freq?: number; decay?: number; flight?: number }
56
+ duration?: number
57
+ /** Flight curve — ANY GSAP ease works: house names, 'elastic.out(1, 0.4)',
58
+ * 'back.out(1.8)'… Overshoots extrapolate past the target (kept renderable). */
59
+ ease?: string
60
+ /** 0..1 — center of the A→B crossfade along the flight. */
61
+ handoff?: number
62
+ /** Width of the crossfade as a fraction of the flight (default 0.3 —
63
+ * tight; each side shows only near its natural shape). */
64
+ crossfade?: number
65
+ /** Arc lift in px (0 disables). Default: auto from travel distance. */
66
+ arc?: number
67
+ /** Part choreography (default 'stagger'): the target's direct children
68
+ * arrive individually inside the landing box; the source's children are
69
+ * absorbed as it leaves. `false` = containers only. */
70
+ parts?: 'stagger' | false
71
+ /** Sub-component matching (default 'auto'): elements tagged
72
+ * `data-morph="name"` on BOTH sides (any depth) morph INTO each other —
73
+ * each pair rides its own interpolated box inside the flight, so the
74
+ * source's sub-component visibly becomes the target's. Untagged direct
75
+ * children are greedily auto-paired by text overlap. `false` disables. */
76
+ match?: 'auto' | false
77
+ /** Landing overshoot on top of a non-spring flight. `true`/default object
78
+ * for the soft house settle, `{ scale, duration, ease }` to tune (elastic
79
+ * landings live here), `false` for none. 'spring' feel ignores this — its
80
+ * ring is already in the flight. */
81
+ settle?: boolean | { scale?: number; duration?: number; ease?: string }
82
+ zIndex?: number
83
+ }
84
+
85
+ export function createMorphKit(deps: {
86
+ tl: any
87
+ gsap: any
88
+ stage: HTMLElement
89
+ config: { width: number; height: number }
90
+ }) {
91
+ const { tl, gsap, stage, config } = deps
92
+ const at = (position: Position): number => (typeof position === 'number' ? position : tl.duration())
93
+
94
+ const resolveEnd = (
95
+ target: string | Element | Rect | 'frame'
96
+ ): { el: HTMLElement | null; rect: Rect } => {
97
+ if (target === 'frame') {
98
+ return { el: null, rect: { left: 0, top: 0, width: config.width, height: config.height } }
99
+ }
100
+ if (typeof target === 'object' && target !== null && !(target instanceof Element)) {
101
+ return { el: null, rect: target as Rect }
102
+ }
103
+ const el = resolveTarget(stage, target as string | Element, 'morph')
104
+ return { el, rect: layoutRect(stage, el) }
105
+ }
106
+
107
+ // KNOWN LIMIT: morph and expand measure at build time via layoutRect. Inside
108
+ // a container the direction scales mid-timeline the flight path lands off —
109
+ // do the container zoom with camera.move (stage-level, measured out), or
110
+ // morph before/after the zoom. actions and highlight resolve lazily; morph's
111
+ // corner interpolation bakes its geometry and has not been converted.
112
+ const morph = (
113
+ from: string | Element,
114
+ to: string | Element | Rect | 'frame',
115
+ opts: MorphOpts = {},
116
+ position?: Position
117
+ ): { duration: number } => {
118
+ const aEl = resolveTarget(stage, from, 'morph')
119
+ const aRect = layoutRect(stage, aEl)
120
+ const bEnd = resolveEnd(to)
121
+ const bEl = bEnd.el
122
+ const bRect = bEnd.rect
123
+ const apple = opts.feel === 'apple'
124
+ const springy = opts.feel === 'spring'
125
+ const duration = opts.duration ?? (apple ? 0.6 : springy ? 1.15 : 0.7)
126
+ const handoff = Math.max(0.1, Math.min(0.9, opts.handoff ?? 0.5))
127
+ const cross = Math.max(0.08, Math.min(0.8, opts.crossfade ?? 0.3))
128
+ const start = at(position)
129
+ const travel = Math.hypot(bRect.left - aRect.left, bRect.top - aRect.top)
130
+ const arc = opts.arc ?? (apple || springy ? Math.min(24, travel * 0.05) : Math.min(40, travel * 0.08))
131
+ const ca = cornersOf(aEl, aRect)
132
+ const cb = cornersOf(bEl, bRect)
133
+ let eased: (p: number) => number
134
+ if (springy) {
135
+ // The reference bounce (dnyxstudios AE expression): main move, then
136
+ // value + v·amp·sin(2π·freq·t)/e^(decay·t)·easeOut-envelope. Ported
137
+ // with amp normalized to "first overshoot as a fraction of travel".
138
+ const bo = opts.bounce ?? {}
139
+ const amp = bo.amp ?? 0.025
140
+ const freq = bo.freq ?? 1.8
141
+ const decay = bo.decay ?? 3
142
+ const flight = Math.max(0.2, Math.min(0.9, bo.flight ?? 0.55))
143
+ // power3.out, not expo.out: expo parks motionless for the last ~30% of
144
+ // the flight, and a bounce after a stall reads as LAG. The wobble also
145
+ // OVERLAPS the flight's tail so the arrival momentum flows straight
146
+ // into the first overshoot — motion never stops.
147
+ const base = gsap.parseEase(opts.ease ?? 'power3.out')
148
+ // Normalize so the first sine peak equals `amp` exactly.
149
+ const tPeak = 1 / (4 * freq)
150
+ const peak = Math.sin(2 * Math.PI * freq * tPeak) * Math.exp(-decay * tPeak)
151
+ const scale = amp / Math.max(1e-6, peak)
152
+ const Dm = duration * flight
153
+ const overlap = Math.min(0.12, Dm * 0.25)
154
+ eased = (p: number) => {
155
+ const tAbs = p * duration
156
+ let v = base(Math.min(1, tAbs / Math.max(1e-6, Dm)))
157
+ const t = tAbs - (Dm - overlap)
158
+ if (t > 0) {
159
+ const envelope = Math.max(0, 1 - t / 3)
160
+ v += scale * Math.sin(2 * Math.PI * freq * t) * Math.exp(-decay * t) * envelope
161
+ }
162
+ return v
163
+ }
164
+ } else {
165
+ eased = gsap.parseEase(opts.ease ?? (apple ? 'expo.out' : EASE))
166
+ }
167
+
168
+ // Both elements need transform-from-origin-0 semantics for the box math.
169
+ gsap.set(aEl, { transformOrigin: '0 0' })
170
+ if (bEl) gsap.set(bEl, { transformOrigin: '0 0' })
171
+ // Authors set radius INLINE (React styles) — clearProps would delete it,
172
+ // so capture the original declarations to restore at the end.
173
+ const aRadius0 = aEl.style.borderRadius
174
+ const bRadius0 = bEl ? bEl.style.borderRadius : ''
175
+
176
+ // ── Sub-component matching: pairs ride their own box inside the flight ──
177
+ const wrapLooseText = (el: HTMLElement | null) => {
178
+ if (!el) return
179
+ for (const node of Array.from(el.childNodes)) {
180
+ if (node.nodeType === 3 && node.textContent && node.textContent.trim()) {
181
+ const span = document.createElement('span')
182
+ span.style.display = 'inline-block'
183
+ node.parentNode?.insertBefore(span, node)
184
+ span.appendChild(node)
185
+ }
186
+ }
187
+ }
188
+ wrapLooseText(aEl)
189
+ wrapLooseText(bEl)
190
+ type Pair = { a: HTMLElement; b: HTMLElement; ar: Rect; br: Rect }
191
+ const pairs: Pair[] = []
192
+ if ((opts.match ?? 'auto') !== false && bEl) {
193
+ // Explicit: data-morph names present on both sides, any depth.
194
+ const named = (root: HTMLElement) => {
195
+ const map = new Map<string, HTMLElement>()
196
+ for (const el of Array.from(root.querySelectorAll<HTMLElement>('[data-morph]'))) {
197
+ const name = el.dataset.morph
198
+ if (name && !map.has(name)) map.set(name, el)
199
+ }
200
+ return map
201
+ }
202
+ const aNamed = named(aEl)
203
+ const bNamed = named(bEl)
204
+ for (const [name, a] of aNamed) {
205
+ const b = bNamed.get(name)
206
+ if (b) pairs.push({ a, b, ar: layoutRect(stage, a), br: layoutRect(stage, b) })
207
+ }
208
+ // Greedy auto-match of untagged DIRECT children by text-token overlap.
209
+ const tokens = (el: HTMLElement) =>
210
+ new Set(
211
+ (el.textContent ?? '')
212
+ .toLowerCase()
213
+ .replace(/[^a-z0-9\s]/g, ' ')
214
+ .split(/\s+/)
215
+ .filter((w) => w.length > 1)
216
+ )
217
+ const taken = new Set(pairs.flatMap((p) => [p.a, p.b]))
218
+ const free = (el: HTMLElement) =>
219
+ !taken.has(el) && !el.querySelector('[data-morph]') && !el.dataset.morph
220
+ const aFree = (Array.from(aEl.children) as HTMLElement[]).filter(free)
221
+ const bFree = bEl ? (Array.from(bEl.children) as HTMLElement[]).filter(free) : []
222
+ for (const a of aFree) {
223
+ const ta = tokens(a)
224
+ if (!ta.size) continue
225
+ let best: HTMLElement | null = null
226
+ let bestScore = 0
227
+ for (const b of bFree) {
228
+ if (taken.has(b)) continue
229
+ let s = 0
230
+ for (const w of tokens(b)) if (ta.has(w)) s += 1
231
+ if (b.tagName === a.tagName) s += 0.25
232
+ if (s > bestScore) {
233
+ bestScore = s
234
+ best = b
235
+ }
236
+ }
237
+ if (best && bestScore >= 1) {
238
+ taken.add(a)
239
+ taken.add(best)
240
+ pairs.push({ a, b: best, ar: layoutRect(stage, a), br: layoutRect(stage, best) })
241
+ }
242
+ }
243
+ }
244
+ // An element that is (or contains) a matched pair must not be absorbed
245
+ // or arrival-staggered — the pair choreography owns it.
246
+ const ownedByPair = (el: HTMLElement) =>
247
+ pairs.some((p) => el === p.a || el === p.b || el.contains(p.a) || el.contains(p.b))
248
+
249
+ // ── The shared box path — one pure function both elements ride ──
250
+ const proxy = { p: 0 }
251
+ const pose = (p: number) => {
252
+ const e = eased(p)
253
+ const w = Math.max(2, aRect.width + (bRect.width - aRect.width) * e)
254
+ const h = Math.max(2, aRect.height + (bRect.height - aRect.height) * e)
255
+ const x = aRect.left + (bRect.left - aRect.left) * e
256
+ const y =
257
+ aRect.top +
258
+ (bRect.top - aRect.top) * e -
259
+ Math.sin(Math.PI * Math.min(1, Math.max(0, e))) * arc
260
+ const cap = Math.min(w, h) / 2
261
+ const cl = (v: number) => Math.max(0, Math.min(cap, v))
262
+ const radius = cl((ca.tl + ca.tr + ca.br + ca.bl) / 4 + ((cb.tl + cb.tr + cb.br + cb.bl) / 4 - (ca.tl + ca.tr + ca.br + ca.bl) / 4) * e)
263
+ // Two-axis radius compensation: under non-uniform scale a single
264
+ // radius renders elliptical, so counter each axis separately.
265
+ const sxA0 = w / Math.max(1, aRect.width)
266
+ const syA0 = h / Math.max(1, aRect.height)
267
+ gsap.set(aEl, {
268
+ x: x - aRect.left,
269
+ y: y - aRect.top,
270
+ scaleX: sxA0,
271
+ scaleY: syA0,
272
+ borderRadius: `${radius / Math.max(0.01, sxA0)}px / ${radius / Math.max(0.01, syA0)}px`,
273
+ force3D: false,
274
+ })
275
+ if (bEl) {
276
+ const sxB0 = w / Math.max(1, bRect.width)
277
+ const syB0 = h / Math.max(1, bRect.height)
278
+ gsap.set(bEl, {
279
+ x: x - bRect.left,
280
+ y: y - bRect.top,
281
+ scaleX: sxB0,
282
+ scaleY: syB0,
283
+ borderRadius: `${radius / Math.max(0.01, sxB0)}px / ${radius / Math.max(0.01, syB0)}px`,
284
+ force3D: false,
285
+ })
286
+ }
287
+ // Matched pairs: counter-transform each side inside its parent so both
288
+ // occupy the SAME interpolated scene box — during the crossfade they
289
+ // are geometrically aligned, and one sub-component becomes the other.
290
+ if (pairs.length) {
291
+ const arcTerm = Math.sin(Math.PI * Math.min(1, Math.max(0, e))) * arc
292
+ const sxA = w / Math.max(1, aRect.width)
293
+ const syA = h / Math.max(1, aRect.height)
294
+ const sxB = w / Math.max(1, bRect.width)
295
+ const syB = h / Math.max(1, bRect.height)
296
+ for (const pr of pairs) {
297
+ const cw = pr.ar.width + (pr.br.width - pr.ar.width) * e
298
+ const chh = pr.ar.height + (pr.br.height - pr.ar.height) * e
299
+ const px = pr.ar.left + (pr.br.left - pr.ar.left) * e
300
+ const py = pr.ar.top + (pr.br.top - pr.ar.top) * e - arcTerm
301
+ gsap.set(pr.a, {
302
+ transformOrigin: '0 0',
303
+ x: (px - x) / sxA - (pr.ar.left - aRect.left),
304
+ y: (py - y) / syA - (pr.ar.top - aRect.top),
305
+ scaleX: cw / Math.max(0.01, sxA * pr.ar.width),
306
+ scaleY: chh / Math.max(0.01, syA * pr.ar.height),
307
+ force3D: false,
308
+ })
309
+ gsap.set(pr.b, {
310
+ transformOrigin: '0 0',
311
+ x: (px - x) / sxB - (pr.br.left - bRect.left),
312
+ y: (py - y) / syB - (pr.br.top - bRect.top),
313
+ scaleX: cw / Math.max(0.01, sxB * pr.br.width),
314
+ scaleY: chh / Math.max(0.01, syB * pr.br.height),
315
+ force3D: false,
316
+ })
317
+ }
318
+ }
319
+ }
320
+
321
+ // ── Crossfade: each side is visible only near its natural shape ──
322
+ const fadeStart = start + duration * (handoff - cross / 2)
323
+ const fadeSpan = duration * cross
324
+ if (bEl) gsap.set(bEl, { autoAlpha: 0 })
325
+ tl.to(aEl, { autoAlpha: 0, duration: fadeSpan * 0.6, ease: 'ease-in-quad' }, fadeStart + fadeSpan * 0.2)
326
+ if (bEl) {
327
+ tl.to(bEl, { autoAlpha: 1, duration: fadeSpan * 0.7, ease: 'ease-out-quad' }, fadeStart)
328
+ }
329
+
330
+ // ── Parts: the target's children ARRIVE as live elements; the source's
331
+ // are absorbed as it leaves. All end states are exact identities. ──
332
+ if ((opts.parts ?? 'stagger') !== false) {
333
+ const kids = (el: HTMLElement | null): HTMLElement[] =>
334
+ el ? (Array.from(el.children) as HTMLElement[]).filter((k) => !ownedByPair(k)) : []
335
+ const absorbSpan = Math.max(0.15, duration * handoff * 0.7)
336
+ for (const [i, kid] of kids(aEl).entries()) {
337
+ tl.to(
338
+ kid,
339
+ { scale: 0.88, y: 8, autoAlpha: 0, duration: absorbSpan, ease: 'ease-in-quad', force3D: false },
340
+ start + i * 0.05
341
+ )
342
+ // The source ends hidden; restore its children for a clean baseline.
343
+ tl.set(kid, { scale: 1, y: 0, autoAlpha: 1 }, start + duration)
344
+ }
345
+ for (const [i, kid] of kids(bEl).entries()) {
346
+ gsap.set(kid, { autoAlpha: 0, y: 12, scale: 0.94 })
347
+ tl.to(
348
+ kid,
349
+ { autoAlpha: 1, y: 0, scale: 1, duration: 0.36, ease: 'promo-out', force3D: false },
350
+ start + duration * handoff + i * 0.07
351
+ )
352
+ }
353
+ }
354
+
355
+ // ── The flight ──
356
+ tl.to(proxy, { p: 1, duration, ease: 'none', onUpdate: () => pose(proxy.p) }, start)
357
+ // Exact end states: A hidden and reset, B at true identity.
358
+ // Restore the author's inline radius; if it lived in a stylesheet,
359
+ // clearing the inline override reveals it again.
360
+ tl.set(
361
+ aEl,
362
+ { autoAlpha: 0, x: 0, y: 0, scaleX: 1, scaleY: 1, ...(aRadius0 ? { borderRadius: aRadius0 } : { clearProps: 'borderRadius' }) },
363
+ start + duration
364
+ )
365
+ if (bEl) {
366
+ tl.set(
367
+ bEl,
368
+ { x: 0, y: 0, scaleX: 1, scaleY: 1, ...(bRadius0 ? { borderRadius: bRadius0 } : { clearProps: 'borderRadius' }) },
369
+ start + duration
370
+ )
371
+ }
372
+ for (const pr of pairs) {
373
+ tl.set(pr.a, { x: 0, y: 0, scaleX: 1, scaleY: 1 }, start + duration)
374
+ tl.set(pr.b, { x: 0, y: 0, scaleX: 1, scaleY: 1 }, start + duration)
375
+ }
376
+
377
+ // ── Optional landing settle (spring feel needs none — it rang in-flight) ──
378
+ const settle = opts.settle === undefined ? !springy : opts.settle !== false
379
+ if (settle && bEl) {
380
+ const s = typeof opts.settle === 'object' ? opts.settle : {}
381
+ tl.fromTo(
382
+ bEl,
383
+ { scale: s.scale ?? (apple ? 1.03 : 1.05), transformOrigin: '50% 50%' },
384
+ {
385
+ scale: 1,
386
+ duration: s.duration ?? (apple ? 0.4 : 0.26),
387
+ ease: s.ease ?? 'promo-soft',
388
+ force3D: false,
389
+ immediateRender: false,
390
+ },
391
+ start + duration + 0.001
392
+ )
393
+ }
394
+ // Anticipation: the source contracts just before leaving.
395
+ if (opts.settle !== false && start >= 0.14) {
396
+ tl.to(aEl, { scale: 0.985, duration: 0.14, ease: 'ease-in-quad', force3D: false }, start - 0.14)
397
+ tl.set(aEl, { scale: 1 }, start)
398
+ }
399
+ return { duration }
400
+ }
401
+
402
+ /** The chip⇄full-bleed round trip. Meant for MEDIA (a thumbnail, a video) —
403
+ * the element persists and fills the frame; 'cover' bleeds on aspect
404
+ * mismatch, 'contain' letterboxes. Expanding a text-heavy card reads as a
405
+ * mistake — expand its media child. */
406
+ const expand = (
407
+ target: string | Element,
408
+ opts: { duration?: number; hold?: number; back?: boolean; inset?: number; fit?: 'cover' | 'contain' } = {},
409
+ position?: Position
410
+ ): { duration: number } => {
411
+ const el = resolveTarget(stage, target, 'expand')
412
+ const rect = layoutRect(stage, el)
413
+ const inset = opts.inset ?? 0
414
+ const frame: Rect = {
415
+ left: inset,
416
+ top: inset,
417
+ width: config.width - inset * 2,
418
+ height: config.height - inset * 2,
419
+ }
420
+ const duration = opts.duration ?? 0.7
421
+ const hold = opts.hold ?? 1.2
422
+ const start = at(position)
423
+ const scale = (opts.fit === 'contain' ? Math.min : Math.max)(
424
+ frame.width / rect.width,
425
+ frame.height / rect.height
426
+ )
427
+ const cx = rect.left + rect.width / 2
428
+ const cy = rect.top + rect.height / 2
429
+ const dx = frame.left + frame.width / 2 - cx
430
+ const dy = frame.top + frame.height / 2 - cy
431
+ tl.to(el, { x: dx, y: dy, scale, duration, ease: EASE, force3D: false }, start)
432
+ if (opts.back === false) return { duration: duration + hold }
433
+ tl.to(el, { x: 0, y: 0, scale: 1, duration: duration * 0.85, ease: EASE, force3D: false }, start + duration + hold)
434
+ return { duration: duration + hold + duration * 0.85 }
435
+ }
436
+
437
+ return { morph, expand }
438
+ }