@impractical-ai/motion 0.1.5 → 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,374 @@
1
+ // ─── Highlight — annotation primitives for pre-existing components ─────────
2
+ //
3
+ // The "look here" vocabulary: rings that draw themselves around an element,
4
+ // hand-drawn circles, highlighter-marker swipes, underlines, glow pulses, and
5
+ // the inverse spotlight (everything EXCEPT the target dims). One call per
6
+ // annotation, one Tweak row, geometry derived from the target (ring padding
7
+ // from element size, marker height from line height), colors overridable per
8
+ // call. Every highlight is STATE with an explicit end: `persist` says how long
9
+ // it holds, `release` says how it leaves — nothing loiters.
10
+ //
11
+ // Overlays are SVG/div siblings appended to the stage (auto-cleaned per
12
+ // rebuild), positioned from the shared authored-layout measurement, so they
13
+ // land correctly even when authored after entrance tweens.
14
+
15
+ import gsap from 'gsap'
16
+ import { layoutRect, layoutTextRect, visualRect, resolveTarget } from './kits'
17
+
18
+ export type HighlightRelease = 'fade' | 'undraw' | 'hold'
19
+
20
+ export type HighlightOpts = {
21
+ /** Stroke/fill color. Each primitive has its own sensible default. */
22
+ color?: string
23
+ /** Draw-on time in seconds. */
24
+ duration?: number
25
+ /** Default 'ease-in-out-cubic'. */
26
+ ease?: string
27
+ /** Seconds the highlight holds after drawing, or 'hold' to stay to the end.
28
+ * Default 'hold' — releasing is a deliberate choice. */
29
+ persist?: number | 'hold'
30
+ /** How it leaves when persist is a number: 'fade' (default) or 'undraw'. */
31
+ release?: Exclude<HighlightRelease, 'hold'>
32
+ /** Extra space around the target in scene px. Default derives from size. */
33
+ padding?: number
34
+ /** Stroke thickness in px. */
35
+ thickness?: number
36
+ /** What to fit: 'text' hugs the glyphs (a block element's box spans its
37
+ * full column — a marker on "99.99%" must not swallow the empty half of
38
+ * the card); 'box' takes the element box. Marker, circle, and underline
39
+ * default to 'text'; ring, glow, and spotlight default to 'box'. */
40
+ fit?: 'text' | 'box'
41
+ }
42
+
43
+ export type SpotlightOpts = {
44
+ /** How dark the rest of the frame goes (0..1). Default 0.55. */
45
+ dim?: number
46
+ /** Defocus everything OUTSIDE the cutout by this many px (the target stays
47
+ * sharp). 0 (default) dims only. Engine ceiling: 20. */
48
+ blur?: number
49
+ /** Gaussian softness of the cutout edge in px. Default 26. */
50
+ feather?: number
51
+ /** Breathing room between the element's edge and the cutout, px. Default 16. */
52
+ padding?: number
53
+ duration?: number
54
+ ease?: string
55
+ persist?: number | 'hold'
56
+ }
57
+
58
+ export type HighlightKit = {
59
+ /** Rounded ring draws itself around the element, following its corner
60
+ * radius. The workhorse product-tour annotation. */
61
+ ring: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
62
+ /** Hand-drawn ellipse loop with overshoot — red-pen annotation energy. */
63
+ circle: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
64
+ /** Highlighter-pen swipe behind the glyphs: a skewed band wipes across in
65
+ * multiply blend, sized from the line height. */
66
+ marker: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
67
+ /** Underline draw. Styles: 'straight' | 'marker' | 'sketch'. */
68
+ underline: (
69
+ target: string | Element,
70
+ style?: 'straight' | 'marker' | 'sketch',
71
+ opts?: HighlightOpts,
72
+ position?: gsap.Position
73
+ ) => { duration: number }
74
+ /** One soft glow pulse as a beat lands — light, not opacity. */
75
+ glow: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
76
+ /** Inverse move: everything EXCEPT the target dims behind a feathered
77
+ * cutout. The strongest "look here" in the vocabulary. */
78
+ spotlight: (target: string | Element, opts?: SpotlightOpts, position?: gsap.Position) => { duration: number }
79
+ }
80
+
81
+ export type HighlightDeps = {
82
+ tl: gsap.core.Timeline
83
+ stage: HTMLElement
84
+ }
85
+
86
+ const EASE = 'ease-in-out-cubic'
87
+
88
+ /** Ring padding derived from target size — small chips get tight rings, large
89
+ * panels get room to breathe. */
90
+ export function autoPadding(rect: { width: number; height: number }): number {
91
+ const d = Math.min(rect.width, rect.height)
92
+ return Math.round(Math.min(18, Math.max(6, d * 0.12)))
93
+ }
94
+
95
+ const NS = 'http://www.w3.org/2000/svg'
96
+
97
+ export function createHighlightKit(deps: HighlightDeps): HighlightKit {
98
+ const { tl, stage } = deps
99
+
100
+ const rectFor = (
101
+ target: string | Element,
102
+ kind: string,
103
+ fit: 'text' | 'box' = 'box'
104
+ ) => {
105
+ const el = resolveTarget(stage, target, kind)
106
+ return {
107
+ el,
108
+ rect: fit === 'text' ? layoutTextRect(stage, el) : layoutRect(stage, el),
109
+ }
110
+ }
111
+
112
+ const overlaySvg = (
113
+ left: number,
114
+ top: number,
115
+ width: number,
116
+ height: number,
117
+ label: string
118
+ ): SVGSVGElement => {
119
+ const svg = document.createElementNS(NS, 'svg')
120
+ svg.setAttribute(`data-highlight-${label}`, '')
121
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
122
+ svg.style.cssText = `position:absolute;left:${left}px;top:${top}px;width:${width}px;height:${height}px;pointer-events:none;overflow:visible;z-index:9900;`
123
+ stage.appendChild(svg)
124
+ return svg
125
+ }
126
+
127
+ /** Draw-on via dash offset, then apply the persist/release contract. */
128
+ const drawAndRelease = (
129
+ strokeEl: SVGElement,
130
+ host: Element,
131
+ length: number,
132
+ opts: HighlightOpts,
133
+ at: number
134
+ ): number => {
135
+ const duration = opts.duration ?? 0.5
136
+ const ease = opts.ease ?? EASE
137
+ strokeEl.setAttribute('stroke-dasharray', String(length))
138
+ strokeEl.setAttribute('stroke-dashoffset', String(length))
139
+ tl.to(strokeEl, { strokeDashoffset: 0, duration, ease }, at)
140
+ const persist = opts.persist ?? 'hold'
141
+ if (persist === 'hold') return duration
142
+ const releaseAt = at + duration + persist
143
+ if ((opts.release ?? 'fade') === 'undraw') {
144
+ tl.to(strokeEl, { strokeDashoffset: -length, duration: duration * 0.8, ease }, releaseAt)
145
+ return duration + persist + duration * 0.8
146
+ }
147
+ tl.to(host, { autoAlpha: 0, duration: 0.25, ease: 'ease-in-quad' }, releaseAt)
148
+ return duration + persist + 0.25
149
+ }
150
+
151
+ const fadeRelease = (host: Element, opts: { persist?: number | 'hold' }, endOfIn: number): number => {
152
+ const persist = opts.persist ?? 'hold'
153
+ if (persist === 'hold') return 0
154
+ tl.to(host, { autoAlpha: 0, duration: 0.25, ease: 'ease-in-quad' }, endOfIn + persist)
155
+ return persist + 0.25
156
+ }
157
+
158
+ const at = (position: gsap.Position | undefined): number =>
159
+ typeof position === 'number' ? position : tl.duration()
160
+
161
+ /** Reposition a stage-space overlay from its build-time layout frame to
162
+ * wherever its target actually sits when the highlight begins — a container
163
+ * the direction scales mid-timeline has moved the UI off its resting rects
164
+ * by then. Resolved via function values at render time; uses css box props,
165
+ * never transforms, so it cannot fight the draw/skew animations. */
166
+ const syncToTarget = (
167
+ host: HTMLElement | SVGSVGElement,
168
+ el: HTMLElement,
169
+ frame: { left: number; top: number; width: number; height: number },
170
+ rect: { left: number; top: number; width: number; height: number },
171
+ position: number
172
+ ): void => {
173
+ const map = () => {
174
+ const v = visualRect(stage, el)
175
+ const s = rect.width ? v.width / rect.width : 1
176
+ return {
177
+ left: v.left + (frame.left - rect.left) * s,
178
+ top: v.top + (frame.top - rect.top) * s,
179
+ width: frame.width * s,
180
+ height: frame.height * s,
181
+ }
182
+ }
183
+ tl.set(
184
+ host,
185
+ {
186
+ left: () => `${map().left}px`,
187
+ top: () => `${map().top}px`,
188
+ width: () => `${map().width}px`,
189
+ height: () => `${map().height}px`,
190
+ },
191
+ position
192
+ )
193
+ }
194
+
195
+ return {
196
+ ring: (target, opts = {}, position) => {
197
+ const { el, rect } = rectFor(target, 'highlight.ring')
198
+ const pad = opts.padding ?? autoPadding(rect)
199
+ const thickness = opts.thickness ?? 3
200
+ const w = rect.width + pad * 2
201
+ const h = rect.height + pad * 2
202
+ const radius = Math.min(
203
+ parseFloat(getComputedStyle(el).borderRadius) + pad || 10,
204
+ Math.min(w, h) / 2
205
+ )
206
+ const svg = overlaySvg(rect.left - pad, rect.top - pad, w, h, 'ring')
207
+ const inset = thickness / 2
208
+ const r = document.createElementNS(NS, 'rect')
209
+ r.setAttribute('x', String(inset))
210
+ r.setAttribute('y', String(inset))
211
+ r.setAttribute('width', String(w - thickness))
212
+ r.setAttribute('height', String(h - thickness))
213
+ r.setAttribute('rx', String(radius))
214
+ r.setAttribute('fill', 'none')
215
+ r.setAttribute('stroke', opts.color ?? '#4c8dff')
216
+ r.setAttribute('stroke-width', String(thickness))
217
+ r.setAttribute('stroke-linecap', 'round')
218
+ svg.appendChild(r)
219
+ const length = r.getTotalLength()
220
+ const start = at(position)
221
+ syncToTarget(svg, el, { left: rect.left - pad, top: rect.top - pad, width: w, height: h }, rect, start)
222
+ return { duration: drawAndRelease(r, svg, length, opts, start) }
223
+ },
224
+
225
+ circle: (target, opts = {}, position) => {
226
+ const { el, rect } = rectFor(target, 'highlight.circle', opts.fit ?? 'text')
227
+ const pad = (opts.padding ?? autoPadding(rect)) + 6
228
+ const w = rect.width + pad * 2
229
+ const h = rect.height + pad * 2
230
+ const svg = overlaySvg(rect.left - pad, rect.top - pad, w, h, 'circle')
231
+ svg.style.transform = 'rotate(-3deg)'
232
+ const e = document.createElementNS(NS, 'ellipse')
233
+ e.setAttribute('cx', String(w / 2))
234
+ e.setAttribute('cy', String(h / 2))
235
+ e.setAttribute('rx', String(w / 2 - 3))
236
+ e.setAttribute('ry', String(h / 2 - 3))
237
+ e.setAttribute('fill', 'none')
238
+ e.setAttribute('stroke', opts.color ?? '#ff3b30')
239
+ e.setAttribute('stroke-width', String(opts.thickness ?? 3.5))
240
+ e.setAttribute('stroke-linecap', 'round')
241
+ svg.appendChild(e)
242
+ // 1.15 loops: the pen overshoots past its starting point — the detail
243
+ // that reads as hand-drawn rather than geometric.
244
+ const length = e.getTotalLength()
245
+ const start = at(position)
246
+ syncToTarget(svg, el, { left: rect.left - pad, top: rect.top - pad, width: w, height: h }, rect, start)
247
+ const drawn = drawAndRelease(e, svg, length * 1.15, { duration: 0.6, ...opts }, start)
248
+ return { duration: drawn }
249
+ },
250
+
251
+ marker: (target, opts = {}, position) => {
252
+ const { el, rect } = rectFor(target, 'highlight.marker', opts.fit ?? 'text')
253
+ const band = document.createElement('div')
254
+ band.setAttribute('data-highlight-marker', '')
255
+ const h = rect.height * 0.72
256
+ band.style.cssText =
257
+ `position:absolute;left:${rect.left - 6}px;top:${rect.top + (rect.height - h) / 2}px;` +
258
+ `width:${rect.width + 14}px;height:${h}px;background:${opts.color ?? '#ffe066'};` +
259
+ `opacity:0.55;mix-blend-mode:multiply;border-radius:${h * 0.28}px;` +
260
+ `transform:skewX(-8deg);transform-origin:0 50%;pointer-events:none;z-index:9890;`
261
+ stage.appendChild(band)
262
+ const duration = opts.duration ?? 0.45
263
+ const start = at(position)
264
+ syncToTarget(
265
+ band,
266
+ el,
267
+ { left: rect.left - 6, top: rect.top + (rect.height - h) / 2, width: rect.width + 14, height: h },
268
+ rect,
269
+ start
270
+ )
271
+ tl.fromTo(band, { scaleX: 0 }, { scaleX: 1, duration, ease: opts.ease ?? 'ease-out-quart' }, start)
272
+ const tail = fadeRelease(band, opts, start + duration)
273
+ return { duration: duration + tail }
274
+ },
275
+
276
+ underline: (target, style = 'sketch', opts = {}, position) => {
277
+ const { el, rect } = rectFor(target, 'highlight.underline', opts.fit ?? 'text')
278
+ const thickness = opts.thickness ?? (style === 'marker' ? 7 : 3)
279
+ const y = rect.top + rect.height + (opts.padding ?? 4)
280
+ const svg = overlaySvg(rect.left, y, rect.width, thickness * 3, 'underline')
281
+ const path = document.createElementNS(NS, 'path')
282
+ const w = rect.width
283
+ const mid = thickness * 1.5
284
+ const d =
285
+ style === 'sketch'
286
+ ? // A slight double-back wave: the pen presses through and returns.
287
+ `M2 ${mid} Q ${w * 0.3} ${mid - 3}, ${w * 0.55} ${mid} T ${w - 2} ${mid - 1}`
288
+ : `M2 ${mid} L ${w - 2} ${mid}`
289
+ path.setAttribute('d', d)
290
+ path.setAttribute('fill', 'none')
291
+ path.setAttribute('stroke', opts.color ?? (style === 'marker' ? '#ffe066' : '#ff3b30'))
292
+ path.setAttribute('stroke-width', String(thickness))
293
+ path.setAttribute('stroke-linecap', 'round')
294
+ if (style === 'marker') {
295
+ path.style.mixBlendMode = 'multiply'
296
+ path.style.opacity = '0.6'
297
+ }
298
+ svg.appendChild(path)
299
+ const start = at(position)
300
+ syncToTarget(svg, el, { left: rect.left, top: y, width: rect.width, height: thickness * 3 }, rect, start)
301
+ return { duration: drawAndRelease(path, svg, path.getTotalLength(), { duration: 0.4, ...opts }, start) }
302
+ },
303
+
304
+ glow: (target, opts = {}, position) => {
305
+ const { el, rect } = rectFor(target, 'highlight.glow')
306
+ const color = opts.color ?? '#ffb020'
307
+ const duration = opts.duration ?? 0.7
308
+ const start = at(position)
309
+ // A shadow-halo overlay, not a filter on the element — so it cannot
310
+ // clobber an fx chain already driving the element's filter.
311
+ const halo = document.createElement('div')
312
+ halo.setAttribute('data-highlight-glow', '')
313
+ halo.style.cssText =
314
+ `position:absolute;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;` +
315
+ `height:${rect.height}px;border-radius:${getComputedStyle(el).borderRadius || '8px'};` +
316
+ `box-shadow:0 0 ${Math.max(24, rect.height * 0.5)}px ${color};opacity:0;` +
317
+ `pointer-events:none;z-index:9880;`
318
+ stage.appendChild(halo)
319
+ syncToTarget(halo, el, { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, rect, start)
320
+ tl.to(halo, { opacity: 0.85, duration: duration / 2, repeat: 1, yoyo: true, ease: 'ease-in-out-quad' }, start)
321
+ return { duration }
322
+ },
323
+
324
+ spotlight: (target, opts = {}, position) => {
325
+ const { el, rect } = rectFor(target, 'highlight.spotlight')
326
+ const dim = opts.dim ?? 0.55
327
+ const blur = Math.min(20, opts.blur ?? 0)
328
+ // Legacy fractional feather values map into px so old calls stay sane.
329
+ const rawFeather = opts.feather ?? 26
330
+ const feather = rawFeather <= 1 ? rawFeather * 80 : rawFeather
331
+ const pad = opts.padding ?? 16
332
+ const duration = opts.duration ?? 0.4
333
+ const overlay = document.createElement('div')
334
+ overlay.setAttribute('data-highlight-spotlight', '')
335
+ // The cutout is the ELEMENT'S OWN SHAPE — a rounded rect at its
336
+ // border-radius plus breathing room — feathered by a true Gaussian in
337
+ // an SVG mask. An elliptical gradient cutout protects a huge oval of
338
+ // background around a rectangular card and shows a visible ring where
339
+ // the gradient ends; a blurred shape-hugging hole does neither.
340
+ const sw = stage.offsetWidth
341
+ const sh = stage.offsetHeight
342
+ const radius = (parseFloat(getComputedStyle(el).borderRadius) || 12) + pad
343
+ const hx = (rect.left - pad).toFixed(1)
344
+ const hy = (rect.top - pad).toFixed(1)
345
+ const hw = (rect.width + pad * 2).toFixed(1)
346
+ const hh = (rect.height + pad * 2).toFixed(1)
347
+ const svg =
348
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${sw}" height="${sh}">` +
349
+ `<defs><filter id="f" x="-50%" y="-50%" width="200%" height="200%">` +
350
+ `<feGaussianBlur stdDeviation="${feather}"/></filter>` +
351
+ `<mask id="m"><rect width="100%" height="100%" fill="white"/>` +
352
+ `<rect x="${hx}" y="${hy}" width="${hw}" height="${hh}" rx="${radius}" fill="black" filter="url(#f)"/>` +
353
+ `</mask></defs>` +
354
+ `<rect width="100%" height="100%" fill="white" mask="url(#m)"/></svg>`
355
+ const maskValue = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
356
+ // With `blur`, the overlay defocuses what's BEHIND it (backdrop-filter)
357
+ // outside the cutout — the target stays sharp while its surroundings
358
+ // fall out of focus, on top of the dim.
359
+ overlay.style.cssText =
360
+ `position:absolute;inset:0;background:rgba(8,8,12,${dim});opacity:0;` +
361
+ `pointer-events:none;z-index:9870;` +
362
+ (blur > 0 ? `backdrop-filter:blur(${blur}px);-webkit-backdrop-filter:blur(${blur}px);` : '') +
363
+ `mask-image:${maskValue};-webkit-mask-image:${maskValue};` +
364
+ `mask-size:100% 100%;-webkit-mask-size:100% 100%;`
365
+ stage.appendChild(overlay)
366
+ const start = at(position)
367
+ tl.to(overlay, { opacity: 1, duration, ease: opts.ease ?? EASE }, start)
368
+ const persist = opts.persist ?? 'hold'
369
+ if (persist === 'hold') return { duration }
370
+ tl.to(overlay, { opacity: 0, duration: 0.3, ease: 'ease-in-quad' }, start + duration + persist)
371
+ return { duration: duration + persist + 0.3 }
372
+ },
373
+ }
374
+ }
@@ -0,0 +1,103 @@
1
+ // Impact moves — the "slam": type or a card that hits the frame hard, or
2
+ // tips away out of it.
3
+ //
4
+ // THE RULE THIS KIT EXISTS TO ENFORCE: distortion belongs to MOTION, never to
5
+ // rest. A slam is legible because the element is perspective-turned only while
6
+ // it is travelling too fast to read, and lands (or leaves) undistorted. The
7
+ // failure mode — seen in a shipped film — is an element that POPS into a
8
+ // sheared pose in one frame and then sits there: the viewer reads a broken
9
+ // font, not a moving object. So:
10
+ // · the big change is the visible travel, not a jump into the end pose
11
+ // · 'in' ends at exact identity (rotation 0, scale 1) — never mid-turn
12
+ // · 'away' ends fully invisible, so the turned pose is never a resting one
13
+ // · pair with ctx.blur() across the fast window: real smear sells the speed
14
+ // that a 30fps frame cannot show on its own
15
+ //
16
+ // Perspective is applied to the ELEMENT, never the camera-scaled stage
17
+ // (promoting the whole scaled stage to a 3D layer blows Chromium's texture
18
+ // budget — see camera.set).
19
+ import { resolveTargets } from './kits'
20
+
21
+ type Position = number | string | undefined
22
+
23
+ export type SlamOpts = {
24
+ /** 'in' = arrives and lands flat (the impact). 'away' = tips back and
25
+ * leaves (the hand-off). Default 'in'. */
26
+ direction?: 'in' | 'away'
27
+ /** Perspective turn in degrees at the extreme of the move. Default 34. */
28
+ tilt?: number
29
+ /** Travel along Y in px. Default 240 ('in' comes from above, 'away' falls). */
30
+ distance?: number
31
+ /** Scale at the extreme. Default 1.55 for 'in', 0.86 for 'away'. */
32
+ scale?: number
33
+ /** Default 0.34s — a slam is fast; past ~0.5s it stops reading as impact. */
34
+ duration?: number
35
+ /** Hinge. Default '50% 100%' (the baseline) so the plane TIPS rather than
36
+ * spins; use '50% 50%' for a card, '0% 50%' to swing from the left edge. */
37
+ origin?: string
38
+ perspective?: number
39
+ ease?: string
40
+ /** Stagger when several elements are handed in. Default 0.06. */
41
+ stagger?: number
42
+ }
43
+
44
+ export type ImpactKit = {
45
+ slam: (target: string | Element | Element[], opts?: SlamOpts, position?: Position) => { duration: number }
46
+ }
47
+
48
+ export function createImpactKit(deps: { tl: any; gsap: any; stage: HTMLElement }): ImpactKit {
49
+ const { tl, gsap, stage } = deps
50
+ const at = (position: Position): number => (typeof position === 'number' ? position : tl.duration())
51
+
52
+ const slam: ImpactKit['slam'] = (target, opts = {}, position) => {
53
+ const els = resolveTargets(stage, target, 'slam')
54
+ const away = opts.direction === 'away'
55
+ const tilt = opts.tilt ?? 34
56
+ const distance = opts.distance ?? 240
57
+ const scale = opts.scale ?? (away ? 0.86 : 1.55)
58
+ const duration = opts.duration ?? 0.34
59
+ const origin = opts.origin ?? '50% 100%'
60
+ const perspective = opts.perspective ?? 900
61
+ const stagger = opts.stagger ?? 0.06
62
+ // Entrances land hard (launch-out puts ~80% of the travel in the first
63
+ // ~20% of the time); exits accelerate away.
64
+ const ease = opts.ease ?? (away ? 'ease-in-quart' : 'launch-out')
65
+ const start = at(position)
66
+
67
+ els.forEach((raw, i) => {
68
+ const el = raw as HTMLElement
69
+ const t = start + i * stagger
70
+ gsap.set(el, { transformOrigin: origin, transformPerspective: perspective })
71
+ if (away) {
72
+ tl.to(
73
+ el,
74
+ {
75
+ rotationX: -tilt,
76
+ y: distance,
77
+ scale,
78
+ autoAlpha: 0,
79
+ duration,
80
+ ease,
81
+ force3D: false,
82
+ },
83
+ t
84
+ )
85
+ // Ends fully gone AND reset: the turned pose is never a resting one,
86
+ // and a cold-seeking export worker lands on a clean state.
87
+ tl.set(el, { rotationX: 0, y: 0, scale: 1, autoAlpha: 0 }, t + duration)
88
+ } else {
89
+ tl.fromTo(
90
+ el,
91
+ { rotationX: tilt, y: -distance, scale, autoAlpha: 0 },
92
+ { rotationX: 0, y: 0, scale: 1, autoAlpha: 1, duration, ease, force3D: false },
93
+ t
94
+ )
95
+ // Exact identity at the landing frame.
96
+ tl.set(el, { rotationX: 0, y: 0, scale: 1 }, t + duration)
97
+ }
98
+ })
99
+ return { duration: (els.length - 1) * stagger + duration }
100
+ }
101
+
102
+ return { slam }
103
+ }