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.
- package/README.md +735 -10
- package/dist/animate.d.ts +2 -2
- package/dist/animate.js +2 -2
- package/dist/cartoon.d.ts +190 -0
- package/dist/cartoon.js +334 -0
- package/dist/color.d.ts +53 -0
- package/dist/color.js +391 -0
- package/dist/directive.d.ts +5 -5
- package/dist/directive.js +15 -7
- package/dist/easing.d.ts +22 -1
- package/dist/easing.js +49 -1
- package/dist/flip.d.ts +44 -0
- package/dist/flip.js +108 -0
- package/dist/horizontal.d.ts +107 -0
- package/dist/horizontal.js +208 -0
- package/dist/index.d.ts +16 -3
- package/dist/index.js +15 -3
- package/dist/inview.d.ts +4 -4
- package/dist/inview.js +5 -5
- package/dist/motion.d.ts +327 -0
- package/dist/motion.js +729 -0
- package/dist/physics.d.ts +146 -0
- package/dist/physics.js +352 -0
- package/dist/pointer.d.ts +76 -0
- package/dist/pointer.js +123 -0
- package/dist/reduced-motion.d.ts +3 -3
- package/dist/reduced-motion.js +5 -4
- package/dist/scroll.d.ts +3 -3
- package/dist/scroll.js +5 -5
- package/dist/scrollfx.d.ts +156 -0
- package/dist/scrollfx.js +148 -0
- package/dist/scrub.d.ts +51 -0
- package/dist/scrub.js +67 -0
- package/dist/spring.d.ts +39 -4
- package/dist/spring.js +26 -7
- package/dist/stagger.d.ts +4 -4
- package/dist/stagger.js +4 -4
- package/dist/timeline.d.ts +45 -0
- package/dist/timeline.js +93 -0
- package/dist/trail.d.ts +27 -0
- package/dist/trail.js +75 -0
- package/dist/tween.d.ts +3 -3
- package/dist/tween.js +3 -3
- package/dist/typography.d.ts +241 -0
- package/dist/typography.js +812 -0
- package/dist/velocity.d.ts +44 -0
- package/dist/velocity.js +88 -0
- 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
|
+
}
|