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
@@ -0,0 +1,812 @@
1
+ import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
2
+ import { animate } from "./animate.js";
3
+ import { resolveEasing } from "./easing.js";
4
+ import { now, schedule } from "./engine.js";
5
+ import { prefersReducedMotion } from "./reduced-motion.js";
6
+ import { createScrollProgress } from "./scroll.js";
7
+ function ownerDoc(el) {
8
+ const od = el
9
+ .ownerDocument;
10
+ if (od)
11
+ return od ?? undefined;
12
+ return typeof document !== "undefined" ? document : undefined;
13
+ }
14
+ /**
15
+ * Split an element's text into per-character inline-block spans so each
16
+ * letter can be transformed independently. The original text is preserved
17
+ * as an aria-label for screen readers.
18
+ */
19
+ function splitChars(el) {
20
+ const doc = ownerDoc(el);
21
+ if (!doc)
22
+ return [];
23
+ const text = el.textContent ?? "";
24
+ el.textContent = "";
25
+ el.setAttribute("aria-label", text);
26
+ const chars = [];
27
+ for (const ch of text) {
28
+ const s = doc.createElement("span");
29
+ s.textContent = ch === " " ? "\u00A0" : ch;
30
+ s.setAttribute("aria-hidden", "true");
31
+ s.style.display = "inline-block";
32
+ s.style.willChange = "transform";
33
+ el.appendChild(s);
34
+ chars.push(s);
35
+ }
36
+ return chars;
37
+ }
38
+ /**
39
+ * Swap a heading's font family (and optionally weight) on hover with a
40
+ * per-letter roll: each letter flips away in the old font and lands in the
41
+ * new one. Because the letters roll individually, the metric change never
42
+ * reads as a layout jump.
43
+ *
44
+ * On touch devices a tap toggles the swap instead of hover. Under reduced
45
+ * motion the font swaps instantly with no roll.
46
+ */
47
+ export function createFontSwap(ref, options) {
48
+ const { to, toWeight, duration = 160, stagger = 24, easing: easingOpt = "easeInOutCubic", tapToToggle = true, } = options;
49
+ const easing = resolveEasing(easingOpt);
50
+ if (typeof window === "undefined") {
51
+ const no = () => false;
52
+ return { swapped: no, swap: () => { }, toggle: () => { } };
53
+ }
54
+ const [swapped, setSwapped] = createSignal(false);
55
+ let chars = null;
56
+ let fromFont = "";
57
+ let fromWeight = "";
58
+ let stoppers = [];
59
+ const ensureSplit = () => {
60
+ const el = ref();
61
+ if (!el || chars)
62
+ return;
63
+ fromFont = el.style.fontFamily || "";
64
+ fromWeight = el.style.fontWeight || "";
65
+ chars = splitChars(el);
66
+ };
67
+ const applyFont = (useTo) => {
68
+ if (!chars)
69
+ return;
70
+ for (const c of chars) {
71
+ c.style.fontFamily = useTo ? to : fromFont;
72
+ if (toWeight !== undefined) {
73
+ c.style.fontWeight = useTo ? String(toWeight) : fromWeight;
74
+ }
75
+ }
76
+ };
77
+ const flipChar = (c, index, useTo) => {
78
+ let cancelled = false;
79
+ let stopSecond = () => { };
80
+ const first = animate(0, 90, {
81
+ duration: Math.max(duration, 1),
82
+ delay: index * stagger,
83
+ easing,
84
+ onUpdate: (v) => {
85
+ if (!cancelled)
86
+ c.style.transform = `rotateX(${v.toFixed(2)}deg)`;
87
+ },
88
+ onComplete: () => {
89
+ if (cancelled)
90
+ return;
91
+ c.style.fontFamily = useTo ? to : fromFont;
92
+ if (toWeight !== undefined) {
93
+ c.style.fontWeight = useTo ? String(toWeight) : fromWeight;
94
+ }
95
+ stopSecond = animate(-90, 0, {
96
+ duration: Math.max(duration, 1),
97
+ easing,
98
+ onUpdate: (v) => {
99
+ if (!cancelled)
100
+ c.style.transform = `rotateX(${v.toFixed(2)}deg)`;
101
+ },
102
+ onComplete: () => {
103
+ if (!cancelled)
104
+ c.style.transform = "";
105
+ },
106
+ }).stop;
107
+ },
108
+ });
109
+ return () => {
110
+ cancelled = true;
111
+ first.stop();
112
+ stopSecond();
113
+ };
114
+ };
115
+ const swap = (useTo) => {
116
+ ensureSplit();
117
+ for (const stopChar of stoppers)
118
+ stopChar();
119
+ stoppers = [];
120
+ if (!chars || prefersReducedMotion()) {
121
+ applyFont(useTo);
122
+ setSwapped(useTo);
123
+ return;
124
+ }
125
+ setSwapped(useTo);
126
+ stoppers = chars.map((c, i) => flipChar(c, i, useTo));
127
+ };
128
+ const toggle = () => swap(!untrack(swapped));
129
+ createEffect(() => {
130
+ const el = ref();
131
+ if (!el)
132
+ return;
133
+ ensureSplit();
134
+ const onEnter = () => swap(true);
135
+ const onLeave = () => swap(false);
136
+ el.addEventListener("pointerenter", onEnter);
137
+ el.addEventListener("pointerleave", onLeave);
138
+ let onTap = null;
139
+ if (tapToToggle && window.matchMedia("(hover: none)").matches) {
140
+ onTap = () => toggle();
141
+ el.addEventListener("click", onTap);
142
+ }
143
+ onCleanup(() => {
144
+ el.removeEventListener("pointerenter", onEnter);
145
+ el.removeEventListener("pointerleave", onLeave);
146
+ if (onTap)
147
+ el.removeEventListener("click", onTap);
148
+ });
149
+ });
150
+ onCleanup(() => {
151
+ for (const stopChar of stoppers)
152
+ stopChar();
153
+ stoppers = [];
154
+ });
155
+ return { swapped, swap, toggle };
156
+ }
157
+ /**
158
+ * Type out text character by character with human-like variable speed:
159
+ * each character's delay jitters around the base speed, and punctuation
160
+ * from `pauses` gets a beat of its own. A blinking block cursor rides
161
+ * along and parks itself when the text is done.
162
+ *
163
+ * Under reduced motion (or on the server) the full text appears instantly.
164
+ */
165
+ export function createTyping(ref, options = {}) {
166
+ const { text: textOpt, speed = 45, variance = 0.4, pauses = { ".": 350, ",": 180, "!": 350, "?": 350, "\n": 450 }, cursor = "|", blinkRate = 530, autostart = true, onComplete, } = options;
167
+ if (typeof window === "undefined") {
168
+ const no = () => false;
169
+ return {
170
+ start: () => { },
171
+ replay: () => { },
172
+ stop: () => { },
173
+ typing: no,
174
+ completed: no,
175
+ };
176
+ }
177
+ const [typing, setTyping] = createSignal(false);
178
+ const [completed, setCompleted] = createSignal(false);
179
+ let full = textOpt ?? "";
180
+ let textSpan = null;
181
+ let cursorSpan = null;
182
+ let cancel = null;
183
+ let index = 0;
184
+ let nextT = 0;
185
+ let doneT = 0;
186
+ let lastBlink = 0;
187
+ let cursorOn = true;
188
+ const ensureSetup = () => {
189
+ const el = ref();
190
+ if (!el || textSpan)
191
+ return;
192
+ const doc = ownerDoc(el);
193
+ if (!doc)
194
+ return;
195
+ if (!textOpt)
196
+ full = el.textContent ?? "";
197
+ el.textContent = "";
198
+ el.setAttribute("aria-label", full);
199
+ textSpan = doc.createElement("span");
200
+ textSpan.setAttribute("aria-hidden", "true");
201
+ el.appendChild(textSpan);
202
+ if (cursor) {
203
+ cursorSpan = doc.createElement("span");
204
+ cursorSpan.textContent = cursor;
205
+ cursorSpan.setAttribute("aria-hidden", "true");
206
+ el.appendChild(cursorSpan);
207
+ }
208
+ if (prefersReducedMotion()) {
209
+ textSpan.textContent = full;
210
+ if (cursorSpan)
211
+ cursorSpan.style.display = "none";
212
+ setCompleted(true);
213
+ }
214
+ };
215
+ const charDelay = (ch) => {
216
+ const jitter = variance <= 0 ? 0 : (Math.random() * 2 - 1) * variance;
217
+ return Math.max(1, speed * (1 + jitter) + (pauses[ch] ?? 0));
218
+ };
219
+ const loop = (t) => {
220
+ if (!textSpan) {
221
+ cancel = null;
222
+ return false;
223
+ }
224
+ while (index < full.length && t >= nextT) {
225
+ const ch = full[index];
226
+ index++;
227
+ nextT = t + charDelay(ch);
228
+ textSpan.textContent = full.slice(0, index);
229
+ if (index >= full.length)
230
+ doneT = t;
231
+ }
232
+ if (cursorSpan && t - lastBlink >= blinkRate) {
233
+ lastBlink = t;
234
+ cursorOn = !cursorOn;
235
+ cursorSpan.style.opacity = cursorOn ? "1" : "0";
236
+ }
237
+ const tail = cursorSpan ? blinkRate * 2 : 0;
238
+ if (index >= full.length && t - doneT >= tail) {
239
+ if (cursorSpan)
240
+ cursorSpan.style.display = "none";
241
+ setTyping(false);
242
+ setCompleted(true);
243
+ cancel = null;
244
+ onComplete?.();
245
+ return false;
246
+ }
247
+ return true;
248
+ };
249
+ const start = () => {
250
+ ensureSetup();
251
+ if (!textSpan || prefersReducedMotion())
252
+ return;
253
+ cancel?.();
254
+ index = 0;
255
+ doneT = 0;
256
+ cursorOn = true;
257
+ textSpan.textContent = "";
258
+ if (cursorSpan) {
259
+ cursorSpan.style.display = "";
260
+ cursorSpan.style.opacity = "1";
261
+ }
262
+ setTyping(true);
263
+ setCompleted(false);
264
+ nextT = now();
265
+ lastBlink = nextT;
266
+ cancel = schedule(loop);
267
+ };
268
+ const stop = () => {
269
+ cancel?.();
270
+ cancel = null;
271
+ setTyping(false);
272
+ };
273
+ if (autostart) {
274
+ createEffect(() => {
275
+ if (ref())
276
+ start();
277
+ });
278
+ }
279
+ onCleanup(() => cancel?.());
280
+ return { start, replay: start, stop, typing, completed };
281
+ }
282
+ /**
283
+ * Per-letter cartoon physics: `drop()` rains each letter from above with
284
+ * gravity, tumbling rotation, and squashy bounces until every letter lands
285
+ * in its slot. `scatter()` flings the letters apart so they can rain again.
286
+ *
287
+ * Pair it with `createInView` to rain a headline in as it scrolls into view.
288
+ * Under reduced motion (or on the server) letters simply sit in place.
289
+ */
290
+ export function createTextPhysics(ref, options = {}) {
291
+ const { dropHeight = 320, gravity = 2600, bounciness = 0.45, tumble = 200, stagger = 45, restThreshold = 60, onSettle, } = options;
292
+ if (typeof window === "undefined") {
293
+ const yes = () => true;
294
+ return { drop: () => { }, scatter: () => { }, settled: yes };
295
+ }
296
+ const [settled, setSettled] = createSignal(true);
297
+ let chars = null;
298
+ let bodies = [];
299
+ let cancel = null;
300
+ let lastT = 0;
301
+ const ensureSplit = () => {
302
+ const el = ref();
303
+ if (!el || chars)
304
+ return;
305
+ chars = splitChars(el);
306
+ bodies = chars.map(() => ({
307
+ x: 0,
308
+ y: 0,
309
+ vx: 0,
310
+ vy: 0,
311
+ rot: 0,
312
+ vr: 0,
313
+ opacity: 1,
314
+ fading: false,
315
+ live: false,
316
+ startAt: 0,
317
+ }));
318
+ };
319
+ const render = (i) => {
320
+ const b = bodies[i];
321
+ chars[i].style.transform =
322
+ `translate(${b.x.toFixed(1)}px, ${b.y.toFixed(1)}px) ` +
323
+ `rotate(${b.rot.toFixed(1)}deg)`;
324
+ chars[i].style.opacity = String(Math.max(0, Math.min(1, b.opacity)));
325
+ };
326
+ const park = (i) => {
327
+ const b = bodies[i];
328
+ b.x = 0;
329
+ b.y = 0;
330
+ b.vx = 0;
331
+ b.vy = 0;
332
+ b.rot = 0;
333
+ b.vr = 0;
334
+ b.opacity = 1;
335
+ b.fading = false;
336
+ b.live = false;
337
+ render(i);
338
+ };
339
+ const loop = (t) => {
340
+ const dt = Math.min(Math.max((t - lastT) / 1000, 0), 0.064);
341
+ lastT = t;
342
+ let allRest = true;
343
+ for (let i = 0; i < bodies.length; i++) {
344
+ const b = bodies[i];
345
+ if (!b.live)
346
+ continue;
347
+ if (t < b.startAt) {
348
+ allRest = false;
349
+ continue;
350
+ }
351
+ if (b.fading) {
352
+ // Scatter: fly apart and fade out.
353
+ b.vy += gravity * dt * 0.4;
354
+ b.x += b.vx * dt;
355
+ b.y += b.vy * dt;
356
+ b.rot += b.vr * dt;
357
+ b.opacity -= dt * 1.5;
358
+ if (b.opacity <= 0) {
359
+ b.opacity = 0;
360
+ b.live = false;
361
+ }
362
+ else {
363
+ allRest = false;
364
+ }
365
+ render(i);
366
+ continue;
367
+ }
368
+ b.vy += gravity * dt;
369
+ b.y += b.vy * dt;
370
+ b.x += b.vx * dt;
371
+ b.rot += b.vr * dt;
372
+ if (b.y >= 0) {
373
+ b.y = 0;
374
+ if (Math.abs(b.vy) > restThreshold) {
375
+ b.vy = -b.vy * bounciness;
376
+ b.vx *= 0.6;
377
+ b.vr *= 0.5;
378
+ }
379
+ else {
380
+ b.vy = 0;
381
+ b.vr = 0;
382
+ b.rot = 0;
383
+ b.x *= Math.pow(0.02, dt);
384
+ if (Math.abs(b.x) < 0.5)
385
+ b.x = 0;
386
+ }
387
+ }
388
+ if (b.y === 0 && b.vy === 0 && b.vr === 0 && b.x === 0) {
389
+ b.live = false;
390
+ }
391
+ else {
392
+ allRest = false;
393
+ }
394
+ render(i);
395
+ }
396
+ if (allRest) {
397
+ setSettled(true);
398
+ cancel = null;
399
+ onSettle?.();
400
+ return false;
401
+ }
402
+ return true;
403
+ };
404
+ const kick = (setupBody) => {
405
+ ensureSplit();
406
+ if (!chars)
407
+ return;
408
+ cancel?.();
409
+ const t0 = now();
410
+ bodies.forEach((b, i) => setupBody(b, i, t0));
411
+ setSettled(false);
412
+ lastT = t0;
413
+ cancel = schedule(loop);
414
+ };
415
+ const drop = () => {
416
+ if (prefersReducedMotion()) {
417
+ ensureSplit();
418
+ if (!chars)
419
+ return;
420
+ cancel?.();
421
+ cancel = null;
422
+ bodies.forEach((_, i) => park(i));
423
+ setSettled(true);
424
+ return;
425
+ }
426
+ kick((b, i, t0) => {
427
+ b.live = true;
428
+ b.fading = false;
429
+ b.startAt = t0 + i * stagger;
430
+ b.y = -dropHeight - Math.random() * 80;
431
+ b.x = 0;
432
+ b.vy = 0;
433
+ b.vx = 0;
434
+ b.rot = (Math.random() * 2 - 1) * tumble;
435
+ b.vr = (Math.random() * 2 - 1) * 420;
436
+ b.opacity = 1;
437
+ render(i);
438
+ });
439
+ };
440
+ const scatter = () => {
441
+ if (prefersReducedMotion())
442
+ return;
443
+ kick((b, i, t0) => {
444
+ b.live = true;
445
+ b.fading = true;
446
+ b.startAt = t0 + i * 20;
447
+ b.vy = -(450 + Math.random() * 750);
448
+ b.vx = (Math.random() * 2 - 1) * 520;
449
+ b.vr = (Math.random() * 2 - 1) * 540;
450
+ b.opacity = 1;
451
+ render(i);
452
+ });
453
+ };
454
+ onCleanup(() => cancel?.());
455
+ return { drop, scatter, settled };
456
+ }
457
+ /**
458
+ * An infinite 3D text tunnel: copies of the text at staggered depths zoom
459
+ * toward the viewer forever, each fading in from the distance and out past
460
+ * the camera. Drive it with time, with scroll progress, or with your own
461
+ * 0..1 signal.
462
+ *
463
+ * Under reduced motion (or on the server) the text renders as a single
464
+ * static line.
465
+ */
466
+ export function createTextTunnel(ref, options = {}) {
467
+ const { drive = "time", period = 3, layers = 6, zoom = 3 } = options;
468
+ if (typeof window === "undefined") {
469
+ return { progress: () => 0, stop: () => { } };
470
+ }
471
+ const el = ref();
472
+ if (!el)
473
+ return { progress: () => 0, stop: () => { } };
474
+ const doc = ownerDoc(el);
475
+ if (!doc)
476
+ return { progress: () => 0, stop: () => { } };
477
+ const [progress, setProgress] = createSignal(0);
478
+ let cancel = null;
479
+ if (!prefersReducedMotion()) {
480
+ const text = el.textContent ?? "";
481
+ el.textContent = "";
482
+ el.setAttribute("aria-label", text);
483
+ const host = el;
484
+ host.style.position = "relative";
485
+ host.style.overflow = "hidden";
486
+ // An invisible sizer keeps the box at the text's natural size.
487
+ const sizer = doc.createElement("span");
488
+ sizer.textContent = text;
489
+ sizer.style.visibility = "hidden";
490
+ sizer.setAttribute("aria-hidden", "true");
491
+ el.appendChild(sizer);
492
+ const clones = [];
493
+ for (let i = 0; i < Math.max(layers, 1); i++) {
494
+ const s = doc.createElement("span");
495
+ s.textContent = text;
496
+ s.setAttribute("aria-hidden", "true");
497
+ s.style.position = "absolute";
498
+ s.style.inset = "0";
499
+ s.style.display = "flex";
500
+ s.style.alignItems = "center";
501
+ s.style.justifyContent = "center";
502
+ s.style.willChange = "transform, opacity";
503
+ el.appendChild(s);
504
+ clones.push(s);
505
+ }
506
+ const minScale = 0.25;
507
+ const render = (p) => {
508
+ for (let i = 0; i < clones.length; i++) {
509
+ const d = (p + i / clones.length) % 1;
510
+ const scale = minScale * Math.pow(zoom / minScale, d);
511
+ const alpha = Math.min(Math.min(d / 0.12, 1), Math.min((1 - d) / 0.12, 1));
512
+ clones[i].style.transform = `scale(${scale.toFixed(3)})`;
513
+ clones[i].style.opacity = String(Math.max(0, Math.min(1, alpha)));
514
+ }
515
+ };
516
+ if (drive === "time") {
517
+ const t0 = now();
518
+ cancel = schedule((t) => {
519
+ const p = ((((t - t0) / 1000 / period) % 1) + 1) % 1;
520
+ setProgress(p);
521
+ render(p);
522
+ return true;
523
+ });
524
+ }
525
+ else if (drive === "scroll") {
526
+ const sp = createScrollProgress(ref);
527
+ createEffect(() => {
528
+ const p = sp();
529
+ setProgress(p);
530
+ render(p);
531
+ });
532
+ }
533
+ else {
534
+ createEffect(() => {
535
+ const p = ((drive() % 1) + 1) % 1;
536
+ setProgress(p);
537
+ render(p);
538
+ });
539
+ }
540
+ }
541
+ onCleanup(() => cancel?.());
542
+ return {
543
+ progress,
544
+ stop: () => {
545
+ cancel?.();
546
+ cancel = null;
547
+ },
548
+ };
549
+ }
550
+ /**
551
+ * Turn text into letter-shaped windows onto another world. In "gradient"
552
+ * mode an animated nebula drifts behind the letterforms via
553
+ * background-clip. In "window" mode the fill goes transparent with a
554
+ * stroked outline: overlay it on a canvas, video, or 3D scene and the live
555
+ * scene shows through the letters themselves.
556
+ */
557
+ export function createTextCutout(ref, options = {}) {
558
+ const { mode = "gradient", progress, palette = ["#312e81", "#7c3aed", "#22d3ee"], stroke = "currentColor", strokeWidth = 1.5, period = 9, } = options;
559
+ if (typeof window === "undefined")
560
+ return { stop: () => { } };
561
+ const el = ref();
562
+ if (!el)
563
+ return { stop: () => { } };
564
+ const st = el.style;
565
+ let cancel = null;
566
+ if (mode === "gradient") {
567
+ st.color = "transparent";
568
+ st.backgroundImage =
569
+ `radial-gradient(circle at 25% 35%, ${palette[0]}, transparent 62%), ` +
570
+ `radial-gradient(circle at 75% 65%, ${palette[1]}, transparent 62%), ` +
571
+ `radial-gradient(circle at 50% 50%, ${palette[2]}, transparent 70%)`;
572
+ st.backgroundSize = "220% 220%, 220% 220%, 220% 220%";
573
+ st.webkitBackgroundClip = "text";
574
+ st.backgroundClip = "text";
575
+ const render = (p) => {
576
+ const a = (p * 100).toFixed(2);
577
+ const b = ((1 - p) * 100).toFixed(2);
578
+ st.backgroundPosition = `${a}% ${b}%, ${b}% ${a}%, 50% 50%`;
579
+ };
580
+ if (prefersReducedMotion()) {
581
+ render(0.35);
582
+ }
583
+ else if (progress) {
584
+ createEffect(() => render(((progress() % 1) + 1) % 1));
585
+ }
586
+ else {
587
+ const t0 = now();
588
+ cancel = schedule((t) => {
589
+ render(((((t - t0) / 1000 / period) % 1) + 1) % 1);
590
+ return true;
591
+ });
592
+ }
593
+ }
594
+ else {
595
+ st.color = "transparent";
596
+ st.webkitTextStroke = `${strokeWidth}px ${stroke}`;
597
+ }
598
+ onCleanup(() => cancel?.());
599
+ return {
600
+ stop: () => {
601
+ cancel?.();
602
+ cancel = null;
603
+ },
604
+ };
605
+ }
606
+ /**
607
+ * Paint each character its own hue and cycle the rainbow across the text:
608
+ * the hue shifts along the string by `spread` degrees and the whole cycle
609
+ * rotates over time (or with your own progress signal, e.g. scroll).
610
+ * Under reduced motion the gradient parks on its first frame.
611
+ */
612
+ export function createTextGradient(ref, options = {}) {
613
+ const { progress, spread = 140, hue = 210, saturation = 85, lightness = 62, period = 7, } = options;
614
+ if (typeof window === "undefined")
615
+ return { stop: () => { } };
616
+ const el = ref();
617
+ if (!el)
618
+ return { stop: () => { } };
619
+ const chars = splitChars(el);
620
+ if (chars.length === 0)
621
+ return { stop: () => { } };
622
+ let cancel = null;
623
+ const render = (shift) => {
624
+ const n = chars.length;
625
+ for (let i = 0; i < n; i++) {
626
+ const along = n === 1 ? 0 : (i / (n - 1)) * spread;
627
+ const h = (((hue + along + shift) % 360) + 360) % 360;
628
+ chars[i].style.color =
629
+ `hsl(${h.toFixed(1)}, ${saturation}%, ${lightness}%)`;
630
+ }
631
+ };
632
+ if (prefersReducedMotion()) {
633
+ render(0);
634
+ }
635
+ else if (progress) {
636
+ createEffect(() => render(progress() * 360));
637
+ }
638
+ else {
639
+ const t0 = now();
640
+ cancel = schedule((t) => {
641
+ render((((t - t0) / 1000 / period) % 1) * 360);
642
+ return true;
643
+ });
644
+ }
645
+ onCleanup(() => cancel?.());
646
+ return {
647
+ stop: () => {
648
+ cancel?.();
649
+ cancel = null;
650
+ },
651
+ };
652
+ }
653
+ /**
654
+ * A decoder-ring text reveal: every character cycles through random glyphs
655
+ * and locks into its final letter left to right, like a combination lock
656
+ * finding its code. Spaces resolve instantly.
657
+ *
658
+ * Under reduced motion (or on the server) the full text appears instantly.
659
+ */
660
+ export function createTextScramble(ref, options = {}) {
661
+ const { text: textOpt, charset = "!<>-_\\/[]{}=+*^?#", stagger = 28, duration = 500, frameRate = 50, autostart = true, onComplete, } = options;
662
+ if (typeof window === "undefined") {
663
+ const no = () => false;
664
+ return {
665
+ start: () => { },
666
+ replay: () => { },
667
+ stop: () => { },
668
+ scrambling: no,
669
+ };
670
+ }
671
+ const [scrambling, setScrambling] = createSignal(false);
672
+ let chars = null;
673
+ let full = textOpt ?? "";
674
+ let cancel = null;
675
+ let lastSwap = 0;
676
+ const ensureSplit = () => {
677
+ const el = ref();
678
+ if (!el || chars)
679
+ return;
680
+ if (textOpt) {
681
+ // The text option is authoritative: seed the element with it so
682
+ // splitChars produces one span per character even when the
683
+ // element starts out empty.
684
+ full = textOpt;
685
+ el.textContent = textOpt;
686
+ }
687
+ else {
688
+ full = el.textContent ?? "";
689
+ }
690
+ chars = splitChars(el);
691
+ };
692
+ const pick = () => charset[(Math.random() * charset.length) | 0];
693
+ const start = () => {
694
+ ensureSplit();
695
+ if (!chars)
696
+ return;
697
+ if (prefersReducedMotion()) {
698
+ chars.forEach((c, i) => {
699
+ c.textContent = full[i] === " " ? "\u00A0" : full[i];
700
+ });
701
+ setScrambling(false);
702
+ onComplete?.();
703
+ return;
704
+ }
705
+ cancel?.();
706
+ const t0 = now();
707
+ lastSwap = 0;
708
+ setScrambling(true);
709
+ cancel = schedule((t) => {
710
+ let done = true;
711
+ if (t - lastSwap >= frameRate) {
712
+ lastSwap = t;
713
+ for (let i = 0; i < chars.length; i++) {
714
+ if (full[i] === " ")
715
+ continue;
716
+ const startAt = t0 + i * stagger;
717
+ if (t < startAt) {
718
+ done = false;
719
+ continue;
720
+ }
721
+ if (t >= startAt + duration) {
722
+ chars[i].textContent = full[i];
723
+ }
724
+ else {
725
+ done = false;
726
+ chars[i].textContent = pick();
727
+ }
728
+ }
729
+ }
730
+ else {
731
+ for (let i = 0; i < chars.length; i++) {
732
+ if (full[i] === " ")
733
+ continue;
734
+ if (t < t0 + i * stagger + duration) {
735
+ done = false;
736
+ break;
737
+ }
738
+ }
739
+ }
740
+ if (done) {
741
+ chars.forEach((c, i) => {
742
+ c.textContent = full[i] === " " ? "\u00A0" : full[i];
743
+ });
744
+ setScrambling(false);
745
+ cancel = null;
746
+ onComplete?.();
747
+ return false;
748
+ }
749
+ return true;
750
+ });
751
+ };
752
+ const stop = () => {
753
+ cancel?.();
754
+ cancel = null;
755
+ setScrambling(false);
756
+ };
757
+ if (autostart) {
758
+ createEffect(() => {
759
+ if (ref())
760
+ start();
761
+ });
762
+ }
763
+ onCleanup(() => cancel?.());
764
+ return { start, replay: start, stop, scrambling };
765
+ }
766
+ /**
767
+ * A traveling sine wave across the text: each character bobs up and down
768
+ * (and tilts with the slope) as the wave passes through. Drive it with
769
+ * time for an ambient shimmer or with scroll progress for a wave that
770
+ * moves as you scroll. Under reduced motion the text sits still.
771
+ */
772
+ export function createTextWave(ref, options = {}) {
773
+ const { amplitude = 9, wavelength = 7, period = 1.8, tilt = true, progress, } = options;
774
+ if (typeof window === "undefined")
775
+ return { stop: () => { } };
776
+ const el = ref();
777
+ if (!el)
778
+ return { stop: () => { } };
779
+ const chars = splitChars(el);
780
+ if (chars.length === 0)
781
+ return { stop: () => { } };
782
+ let cancel = null;
783
+ const render = (phase) => {
784
+ for (let i = 0; i < chars.length; i++) {
785
+ const p = 2 * Math.PI * (phase - i / wavelength);
786
+ const y = amplitude * Math.sin(p);
787
+ const r = tilt ? amplitude * 0.9 * Math.cos(p) : 0;
788
+ chars[i].style.transform =
789
+ `translateY(${y.toFixed(2)}px) rotate(${r.toFixed(2)}deg)`;
790
+ }
791
+ };
792
+ if (prefersReducedMotion()) {
793
+ // Static: no wave.
794
+ }
795
+ else if (progress) {
796
+ createEffect(() => render(progress()));
797
+ }
798
+ else {
799
+ const t0 = now();
800
+ cancel = schedule((t) => {
801
+ render((t - t0) / 1000 / period);
802
+ return true;
803
+ });
804
+ }
805
+ onCleanup(() => cancel?.());
806
+ return {
807
+ stop: () => {
808
+ cancel?.();
809
+ cancel = null;
810
+ },
811
+ };
812
+ }