solid-drift 0.2.0 → 0.7.1

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.
Files changed (48) hide show
  1. package/README.md +735 -10
  2. package/dist/animate.d.ts +2 -2
  3. package/dist/animate.js +2 -2
  4. package/dist/cartoon.d.ts +190 -0
  5. package/dist/cartoon.js +334 -0
  6. package/dist/color.d.ts +53 -0
  7. package/dist/color.js +391 -0
  8. package/dist/directive.d.ts +5 -5
  9. package/dist/directive.js +15 -7
  10. package/dist/easing.d.ts +22 -1
  11. package/dist/easing.js +49 -1
  12. package/dist/flip.d.ts +44 -0
  13. package/dist/flip.js +108 -0
  14. package/dist/horizontal.d.ts +107 -0
  15. package/dist/horizontal.js +208 -0
  16. package/dist/index.d.ts +16 -3
  17. package/dist/index.js +15 -3
  18. package/dist/inview.d.ts +4 -4
  19. package/dist/inview.js +5 -5
  20. package/dist/motion.d.ts +327 -0
  21. package/dist/motion.js +729 -0
  22. package/dist/physics.d.ts +146 -0
  23. package/dist/physics.js +352 -0
  24. package/dist/pointer.d.ts +76 -0
  25. package/dist/pointer.js +123 -0
  26. package/dist/reduced-motion.d.ts +3 -3
  27. package/dist/reduced-motion.js +5 -4
  28. package/dist/scroll.d.ts +3 -3
  29. package/dist/scroll.js +5 -5
  30. package/dist/scrollfx.d.ts +156 -0
  31. package/dist/scrollfx.js +148 -0
  32. package/dist/scrub.d.ts +51 -0
  33. package/dist/scrub.js +67 -0
  34. package/dist/spring.d.ts +39 -4
  35. package/dist/spring.js +26 -7
  36. package/dist/stagger.d.ts +4 -4
  37. package/dist/stagger.js +4 -4
  38. package/dist/timeline.d.ts +45 -0
  39. package/dist/timeline.js +93 -0
  40. package/dist/trail.d.ts +27 -0
  41. package/dist/trail.js +75 -0
  42. package/dist/tween.d.ts +3 -3
  43. package/dist/tween.js +3 -3
  44. package/dist/typography.d.ts +241 -0
  45. package/dist/typography.js +812 -0
  46. package/dist/velocity.d.ts +44 -0
  47. package/dist/velocity.js +88 -0
  48. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # solid-drift
2
2
 
3
- Signal-native animation for SolidJS. Animate **values, not elements**: springs and tweens follow your signals, and retargeting mid-flight is seamless by design — no restarts, no jumps.
3
+ Signal-native animation for SolidJS. Animate **values, not elements**: springs and tweens follow your signals, and retargeting mid-flight is seamless by design, with no restarts and no jumps.
4
4
 
5
5
  Built with AI assistance.
6
6
 
