@impractical-ai/motion 0.1.7 → 0.1.9

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.
@@ -1,483 +0,0 @@
1
- // Ensemble choreography — words that move MANY elements as one system.
2
- // dropIn — elements arrive from BEHIND THE CAMERA onto the layout
3
- // (reference: raylight.app drop-in film)
4
- // assemble — elements fly in from scattered planar directions and settle
5
- // into their exact layout slots (Apple tile wall, Firefly
6
- // place-on-canvas, Slack puzzle-slots)
7
- // scatter — the inverse: elements explode outward from their layout
8
- // fountain — elements fly BALLISTICALLY through the frame: tossed up,
9
- // raining down, or bursting radially (Pitch slide fountain,
10
- // Apple card pile, Slack icon rain)
11
- // confetti — generated celebration particles (Simar's wow-staging beat)
12
- //
13
- // SHOWPIECE DOCTRINE (fountain/confetti especially): these are BEAT-LEVEL
14
- // events — at most 1–2 per film, and they must land on a story payoff (a
15
- // launch, a celebration, a reveal). A showpiece on an ambient beat is noise.
16
- //
17
- // Generalized + deterministic: geometry is measured (layoutRect); poses are
18
- // closed-form functions of the seeked time (ballistics = parabola, no
19
- // integration); per-item variation comes from index hashing, never
20
- // Math.random; every element ends at exact identity or fully exited.
21
- import { resolveTargets, layoutRect } from './kits'
22
-
23
- type Position = number | string | undefined
24
-
25
- /** Deterministic per-index variation in [0, 1). */
26
- function hash01(i: number, salt = 0): number {
27
- let x = (i + 1) * 2654435761 + salt * 40503
28
- x = ((x >>> 16) ^ x) * 0x45d9f3b
29
- x = ((x >>> 16) ^ x) * 0x45d9f3b
30
- return (((x >>> 16) ^ x) >>> 0) % 10000 / 10000
31
- }
32
-
33
- export type DropInOpts = {
34
- /** Plane blow-up factor the elements start at (default 3.2). */
35
- from?: number
36
- /** Per-element flight time (default 0.75). */
37
- duration?: number
38
- /** Stagger between elements (default 0.1). */
39
- stagger?: number
40
- /** Arrival order (default 'layout' = top-to-bottom, left-to-right). */
41
- order?: 'layout' | 'dom' | 'random-ish'
42
- /** Soft focus during the approach: brief blur that resolves on landing
43
- * (default true; capped well under the 20px ceiling). */
44
- soft?: boolean
45
- /** Landing overshoot (default 1.02 → 1). `false` disables. */
46
- settle?: boolean
47
- ease?: string
48
- }
49
-
50
- export function createEnsembleKit(deps: {
51
- tl: any
52
- gsap: any
53
- stage: HTMLElement
54
- config: { width: number; height: number }
55
- }) {
56
- const { tl, gsap, stage, config } = deps
57
- const at = (position: Position): number => (typeof position === 'number' ? position : tl.duration())
58
-
59
- const dropIn = (
60
- targets: string | Element | Element[],
61
- opts: DropInOpts = {},
62
- position?: Position
63
- ): { duration: number } => {
64
- const els = resolveTargets(stage, targets, 'dropIn')
65
- const s0 = Math.max(1.2, opts.from ?? 3.2)
66
- const duration = opts.duration ?? 0.75
67
- const stagger = opts.stagger ?? 0.1
68
- const ease = opts.ease ?? 'power3.out'
69
- const start = at(position)
70
- const cx = config.width / 2
71
- const cy = config.height / 2
72
-
73
- const entries = els.map((el, i) => {
74
- const r = layoutRect(stage, el as HTMLElement)
75
- return { el: el as HTMLElement, r, i }
76
- })
77
- if (opts.order !== 'dom') {
78
- if (opts.order === 'random-ish') {
79
- // Deterministic shuffle: order by a hash of layout position.
80
- entries.sort(
81
- (a, b) =>
82
- ((a.r.left * 7 + a.r.top * 13) % 97) - ((b.r.left * 7 + b.r.top * 13) % 97)
83
- )
84
- } else {
85
- entries.sort((a, b) => a.r.top - b.r.top || a.r.left - b.r.left)
86
- }
87
- }
88
-
89
- for (const [order, { el, r }] of entries.entries()) {
90
- // Start pose = the element's position if the whole plane were scaled
91
- // s0 about the frame center: it sits further out along the radial
92
- // direction and s0× larger — i.e., closer to the camera.
93
- const ecx = r.left + r.width / 2
94
- const ecy = r.top + r.height / 2
95
- const x0 = (ecx - cx) * (s0 - 1)
96
- const y0 = (ecy - cy) * (s0 - 1)
97
- const t = start + order * stagger
98
- gsap.set(el, { transformOrigin: '50% 50%' })
99
- tl.fromTo(
100
- el,
101
- { x: x0, y: y0, scale: s0, autoAlpha: 0 },
102
- { x: 0, y: 0, scale: opts.settle === false ? 1 : 1.02, autoAlpha: 1, duration, ease, force3D: false },
103
- t
104
- )
105
- if (opts.settle !== false) {
106
- tl.to(el, { scale: 1, duration: 0.22, ease: 'promo-soft', force3D: false }, t + duration)
107
- }
108
- if (opts.soft !== false) {
109
- tl.fromTo(
110
- el,
111
- { filter: 'blur(9px)' },
112
- { filter: 'blur(0px)', duration: duration * 0.85, ease: 'power2.out' },
113
- t
114
- )
115
- tl.set(el, { clearProps: 'filter' }, t + duration)
116
- }
117
- }
118
- const total = (entries.length - 1) * stagger + duration + (opts.settle === false ? 0 : 0.22)
119
- return { duration: Math.max(0, total) }
120
- }
121
-
122
- /** Elements fly in from scattered planar directions and settle into their
123
- * exact layout slots. `from`: 'edges' (each from its nearest frame edge),
124
- * 'center' (burst outward-in), 'above'. Ends at exact identity. */
125
- const assemble = (
126
- targets: string | Element | Element[],
127
- opts: { from?: 'edges' | 'center' | 'above'; distance?: number; duration?: number; stagger?: number; spin?: number; ease?: string } = {},
128
- position?: Position
129
- ): { duration: number } => {
130
- const els = resolveTargets(stage, targets, 'assemble')
131
- const from = opts.from ?? 'edges'
132
- const duration = opts.duration ?? 0.7
133
- const stagger = opts.stagger ?? 0.07
134
- const spin = opts.spin ?? 14
135
- const ease = opts.ease ?? 'promo-out'
136
- const start = at(position)
137
- const cx = config.width / 2
138
- const cy = config.height / 2
139
- els.forEach((raw, i) => {
140
- const el = raw as HTMLElement
141
- const r = layoutRect(stage, el)
142
- const ecx = r.left + r.width / 2
143
- const ecy = r.top + r.height / 2
144
- const d = opts.distance ?? Math.max(240, Math.hypot(cx, cy) * 0.5)
145
- let dx = 0
146
- let dy = 0
147
- if (from === 'above') {
148
- dx = (hash01(i, 1) - 0.5) * d * 0.5
149
- dy = -d
150
- } else {
151
- // Outward along the radial from frame center (edges) or inward (center).
152
- const len = Math.max(1, Math.hypot(ecx - cx, ecy - cy))
153
- const sign = from === 'center' ? -1 : 1
154
- dx = ((ecx - cx) / len) * d * sign
155
- dy = ((ecy - cy) / len) * d * sign
156
- }
157
- const t = start + i * stagger
158
- gsap.set(el, { transformOrigin: '50% 50%' })
159
- tl.fromTo(
160
- el,
161
- { x: dx, y: dy, rotation: (hash01(i, 2) - 0.5) * 2 * spin, autoAlpha: 0 },
162
- { x: 0, y: 0, rotation: 0, autoAlpha: 1, duration, ease, force3D: false },
163
- t
164
- )
165
- })
166
- return { duration: (els.length - 1) * stagger + duration }
167
- }
168
-
169
- /** The inverse of assemble: elements explode outward from their layout
170
- * (radially from frame center), fading as they go. They end hidden with
171
- * transforms reset, so the layout is clean if re-shown. */
172
- const scatter = (
173
- targets: string | Element | Element[],
174
- opts: { distance?: number; duration?: number; stagger?: number; spin?: number; ease?: string } = {},
175
- position?: Position
176
- ): { duration: number } => {
177
- const els = resolveTargets(stage, targets, 'scatter')
178
- const duration = opts.duration ?? 0.6
179
- const stagger = opts.stagger ?? 0.05
180
- const spin = opts.spin ?? 24
181
- const start = at(position)
182
- const cx = config.width / 2
183
- const cy = config.height / 2
184
- els.forEach((raw, i) => {
185
- const el = raw as HTMLElement
186
- const r = layoutRect(stage, el)
187
- const ecx = r.left + r.width / 2
188
- const ecy = r.top + r.height / 2
189
- const len = Math.max(1, Math.hypot(ecx - cx, ecy - cy))
190
- const d = (opts.distance ?? 420) * (0.7 + hash01(i, 3) * 0.6)
191
- const t = start + i * stagger
192
- gsap.set(el, { transformOrigin: '50% 50%' })
193
- tl.to(
194
- el,
195
- {
196
- x: ((ecx - cx) / len) * d,
197
- y: ((ecy - cy) / len) * d,
198
- rotation: (hash01(i, 4) - 0.5) * 2 * spin,
199
- autoAlpha: 0,
200
- duration,
201
- ease: opts.ease ?? 'promo-in',
202
- force3D: false,
203
- },
204
- t
205
- )
206
- tl.set(el, { x: 0, y: 0, rotation: 0 }, t + duration)
207
- })
208
- return { duration: (els.length - 1) * stagger + duration }
209
- }
210
-
211
- /** SHOWPIECE. Elements fly BALLISTICALLY through the frame — tossed up and
212
- * falling back past the bottom ('up'), raining through ('down'), or
213
- * bursting radially from their own position ('burst'). Items EXIT: they
214
- * end hidden with transforms reset. Closed-form parabola per item (pure
215
- * function of seeked time), spin and velocity varied by index hash.
216
- *
217
- * WHEN NOT TO USE: on ambient beats. A fountain must be a story payoff —
218
- * something launched, generated, or released — and at most one or two
219
- * showpieces belong in a whole film. */
220
- const fountain = (
221
- targets: string | Element | Element[],
222
- opts: {
223
- direction?: 'up' | 'down' | 'burst'
224
- duration?: number
225
- stagger?: number
226
- gravity?: number
227
- power?: number
228
- spin?: number
229
- ease?: string
230
- } = {},
231
- position?: Position
232
- ): { duration: number } => {
233
- const els = resolveTargets(stage, targets, 'fountain')
234
- const direction = opts.direction ?? 'up'
235
- const duration = opts.duration ?? 2.0
236
- const stagger = opts.stagger ?? 0.08
237
- const g = opts.gravity ?? 2600
238
- const power = opts.power ?? 1
239
- const spin = opts.spin ?? 260
240
- const start = at(position)
241
- const H = config.height
242
- els.forEach((raw, i) => {
243
- const el = raw as HTMLElement
244
- const h1 = hash01(i, 5)
245
- const h2 = hash01(i, 6)
246
- const h3 = hash01(i, 7)
247
- const vx = (h1 - 0.5) * 900 * power
248
- const vy =
249
- direction === 'down'
250
- ? 200 + h2 * 300
251
- : direction === 'burst'
252
- ? -(600 + h2 * 900) * power
253
- : -(1400 + h2 * 900) * power
254
- const w = (h3 - 0.5) * 2 * spin
255
- const t0 = start + i * stagger
256
- gsap.set(el, { transformOrigin: '50% 50%' })
257
- const proxy = { t: 0 }
258
- tl.set(el, { autoAlpha: 1 }, t0)
259
- tl.to(
260
- proxy,
261
- {
262
- t: duration,
263
- duration,
264
- ease: 'none',
265
- onUpdate: () => {
266
- const t = proxy.t
267
- gsap.set(el, {
268
- x: vx * t,
269
- y: vy * t + 0.5 * g * t * t + (direction === 'down' ? -H * 0.2 : 0),
270
- rotation: w * t,
271
- force3D: false,
272
- })
273
- },
274
- },
275
- t0
276
- )
277
- // Items exit; leave the layout clean behind them.
278
- tl.set(el, { autoAlpha: 0, x: 0, y: 0, rotation: 0 }, t0 + duration)
279
- })
280
- return { duration: (els.length - 1) * stagger + duration }
281
- }
282
-
283
- /** SHOWPIECE. A generated celebration burst — no authored elements needed:
284
- * particles are created, fly ballistically with spin and flutter, fade,
285
- * and the whole layer ends hidden. `at` is the origin (element or scene
286
- * point); `count` is capped for paint cost.
287
- *
288
- * WHEN NOT TO USE: anywhere except a genuine payoff beat (shipped,
289
- * delivered, launched, won). One confetti per film, maximum. */
290
- const confetti = (
291
- opts: {
292
- at?: string | Element | { x: number; y: number }
293
- count?: number
294
- palette?: string[]
295
- power?: number
296
- spread?: number
297
- duration?: number
298
- size?: number
299
- } = {},
300
- position?: Position
301
- ): { duration: number } => {
302
- const count = Math.min(160, opts.count ?? 90)
303
- const palette = opts.palette ?? ['#2f6bff', '#7c5cff', '#ff8a5c', '#ffd166', '#06d6a0', '#ef476f']
304
- const duration = opts.duration ?? 2.4
305
- const power = opts.power ?? 1
306
- const spreadDeg = opts.spread ?? 70
307
- const start = at(position)
308
- let ox = config.width / 2
309
- let oy = config.height * 0.6
310
- if (opts.at && typeof opts.at === 'object' && !(opts.at instanceof Element) && 'x' in opts.at) {
311
- ox = opts.at.x
312
- oy = opts.at.y
313
- } else if (opts.at) {
314
- const el = resolveTargets(stage, opts.at as string | Element, 'confetti')[0] as HTMLElement
315
- const r = layoutRect(stage, el)
316
- ox = r.left + r.width / 2
317
- oy = r.top + r.height / 2
318
- }
319
- const layer = document.createElement('div')
320
- layer.dataset.confetti = ''
321
- layer.style.cssText = `position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;z-index:70;visibility:hidden;`
322
- stage.appendChild(layer)
323
- type P = { el: HTMLElement; vx: number; vy: number; w: number; flutter: number; phase: number; delay: number }
324
- const parts: P[] = []
325
- for (let i = 0; i < count; i += 1) {
326
- const p = document.createElement('div')
327
- const s = (opts.size ?? 12) * (0.6 + hash01(i, 8) * 0.9)
328
- p.style.cssText =
329
- `position:absolute;left:${ox}px;top:${oy}px;width:${s}px;height:${s * (0.5 + hash01(i, 9) * 0.7)}px;` +
330
- `border-radius:2px;background:${palette[i % palette.length]};will-change:transform;`
331
- layer.appendChild(p)
332
- const ang = ((hash01(i, 10) - 0.5) * 2 * spreadDeg * Math.PI) / 180
333
- const speed = (900 + hash01(i, 11) * 1100) * power
334
- parts.push({
335
- el: p,
336
- vx: Math.sin(ang) * speed,
337
- vy: -Math.cos(ang) * speed,
338
- w: (hash01(i, 12) - 0.5) * 1400,
339
- flutter: 20 + hash01(i, 13) * 50,
340
- phase: hash01(i, 14) * Math.PI * 2,
341
- delay: hash01(i, 15) * 0.12,
342
- })
343
- }
344
- const g = 2200
345
- tl.set(layer, { visibility: 'visible' }, start)
346
- const proxy = { t: 0 }
347
- tl.to(
348
- proxy,
349
- {
350
- t: duration,
351
- duration,
352
- ease: 'none',
353
- onUpdate: () => {
354
- const T = proxy.t
355
- for (const p of parts) {
356
- const t = Math.max(0, T - p.delay)
357
- const fade = Math.max(0, Math.min(1, (duration - 0.35 - t) / 0.5 + 1))
358
- gsap.set(p.el, {
359
- x: p.vx * t + Math.sin(p.phase + t * 9) * p.flutter * t,
360
- y: p.vy * t + 0.5 * g * t * t,
361
- rotation: p.w * t,
362
- opacity: t > 0 ? Math.min(1, fade) : 0,
363
- force3D: false,
364
- })
365
- }
366
- },
367
- },
368
- start
369
- )
370
- tl.set(layer, { visibility: 'hidden' }, start + duration)
371
- return { duration }
372
- }
373
-
374
- /** A continuous belt of items scrolling through a clipped viewport — the
375
- * gallery-scroll (Dash's file reels, Apple's tile river). The container's
376
- * children are duplicated once for a seamless wrap and the row translates
377
- * linearly; it ends reset to the authored position. Author the row inside
378
- * an overflow:hidden viewport.
379
- *
380
- * WHEN NOT TO USE: as ambient filler behind a reading beat — a river IS a
381
- * showcase beat (logos, covers, generated results). */
382
- const river = (
383
- container: string | Element,
384
- opts: { speed?: number; direction?: 'left' | 'right'; duration?: number } = {},
385
- position?: Position
386
- ): { duration: number } => {
387
- const el = resolveTargets(stage, container, 'river')[0] as HTMLElement
388
- const duration = opts.duration ?? 6
389
- const speed = opts.speed ?? 140
390
- const dir = opts.direction === 'right' ? 1 : -1
391
- const start = at(position)
392
- const kids = Array.from(el.children) as HTMLElement[]
393
- const gapPx = parseFloat(getComputedStyle(el).columnGap) || 0
394
- // One full period = authored content + the seam gap; translating by
395
- // exactly this shows identical pixels, so the wrap is invisible.
396
- const loop = el.scrollWidth + gapPx
397
- for (const k of kids) el.appendChild(k.cloneNode(true))
398
- const proxy = { t: 0 }
399
- tl.to(
400
- proxy,
401
- {
402
- t: duration,
403
- duration,
404
- ease: 'none',
405
- onUpdate: () => {
406
- const travel = (proxy.t * speed) % loop
407
- // Leftward belts reveal their right edge (where the clone sits);
408
- // rightward belts reveal their LEFT edge, so start one period back.
409
- gsap.set(el, { x: dir < 0 ? -travel : -loop + travel, force3D: false })
410
- },
411
- },
412
- start
413
- )
414
- tl.set(el, { x: 0 }, start + duration)
415
- return { duration }
416
- }
417
-
418
- /** Bento reflow: tiles in ONE grid continuously trade space — spans change
419
- * state to state and every tile FLIPs (measured, exact) to its new slot,
420
- * so the grid stays filled while its cells resize around each other.
421
- * Author a CSS grid container (grid-auto-flow: dense recommended); state 0
422
- * is the authored layout and the cycle ends exactly back on it. States are
423
- * generated deterministically from index hashes. */
424
- const bento = (
425
- container: string | Element,
426
- opts: { states?: number; interval?: number; duration?: number; ease?: string } = {},
427
- position?: Position
428
- ): { duration: number } => {
429
- const el = resolveTargets(stage, container, 'bento')[0] as HTMLElement
430
- const tiles = Array.from(el.children) as HTMLElement[]
431
- const nStates = Math.max(2, opts.states ?? 4)
432
- const interval = opts.interval ?? 1.6
433
- const flip = Math.min(opts.duration ?? 0.7, interval)
434
- const ease = opts.ease ?? 'promo-inout'
435
- const start = at(position)
436
- const spansFor = (s: number, i: number): [number, number] =>
437
- s === 0
438
- ? [1, 1]
439
- : [1 + (hash01(i, s * 3 + 1) < 0.28 ? 1 : 0), 1 + (hash01(i, s * 3 + 2) < 0.22 ? 1 : 0)]
440
- // Boxes RESIZE (width/height tweens), never scale — scaling squashes the
441
- // tile's content (stretched labels). Border-box so measured rects map
442
- // straight onto the tweened dimensions.
443
- tiles.forEach((tile) => {
444
- tile.style.boxSizing = 'border-box'
445
- })
446
- const apply = (s: number) => {
447
- tiles.forEach((tile, i) => {
448
- const [w, h] = spansFor(s, i)
449
- tile.style.gridColumn = `span ${w}`
450
- tile.style.gridRow = `span ${h}`
451
- })
452
- }
453
- // Measure every state's true rects at build time, then restore.
454
- const states: Array<Array<{ left: number; top: number; width: number; height: number }>> = []
455
- for (let s = 0; s < nStates; s += 1) {
456
- apply(s)
457
- states.push(tiles.map((tile) => layoutRect(stage, tile)))
458
- }
459
- apply(0)
460
- for (let s = 1; s <= nStates; s += 1) {
461
- const to = s % nStates
462
- const from = s - 1
463
- const t0 = start + s * interval
464
- tiles.forEach((tile, i) => {
465
- const a = states[from][i]
466
- const b = states[to][i]
467
- const [w, h] = spansFor(to, i)
468
- tl.set(tile, { gridColumn: `span ${w}`, gridRow: `span ${h}` }, t0)
469
- tl.fromTo(
470
- tile,
471
- { x: a.left - b.left, y: a.top - b.top, width: a.width, height: a.height },
472
- { x: 0, y: 0, width: b.width, height: b.height, duration: flip, ease, immediateRender: false },
473
- t0
474
- )
475
- // Hand sizing back to the grid once the box has arrived.
476
- tl.set(tile, { clearProps: 'width,height' }, t0 + flip)
477
- })
478
- }
479
- return { duration: nStates * interval + flip }
480
- }
481
-
482
- return { dropIn, assemble, scatter, fountain, confetti, river, bento }
483
- }