@@ -19,8 +19,11 @@ import { createSpring, createTween, drift } from "solid-drift";
19
19
  function Panel() {
20
20
  const [open, setOpen] = createSignal(false);
21
21
 
22
- // Springs follow the signal with physics; tweens use duration + easing.
23
- const y = createSpring(() => (open() ? 0 : 24), { stiffness: 170, damping: 26 });
22
+ // Springs follow the signal with physics, tweens use duration and easing.
23
+ const y = createSpring(() => (open() ? 0 : 24), {
24
+ stiffness: 170,
25
+ damping: 26,
26
+ });
24
27
  const opacity = createTween(() => (open() ? 1 : 0), { duration: 250 });
25
28
 
26
29
  return (
@@ -38,13 +41,13 @@ function Panel() {
38
41
 
39
42
  Returns a signal that follows `source` with spring physics. Changing the source mid-flight bends the spring toward the new target, keeping its velocity.
40
43
 
41
- | Option | Default | Description |
42
- | ----------- | ------- | ------------------------------------ |
43
- | `stiffness` | `170` | Spring stiffness |
44
+ | Option | Default | Description |
45
+ | ----------- | ------- | ------------------------------------- |
46
+ | `stiffness` | `170` | Spring stiffness |
44
47
  | `damping` | `26` | Damping coefficient |
45
48
  | `mass` | `1` | Mass |
46
49
  | `precision` | `0.01` | Rest threshold for value and velocity |
47
- | `onRest` | — | Called once the spring settles |
50
+ | `onRest` | none | Called once the spring settles |
48
51
 
49
52
  ### `createTween(source, options?)`
50
53
 
@@ -55,7 +58,7 @@ Returns a signal that tweens toward `source` over a fixed duration. Interrupting
55
58
  | `duration` | `300` | Duration in milliseconds |
56
59
  | `delay` | `0` | Delay before starting (ms) |
57
60
  | `easing` | `"easeOutCubic"` | Easing function or name |
58
- | `onComplete` | — | Called when the tween finishes |
61
+ | `onComplete` | none | Called when the tween finishes |
59
62
 
60
63
  ### `animate(from, to, options?)`
61
64
 
@@ -80,13 +83,735 @@ Binds animated values directly to an element's style. Each prop accepts a plain
80
83
 
81
84
  `x`/`y` map to `translate3d` px, `rotate` to degrees.
82
85
 
86
+ ### `createHorizontalScroll(options)`
87
+
88
+ Pins a tall section and slides a wide track through it as the user scrolls vertically: the classic horizontal-scroll storytelling chapter. The pinning itself is plain CSS (`position: sticky` on the stage), so this primitive never hijacks scrolling. It only translates the track with `translate3d`, smoothed by a spring that retargets seamlessly mid-scroll. Touch stays native: vertical swipes scroll the page, so keep `touch-action: pan-y` on the stage in your CSS.
89
+
90
+ Complete example (copy, paste, adjust the chapters):
91
+
92
+ ```tsx
93
+ import { createHorizontalScroll } from "solid-drift";
94
+
95
+ function Story() {
96
+ let pin!: HTMLElement;
97
+ let track!: HTMLElement;
98
+ const { progress } = createHorizontalScroll({
99
+ pin: () => pin,
100
+ track: () => track,
101
+ stiffness: 120,
102
+ damping: 20,
103
+ onProgress: (p) => console.log("chapter progress:", p),
104
+ });
105
+ return (
106
+ <section ref={pin} style={{ height: "300vh" }}>
107
+ <div
108
+ style={{
109
+ position: "sticky",
110
+ top: 0,
111
+ height: "100vh",
112
+ overflow: "hidden",
113
+ "touch-action": "pan-y",
114
+ }}
115
+ >
116
+ <div
117
+ ref={track}
118
+ style={{ display: "flex", width: "max-content", height: "100%" }}
119
+ >
120
+ <article style={{ width: "100vw" }}>Chapter one</article>
121
+ <article style={{ width: "100vw" }}>Chapter two</article>
122
+ <article style={{ width: "100vw" }}>Chapter three</article>
123
+ </div>
124
+ </div>
125
+ </section>
126
+ );
127
+ }
128
+ ```
129
+
130
+ The stage (the sticky element) defaults to the track's parent, so you only need `pin` and `track` refs in the common layout above.
131
+
132
+ | Option | Default | Description |
133
+ | ------------ | --------------------------------------- | --------------------------------------------------------------------- |
134
+ | `pin` | (required) | Tall wrapper. Its height beyond one viewport is the scroll range |
135
+ | `track` | (required) | Wide track translated horizontally |
136
+ | `stage` | track's parent | Sticky viewport showing one screenful at a time |
137
+ | `distance` | `track.scrollWidth - stage.clientWidth` | Horizontal travel in px. Number, or a function re-evaluated on resize |
138
+ | `start` | `0` | Fraction of the scroll range where the slide begins |
139
+ | `end` | `1` | Fraction of the scroll range where the slide ends |
140
+ | `stiffness` | `120` | Spring stiffness smoothing scroll into motion |
141
+ | `damping` | `20` | Spring damping for the smoothing |
142
+ | `onProgress` | none | Called with the smoothed progress (0 to 1) on every update |
143
+
144
+ Returns `{ progress, distance, refresh }`:
145
+
146
+ - `progress` is a signal from 0 to 1 following the smoothed slide. Drive per-panel parallax or a chapter indicator from it.
147
+ - `distance` is a signal with the current travel in pixels.
148
+ - `refresh()` re-measures the distance and recomputes progress immediately. The primitive already re-measures on resize via `ResizeObserver` and recomputes on scroll (rAF-throttled, passive listeners). Call `refresh()` yourself after layout shifts it cannot see, like webfont loads.
149
+
150
+ Reduced motion is a first-class path. When the user prefers reduced motion, the stage unpins (the tall section scrolls as a normal page), the track renders as a plain vertical stack of panels, nothing slides, and `progress` still reports 0 to 1 through the section so indicators keep working. Flipping the OS preference mid-session applies live. SSR-safe: constant `0` accessors on the server.
151
+
152
+ ### `createScrub(progress, keyframes, options?)`
153
+
154
+ Maps a 0-to-1 progress signal through an array of keyframes and returns the interpolated value as a signal. This is the scroll-choreography primitive: pair it with `createScrollProgress` and any numeric style becomes a scrubbed sequence. Parallax is the two-keyframe case, longer lists build full scenes (fade in, hold, fade out) driven by one scroll.
155
+
156
+ ```tsx
157
+ import { createScrollProgress, createScrub } from "solid-drift";
158
+
159
+ const progress = createScrollProgress(() => section);
160
+
161
+ // Parallax: the background drifts against the scroll
162
+ const y = createScrub(progress, [
163
+ { at: 0, value: 60 },
164
+ { at: 1, value: -60 },
165
+ ]);
166
+
167
+ // Choreography: fade in, hold, fade out
168
+ const opacity = createScrub(progress, [
169
+ { at: 0, value: 0 },
170
+ { at: 0.3, value: 1, easing: "easeOutCubic" },
171
+ { at: 0.7, value: 1 },
172
+ { at: 1, value: 0 },
173
+ ]);
174
+ ```
175
+
176
+ Each keyframe is `{ at, value, easing? }`: `at` is the progress position from 0 to 1, `value` is the value there, and `easing` shapes the segment that ends at that keyframe (CSS keyframe convention). Keyframes sort themselves by `at`, progress outside the range clamps to the end values, and segments default to linear so motion tracks scroll 1:1 unless you ask for shaping. Pure computation with no listeners, so it is SSR-safe by construction. Under reduced motion it holds the final keyframe value.
177
+
178
+ | Option | Default | Description |
179
+ | -------- | ---------- | ---------------------------------------- |
180
+ | `easing` | `"linear"` | Fallback easing for segments without one |
181
+
182
+ ### `createScrollColor(stops, options?)`
183
+
184
+ Maps a 0-to-1 progress signal through a list of color stops and returns the interpolated color as a string signal. Colors shift as you scroll: a hero tint that warms through a chapter, section backgrounds that deepen, text that cools into a new mood.
185
+
186
+ Each stop is `{ at, color, easing? }`: `at` is the progress position from 0 to 1, `color` is the color to reach there, and `easing` shapes the segment that ends at that stop (CSS keyframe convention, same as `createScrub`). Colors interpolate in linear light, so the midpoint between red and blue is the vivid purple your eyes expect, not the muddy `#800080` from naive channel math. Alpha channels interpolate too. Stops sort themselves by `at` and progress outside the range clamps to the end colors.
187
+
188
+ Accepted inputs: hex (`#rgb`, `#rrggbb`, with optional alpha), `rgb()`/`rgba()`, `hsl()`/`hsla()` (comma and space syntax), and the 148 CSS named colors.
189
+
190
+ ```tsx
191
+ import { createScrollProgress, createScrollColor } from "solid-drift";
192
+
193
+ const progress = createScrollProgress();
194
+
195
+ // The hero tint warms as you scroll through the first chapter.
196
+ const tint = createScrollColor(
197
+ [
198
+ { at: 0, color: "#f4f6f9" },
199
+ { at: 0.5, color: "#f7e8d0" },
200
+ { at: 1, color: "#2b5176", easing: "easeInOutQuad" },
201
+ ],
202
+ { progress },
203
+ );
204
+
205
+ <section style={{ "background-color": tint() }} />;
206
+ ```
207
+
208
+ | Option | Default | Description |
209
+ | ---------- | ---------------------- | ------------------------------------------------------ |
210
+ | `progress` | whole-page scroll | Progress signal, 0 to 1 (pass `createScrollProgress(() => section)` for element-scoped color) |
211
+ | `easing` | `"linear"` | Fallback easing for segments without one |
212
+ | `format` | `"hex"` | Output format: `"hex"`, `"rgb"`, or `"hsl"` |
213
+
214
+ Pure computation with no listeners, so it is SSR-safe by construction. Under reduced motion it holds the final stop's color.
215
+
216
+ ### `createScrollTracking(options?)`
217
+
218
+ Drives `letter-spacing` from a 0-to-1 progress signal: display words that spread apart or tighten together as you scroll. Returns a string signal like `"0.15em"` or `"6px"`, ready to drop into a style binding.
219
+
220
+ ```tsx
221
+ import { createScrollProgress, createScrollTracking } from "solid-drift";
222
+
223
+ const progress = createScrollProgress(() => chapter);
224
+
225
+ // A chapter title that tightens as it arrives.
226
+ const tracking = createScrollTracking({
227
+ progress,
228
+ from: 0.35,
229
+ to: 0,
230
+ unit: "em",
231
+ easing: "easeOutCubic",
232
+ });
233
+
234
+ <h2 style={{ "letter-spacing": tracking() }}>Chapter One</h2>;
235
+ ```
236
+
237
+ | Option | Default | Description |
238
+ | ---------- | ----------------- | ------------------------------------------------------ |
239
+ | `progress` | whole-page scroll | Progress signal, 0 to 1 |
240
+ | `from` | `0.3` | Letter-spacing at progress 0 |
241
+ | `to` | `0` | Letter-spacing at progress 1 |
242
+ | `unit` | `"em"` | Unit for the returned value: `"em"` or `"px"` |
243
+ | `easing` | `"linear"` | Easing applied to the progress before mapping |
244
+
245
+ SSR-safe (returns the `from` value on the server). Under reduced motion it holds the `to` value, the settled readable end state.
246
+
247
+ ### `createScrollLine(options?)`
248
+
249
+ Drives a divider/rule reveal from a 0-to-1 progress signal: a chapter line that draws itself as you scroll. It uses scale (not width/height) so the reveal stays on the compositor. Returns a signal holding `{ transform, transformOrigin }`, ready to spread into a style binding. The line element itself only needs a background (or border) and a fixed size; the primitive supplies the scale and the edge it grows from.
250
+
251
+ ```tsx
252
+ import { createScrollProgress, createScrollLine } from "solid-drift";
253
+
254
+ const progress = createScrollProgress(() => chapter);
255
+ const line = createScrollLine({ progress, axis: "x", origin: "start" });
256
+
257
+ <div
258
+ style={{
259
+ height: "2px",
260
+ "background-color": "#d9a441",
261
+ ...line(),
262
+ }}
263
+ />;
264
+ ```
265
+
266
+ | Option | Default | Description |
267
+ | ---------- | ----------------- | ------------------------------------------------------ |
268
+ | `progress` | whole-page scroll | Progress signal, 0 to 1 |
269
+ | `axis` | `"x"` | Grow along `"x"` (horizontal rule) or `"y"` (vertical rule) |
270
+ | `from` | `0` | Scale at progress 0 |
271
+ | `to` | `1` | Scale at progress 1 |
272
+ | `origin` | `"start"` | Which edge the line grows from: `"start"`, `"center"`, or `"end"` (`"start"` is left/top) |
273
+ | `easing` | `"linear"` | Easing applied to the progress before mapping |
274
+
275
+ SSR-safe (returns the `from` scale on the server). Under reduced motion the line holds the `to` scale, so it is fully revealed rather than stuck invisible.
276
+
277
+ ### `createVelocity(source?, options?)`
278
+
279
+ A signal tracking how fast another signal changes, in units per second, smoothed with an exponential moving average. With no source it measures page scroll in pixels per second. It spikes while the user flings the page and coasts back to exactly 0 when motion stops, which makes it ideal for velocity-driven skew, stretch, or blur that intensifies with speed. The measurement loop runs only while the value is live.
280
+
281
+ ```tsx
282
+ import { createVelocity } from "solid-drift"
283
+
284
+ // Skew a list while scrolling fast, relax when scrolling stops
285
+ const velocity = createVelocity()
286
+ const skew = () => Math.max(-8, Math.min(8, velocity() / 120))
287
+ <ul style={{ transform: `skewY(${skew()}deg)` }}>...</ul>
288
+
289
+ // Velocity of any signal, in its own units per second
290
+ const [n, setN] = createSignal(0)
291
+ const speed = createVelocity(n, { smoothing: 0.7 })
292
+ ```
293
+
294
+ | Option | Default | Description |
295
+ | ------------- | ------- | ---------------------------------------------------- |
296
+ | `smoothing` | `0.8` | Exponential smoothing, 0 to 1. Higher rides smoother |
297
+ | `scale` | `1` | Multiplier applied to the raw units per second |
298
+ | `settleAfter` | `0.12` | Seconds of stillness before the velocity parks at 0 |
299
+
300
+ SSR-safe and reduced-motion safe: both return a constant `0` accessor.
301
+
302
+ ### `createMagnetic(ref, options?)`
303
+
304
+ Magnetic attraction toward the pointer. When the pointer comes within `radius` of the element's center, the element is pulled toward it with a strength that fades with distance. When the pointer leaves, the element springs back to rest. The pull itself is a spring, so arrivals and releases glide instead of snapping. Built on `pointermove`, so touch drags work the same as mouse hovers.
305
+
306
+ ```tsx
307
+ import { createMagnetic } from "solid-drift"
308
+
309
+ let btn!: HTMLButtonElement
310
+ const { x, y } = createMagnetic(() => btn, { strength: 0.4 })
311
+ <button ref={btn} style={{ transform: `translate(${x()}px, ${y()}px)` }}>
312
+ Pull me
313
+ </button>
314
+ ```
315
+
316
+ | Option | Default | Description |
317
+ | ---------- | ------- | --------------------------------------------------- |
318
+ | `radius` | `140` | Attraction radius in px around the element's center |
319
+ | `strength` | `0.35` | Pull strength at the center, 0 to 1 |
320
+ | `spring` | default | Spring physics for the pull and the release |
321
+
322
+ Returns `{ x, y }`: spring-smoothed pull offsets in pixels. SSR-safe and reduced-motion safe: both return constant `0` accessors.
323
+
324
+ ### `createTilt(ref, options?)`
325
+
326
+ 3D tilt that follows the pointer across an element. The pointer's position over the element maps to `rotateX`/`rotateY` in degrees, spring-smoothed so the card leans with weight instead of jittering. When the pointer leaves, the element settles back to flat. Pair with a CSS `perspective` on the parent for real depth. Touch drags tilt while touching, release settles back to flat.
327
+
328
+ ```tsx
329
+ import { createTilt } from "solid-drift"
330
+
331
+ let card!: HTMLDivElement
332
+ const { rotateX, rotateY } = createTilt(() => card, { maxAngle: 12 })
333
+ <div style={{ perspective: "800px" }}>
334
+ <div
335
+ ref={card}
336
+ style={{ transform: `rotateX(${rotateX()}deg) rotateY(${rotateY()}deg)` }}
337
+ />
338
+ </div>
339
+ ```
340
+
341
+ | Option | Default | Description |
342
+ | ---------- | ------- | --------------------------------------------- |
343
+ | `maxAngle` | `10` | Maximum tilt in degrees at the element's edge |
344
+ | `spring` | default | Spring physics for the tilt and the settle |
345
+
346
+ Returns `{ rotateX, rotateY }`: spring-smoothed tilt in degrees. SSR-safe and reduced-motion safe: both return constant `0` accessors.
347
+
348
+ ### `createTrail(source, options?)`
349
+
350
+ A signal that replays another signal's past: it returns the value the source had `delay` milliseconds ago, interpolated between samples. Chain trails off one source for follower effects (a cursor with a comet tail, cascading highlights), or trail a scroll progress for a delayed echo of the page. The trail catches up and parks exactly on the latest value when the source rests. The follow loop runs only while the trail is behind.
351
+
352
+ ```tsx
353
+ import { createTrail } from "solid-drift";
354
+
355
+ const [tab, setTab] = createSignal(0);
356
+ // The indicator glides behind the selection instead of jumping
357
+ const ghost = createTrail(tab, { delay: 150 });
358
+ ```
359
+
360
+ | Option | Default | Description |
361
+ | ------- | ------- | -------------------------------------------------- |
362
+ | `delay` | `120` | How far behind the source the trail follows, in ms |
363
+
364
+ SSR-safe: returns the source itself on the server. Under reduced motion it also returns the source directly, with no trailing motion. A `delay` of 0 returns the source itself.
365
+
366
+ ### `createTimeline(steps)`
367
+
368
+ Plays a sequence of one-shot animations back to back. Each step is an `animate()` call: `from`/`to` plus duration, delay, easing, and `onUpdate`. Steps run strictly in order, so one step's `onUpdate` can drive one element while the next step drives another, building choreographed entrances without nested callbacks.
369
+
370
+ ```ts
371
+ import { createTimeline } from "solid-drift";
372
+
373
+ const intro = createTimeline([
374
+ {
375
+ from: 0,
376
+ to: 1,
377
+ duration: 400,
378
+ onUpdate: (v) => (title.style.opacity = String(v)),
379
+ },
380
+ {
381
+ from: 24,
382
+ to: 0,
383
+ duration: 500,
384
+ easing: "easeOutExpo",
385
+ onUpdate: (v) => (title.style.transform = `translateY(${v}px)`),
386
+ },
387
+ {
388
+ from: 0,
389
+ to: 1,
390
+ duration: 300,
391
+ onUpdate: (v) => (cta.style.opacity = String(v)),
392
+ },
393
+ ]);
394
+ await intro.start();
395
+ ```
396
+
397
+ Returns `{ start, stop, replay, status }`:
398
+
399
+ - `start()` plays every step in order and resolves when the last step completes.
400
+ - `stop()` halts mid-step and resolves the in-flight `start()` promise.
401
+ - `replay()` stops and plays again from the first step.
402
+ - `status` is a reactive `"idle" | "running" | "done"` signal.
403
+
404
+ Under reduced motion every step jumps straight to its end value (each step's `onUpdate(to)` still runs, so the final state is always correct). SSR-safe: steps apply their end values instantly.
405
+
406
+ ### `animateFlip(ref, mutate, options?)`
407
+
408
+ FLIP layout animation around a DOM mutation. It records the element's position and size (First), runs your `mutate()` which changes the layout (Last), then Inverts the delta as a transform and Plays it back to identity. List reorders, expanding panels, and grid reshuffles glide to their new spots instead of jumping. The element's pre-existing `transform` is captured and restored after the animation, so FLIP composes with other transform animations.
409
+
410
+ ```ts
411
+ import { animateFlip } from "solid-drift";
412
+
413
+ const [items, setItems] = createSignal(["a", "b", "c"]);
414
+ let list!: HTMLUListElement;
415
+
416
+ const shuffle = () =>
417
+ animateFlip(
418
+ () => list,
419
+ () => setItems((prev) => [...prev].reverse()),
420
+ { duration: 450 },
421
+ );
422
+ ```
423
+
424
+ | Option | Default | Description |
425
+ | ---------- | ---------------- | ---------------------------------------------------------------------------------------------------- |
426
+ | `duration` | `400` | Duration in milliseconds |
427
+ | `delay` | `0` | Delay before starting, in milliseconds |
428
+ | `easing` | `"easeOutCubic"` | Easing function or name |
429
+ | `scale` | `true` | Also animate the size delta as scale, so growing or shrinking elements morph instead of just sliding |
430
+
431
+ Returns `{ stop, finished }`: `stop()` halts mid-flight and restores the original transform. SSR-safe and reduced-motion safe: the mutation runs with no animation. If the element does not move, no animation runs either.
432
+
433
+ ### `springPresets`
434
+
435
+ Named spring configurations for common feels. Spread into `createSpring`, `createMagnetic`, or `createTilt` options.
436
+
437
+ ```ts
438
+ import { createSpring, springPresets } from "solid-drift";
439
+
440
+ const x = createSpring(target, { ...springPresets.wobbly });
441
+ ```
442
+
443
+ | Preset | Feel |
444
+ | ---------- | ----------------------------------------------- |
445
+ | `gentle` | Soft and calm, default-like, a touch slower |
446
+ | `default` | The library default balance |
447
+ | `snappy` | Tight and responsive, for UI that must keep up |
448
+ | `wobbly` | Loose and playful, with a visible overshoot |
449
+ | `molasses` | Heavy and deliberate, like moving through syrup |
450
+
451
+ ### `createSquashStretch(ref, options)`
452
+
453
+ Cartoon squash and stretch driven by real velocity. Give it a motion source (a signal, or an element it watches) and it stretches the element along its direction of travel, preserving volume on the cross axis. Slam to a stop and it squash-pancakes on impact, then jiggles back to rest. The deform is applied through the CSS `scale` property, so it composes with `translate` and `rotate` from other primitives.
454
+
455
+ ```tsx
456
+ import { createSpring, createSquashStretch } from "solid-drift"
457
+
458
+ let card: HTMLDivElement | undefined
459
+ const [target, setTarget] = createSignal(0)
460
+ const x = createSpring(target, { stiffness: 120, damping: 14 })
461
+ const { scaleX, scaleY } = createSquashStretch(() => card, { source: x })
462
+
463
+ <div ref={card} use:drift={{ x, scaleX, scaleY }}>fling me</div>
464
+ ```
465
+
466
+ Options: `source` (signal, or `"element"` to watch the ref's own movement), `maxStretch` (default 1.3), `maxSquash` (default 0.7), `preserveVolume` (default 0.8), `fullSpeed` (px/s that maps to full stretch, default 2400), `spring` (stiffness/damping for the deform itself). Returns `{ scaleX, scaleY }`. Pass `ref` as `null` to get the raw deform signals without touching the DOM. Under reduced motion the deform stays at 1.
467
+
468
+ ### `createFollowThrough(source, options?)`
469
+
470
+ Overlapping action for signals: a chain of followers that chase the source with staggered delays and springy overshoot, like a tail or a cape trailing behind a runner. Each link follows the previous one, so the lag compounds down the chain.
471
+
472
+ ```ts
473
+ import { createFollowThrough } from "solid-drift";
474
+
475
+ const [tip, setTip] = createSignal(0);
476
+ // Three followers, each 70ms behind the last.
477
+ const [seg1, seg2, seg3] = createFollowThrough(tip, { links: 3 });
478
+ ```
479
+
480
+ Options: `links` (default 3), `delayPerLink` (ms, default 70), `spring` (stiffness/damping, or an array with one config per link for a whip that loosens toward the tail). Under reduced motion each follower is the source itself.
481
+
482
+ ### `createAnticipation(from, to, options?)`
483
+
484
+ The wind-up before the punch. Moves opposite the travel direction, holds a beat, then fires the main animation. Returns `AnimationControls` (`finished`, `stop()`).
485
+
486
+ ```ts
487
+ import { createAnticipation } from "solid-drift";
488
+
489
+ // A punch button: pulls back 24px, holds, then slams forward.
490
+ createAnticipation(0, 200, {
491
+ windupDistance: 24,
492
+ windupDuration: 160,
493
+ holdDuration: 60,
494
+ duration: 320,
495
+ easing: "easeOutExpo",
496
+ onUpdate: (v) => (el.style.translate = `${v}px`),
497
+ });
498
+ ```
499
+
500
+ Options: `windupDistance` (px opposite travel, default 24), `windupDuration` (default 140), `holdDuration` (default 50), plus every `animate()` option (`duration`, `easing`, `delay`, `onUpdate`, `onComplete`). Under reduced motion it skips straight to the main animation with no wind-up.
501
+
502
+ ### `createWobble(ref?, options?)`
503
+
504
+ A triggerable cartoon wobble: decaying rotational oscillation with a counter-phase scale pulse, like a jelly nudged on a plate. Call `wobble()` yourself or let a pointer-down on the element trigger it.
505
+
506
+ ```ts
507
+ import { createWobble } from "solid-drift";
508
+
509
+ const { wobble } = createWobble(() => badge, {
510
+ rotation: 9, // degrees of swing
511
+ frequency: 5, // wobbles per second
512
+ decay: 0.45, // seconds of visible wobble
513
+ trigger: "pointerdown",
514
+ });
515
+ ```
516
+
517
+ Options: `rotation` (default 7), `frequency` (default 5), `decay` (default 0.45), `scaleAmount` (default 0.06), `trigger` (`"pointerdown" | "none"`, default `"pointerdown"` when a ref is given). Returns `{ rotate, scaleX, scaleY, wobble }`; `wobble(direction?)` retriggers with an optional initial direction. Under reduced motion it never moves.
518
+
519
+ ### `createGravity(options?)`
520
+
521
+ Real falling physics with floor bounces. A body accelerates downward, bounces with restitution, and comes to rest exactly on the floor. Call `drop()` to replay.
522
+
523
+ ```ts
524
+ import { createGravity } from "solid-drift";
525
+
526
+ const { x, y, moving, drop } = createGravity({
527
+ from: 300, // drop height above the floor
528
+ gravity: 2600,
529
+ bounciness: 0.55,
530
+ velocityX: 120, // optional sideways toss
531
+ onRest: () => console.log("landed"),
532
+ });
533
+ ```
534
+
535
+ Returns `{ x, y, vx, vy, moving, drop, stop }`. Under reduced motion the body sits on the floor and `drop()` is a no-op.
536
+
537
+ ### `createPendulum(ref, options?)`
538
+
539
+ A true physical pendulum: integrates the pendulum equation, so the period naturally depends on the rope length. Drag the bob with the pointer to set a release angle, or call `swing(degrees)`.
540
+
541
+ ```ts
542
+ import { createPendulum } from "solid-drift";
543
+
544
+ const { angle, x, y, swing } = createPendulum(() => bob, {
545
+ length: 180,
546
+ gravity: 2600,
547
+ damping: 0.35,
548
+ amplitude: 40, // auto-swings on mount
549
+ });
550
+ ```
551
+
552
+ Returns `{ angle, x, y, swing }` where `x`/`y` are the bob offset from the pivot. Pointer drag pauses the sim, sets the angle from the pointer position around the pivot, and releases on pointer-up. Under reduced motion it hangs at rest.
553
+
554
+ ### `createFling(ref, options?)`
555
+
556
+ Drag it, throw it: pointer drag with release velocity, exponential friction, and bounces off the container or viewport edges. The momentum is sampled from the last 120ms of pointer movement, so a flick feels like a flick.
557
+
558
+ ```ts
559
+ import { createFling } from "solid-drift";
560
+
561
+ const { x, y, moving, stop } = createFling(() => card, {
562
+ friction: 1.4,
563
+ bounciness: 0.6,
564
+ bounds: () => arena, // or omit for the viewport
565
+ });
566
+ ```
567
+
568
+ Returns `{ x, y, vx, vy, moving, stop }`. Under reduced motion dragging still works but release has no momentum.
569
+
570
+ ### `createFontSwap(ref, options)`
571
+
572
+ Interactive display type: swaps the font family (and optionally weight) on hover with a per-letter roll. Each letter flips away in the old font and lands in the new one, so the metric change never reads as a layout jump.
573
+
574
+ ```ts
575
+ import { createFontSwap } from "solid-drift";
576
+
577
+ const { swapped, toggle } = createFontSwap(() => headline, {
578
+ to: "Georgia, serif",
579
+ toWeight: 700,
580
+ duration: 160,
581
+ stagger: 24,
582
+ });
583
+ ```
584
+
585
+ Options: `to` (font family), `toWeight`, `duration` (ms per half-roll), `stagger` (ms between letters), `easing`, `tapToToggle` (on touch devices a tap toggles, default true). Returns `{ swapped, swap, toggle }`. Under reduced motion the font swaps instantly.
586
+
587
+ ### `createTyping(ref, options?)`
588
+
589
+ Types out text character by character with human-like variable speed: each character's delay jitters around the base speed, and punctuation gets its own beat. A blinking cursor rides along and parks itself when done.
590
+
591
+ ```ts
592
+ import { createTyping } from "solid-drift";
593
+
594
+ const { start, replay, typing } = createTyping(() => terminal, {
595
+ speed: 45,
596
+ variance: 0.4,
597
+ pauses: { ".": 350, ",": 180 },
598
+ cursor: "▍",
599
+ });
600
+ ```
601
+
602
+ Options: `text` (defaults to the element's current text), `speed`, `variance`, `pauses`, `cursor` (`""` for none), `blinkRate`, `autostart` (default true), `onComplete`. Returns `{ start, replay, stop, typing, completed }`. Under reduced motion the full text appears instantly.
603
+
604
+ ### `createTextPhysics(ref, options?)`
605
+
606
+ Per-letter cartoon physics: `drop()` rains each letter from above with gravity, tumbling rotation, and squashy bounces until every letter lands in its slot. `scatter()` flings the letters apart so they can rain again. Pair with `createInView` to rain a headline in as it scrolls into view.
607
+
608
+ ```ts
609
+ import { createTextPhysics, createInView } from "solid-drift";
610
+
611
+ const { drop, settled } = createTextPhysics(() => headline, {
612
+ dropHeight: 320,
613
+ bounciness: 0.45,
614
+ tumble: 200,
615
+ stagger: 45,
616
+ });
617
+ createInView(
618
+ () => headline,
619
+ (inView) => inView && drop(),
620
+ );
621
+ ```
622
+
623
+ Options: `dropHeight`, `gravity`, `bounciness`, `tumble` (max initial rotation in degrees), `stagger`, `restThreshold`, `onSettle`. Returns `{ drop, scatter, settled }`. Under reduced motion letters sit in place.
624
+
625
+ ### `createTextTunnel(ref, options?)`
626
+
627
+ An infinite 3D text tunnel: copies of the text at staggered depths zoom toward the viewer forever, each fading in from the distance and out past the camera. Drive it with time, scroll progress, or your own 0..1 signal.
628
+
629
+ ```ts
630
+ import { createTextTunnel } from "solid-drift";
631
+
632
+ // Time-driven ambient tunnel.
633
+ createTextTunnel(() => title, { layers: 6, period: 3 });
634
+
635
+ // Or scroll-driven: the tunnel dives as you scroll.
636
+ createTextTunnel(() => title, { drive: "scroll" });
637
+ ```
638
+
639
+ Options: `drive` (`"time" | "scroll" | Accessor<number>`, default `"time"`), `period` (seconds per cycle), `layers` (default 6), `zoom` (front-layer scale, default 3). Returns `{ progress, stop }`. Under reduced motion the text renders as a single static line.
640
+
641
+ ### `createTextCutout(ref, options?)`
642
+
643
+ Turns text into letter-shaped windows onto another world. In `"gradient"` mode an animated nebula drifts behind the letterforms via `background-clip: text`. In `"window"` mode the fill goes transparent with a stroked outline: overlay it on a canvas, video, or 3D scene and the live scene shows through the letters themselves.
644
+
645
+ ```ts
646
+ import { createTextCutout } from "solid-drift";
647
+
648
+ // Nebula drifting inside the letterforms.
649
+ createTextCutout(() => headline, {
650
+ palette: ["#312e81", "#7c3aed", "#22d3ee"],
651
+ });
652
+
653
+ // Or frame a live scene behind stroked letters:
654
+ // <canvas/> + <h1> with createTextCutout(h1, { mode: "window" })
655
+ ```
656
+
657
+ Options: `mode` (`"gradient" | "window"`, default `"gradient"`), `progress` (0..1 signal, defaults to a slow time drift), `palette`, `stroke`, `strokeWidth`, `period`. Returns `{ stop }`.
658
+
659
+ ### `createTextGradient(ref, options?)`
660
+
661
+ Paints each character its own hue and cycles the rainbow across the text: the hue shifts along the string by `spread` degrees while the whole cycle rotates over time, or with your own progress signal for scroll-driven hue shifts.
662
+
663
+ ```ts
664
+ import { createTextGradient } from "solid-drift";
665
+
666
+ createTextGradient(() => headline, {
667
+ hue: 210,
668
+ spread: 140,
669
+ period: 7,
670
+ });
671
+ ```
672
+
673
+ Options: `progress`, `spread`, `hue`, `saturation`, `lightness`, `period`. Returns `{ stop }`. Under reduced motion the gradient parks on its first frame.
674
+
675
+ ### `createTextScramble(ref, options?)`
676
+
677
+ A decoder-ring text reveal: every character cycles through random glyphs and locks into its final letter left to right, like a combination lock finding its code.
678
+
679
+ ```ts
680
+ import { createTextScramble } from "solid-drift";
681
+
682
+ const { start, replay, scrambling } = createTextScramble(() => headline, {
683
+ stagger: 28,
684
+ duration: 500,
685
+ charset: "!<>-_\\/[]{}=+*^?#",
686
+ });
687
+ ```
688
+
689
+ Options: `text` (defaults to the element's text), `charset`, `stagger`, `duration` (ms each character scrambles), `frameRate`, `autostart` (default true), `onComplete`. Returns `{ start, replay, stop, scrambling }`. Under reduced motion the full text appears instantly.
690
+
691
+ ### `createTextWave(ref, options?)`
692
+
693
+ A traveling sine wave across the text: each character bobs up and down and tilts with the slope as the wave passes through. Time-driven for an ambient shimmer, or hand it scroll progress for a wave that moves as you scroll.
694
+
695
+ ```ts
696
+ import { createTextWave } from "solid-drift";
697
+
698
+ createTextWave(() => headline, {
699
+ amplitude: 9,
700
+ wavelength: 7,
701
+ period: 1.8,
702
+ });
703
+ ```
704
+
705
+ Options: `amplitude`, `wavelength` (characters per wave), `period`, `tilt` (default true), `progress`. Returns `{ stop }`. Under reduced motion the text sits still.
706
+
707
+ ### `createKineticType(ref, options?)`
708
+
709
+ Kinetic typography: each character (or word) flies in with position, blur, scale, and opacity, staggered for that showreel title feel. One master clock drives every unit, so a 40-character headline costs a single rAF task.
710
+
711
+ ```tsx
712
+ let title!: HTMLHeadingElement;
713
+ const kinetic = createKineticType(() => title, {
714
+ unit: "chars", // or "words"
715
+ duration: 550, // ms per unit
716
+ stagger: 45, // ms between unit starts
717
+ from: { y: 40, blur: 12, scale: 0.8, opacity: 0, rotate: 0 },
718
+ easing: "easeOutExpo",
719
+ });
720
+ onMount(() => kinetic.play());
721
+ <h1 ref={title}>Showreel</h1>
722
+ ```
723
+
724
+ Returns `{ play, stop, replay, status }`. `play()` resolves when the last unit arrives. Under reduced motion every unit jumps to its final state, so the text is fully readable.
725
+
726
+ ### `createScenePlayer(scenes)`
727
+
728
+ Scene orchestrator for showreels and launch films: an ordered list of scenes, each with a `duration` and `onEnter`/`onExit` hooks. `scene()` tells your view which scene is live; the hooks trigger each scene's choreography (a `createKineticType`, a camera move, a color shift).
729
+
730
+ ```ts
731
+ const player = createScenePlayer([
732
+ { duration: 1200, onEnter: () => hookTitle.play() },
733
+ { duration: 2000, onEnter: () => cameraZoom.play() },
734
+ { duration: 1500, onEnter: () => showLogo() }, // final frame
735
+ ]);
736
+ await player.play(); // resolves after the last scene
737
+ ```
738
+
739
+ Returns `{ scene, status, play, pause, stop, replay, next, prev, goTo }`. `pause()` freezes the clock and `play()` resumes where it left off. Under reduced motion `play()` jumps straight to the last scene (the clean final frame).
740
+
741
+ ### `createCamera(keyframes, options)`
742
+
743
+ Camera moves for a motion-design stage: pan (`x`/`y` in px) and zoom (`scale`) through keyframes, driven by a 0-to-1 progress signal. Returns a compositor-friendly transform string (`translate3d(...) scale(...)`).
744
+
745
+ ```tsx
746
+ const [p, setP] = createSignal(0);
747
+ const cam = createCamera(
748
+ [
749
+ { at: 0, x: 0, y: 0, scale: 1 },
750
+ { at: 1, x: -120, y: 40, scale: 1.6, easing: "easeInOutCubic" },
751
+ ],
752
+ { progress: p },
753
+ );
754
+ <div style={{ transform: cam() }}>...</div>
755
+ ```
756
+
757
+ Keyframes sort themselves by `at`; progress outside the range clamps to the end poses. Pure computation, no listeners. Under reduced motion it holds the final keyframe's pose.
758
+
759
+ ### `createColorShift(stops, options?)`
760
+
761
+ Time-based color interpolation across stops: the sibling of `createScrollColor` for motion graphics, where color shifts run on a clock instead of scroll. Colors interpolate in linear light and alpha channels interpolate too.
762
+
763
+ ```ts
764
+ const shift = createColorShift(
765
+ [
766
+ { at: 0, color: "#0a1220" },
767
+ { at: 0.5, color: "#2f8fdd" },
768
+ { at: 1, color: "#d9a441", easing: "easeInOutQuad" },
769
+ ],
770
+ { duration: 2000, format: "hex" },
771
+ );
772
+ shift.color(); // "#0a1220" ... "#d9a441" as it plays
773
+ await shift.play();
774
+ ```
775
+
776
+ Returns `{ color, play, stop, replay, status }`. Under reduced motion `play()` jumps to the final stop's color.
777
+
778
+ ### `createTransition(options?)`
779
+
780
+ Match-cut style scene handoffs: `outgoing()` and `incoming()` return style objects for the two scene layers, driven by one 0-to-1 progress.
781
+
782
+ ```tsx
783
+ const cut = createTransition({ type: "wipe", direction: "left", duration: 600 });
784
+ const go = async () => {
785
+ showSceneB();
786
+ await cut.play();
787
+ };
788
+ <div style={cut.outgoing()}>{sceneA}</div>
789
+ <div style={cut.incoming()}>{sceneB}</div>
790
+ ```
791
+
792
+ Types: `"cut"` (instant swap), `"fade"` (crossfade), `"slide"` (layers move in opposite directions), `"wipe"` (incoming scene reveals over the outgoing one with a clip-path). Directions for slide/wipe: `"left"`, `"right"`, `"up"`, `"down"`. Everything animates on opacity, transform, or clip-path, so handoffs stay on the compositor. Under reduced motion every type degrades to a cut.
793
+
794
+ ### `createBeat(options?)`
795
+
796
+ A beat clock for cutting on the music: `onBeat` fires your scene cuts, kinetic type replays, or color shifts in time. `phase()` gives the fractional position inside the current beat for syncing continuous motion to the rhythm.
797
+
798
+ ```ts
799
+ const beat = createBeat({ bpm: 128, beatsPerBar: 4 });
800
+ const off = beat.onBeat((b) => {
801
+ if (b % 8 === 0) player.next(); // cut scenes every 2 bars
802
+ });
803
+ beat.start();
804
+ ```
805
+
806
+ Returns `{ beat, bar, phase, onBeat, start, stop, status }`. `onBeat` returns an unsubscribe function. Beats are timing, not motion, so the clock keeps ticking under reduced motion (your callbacks decide what that means visually).
807
+
83
808
  ### Easings
84
809
 
85
- Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack` — plus `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
810
+ Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack`, plus the cartoon set: `easeInBack` (anticipation dip before movement), `easeInOutBack` (wind-up, overshoot, settle), `easeOutElastic` (decaying rubber-band oscillation), `easeOutBounce` (shrinking cartoon bounces). Also `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
86
811
 
87
812
  ## How it works
88
813
 
89
- One shared `requestAnimationFrame` loop drives every animation in the app, so hundreds of springs cost a single rAF tick per frame. Springs integrate with semi-implicit Euler; tweens sample an easing curve. Everything is SSR-safe (animations simply don't run on the server).
814
+ One shared `requestAnimationFrame` loop drives every animation in the app, so hundreds of springs cost a single rAF tick per frame. Springs integrate with semi-implicit Euler, tweens sample an easing curve. Everything is SSR-safe (animations simply don't run on the server).
90
815
 
91
816
  ## License
92
817