glyphdust 0.2.0 → 0.3.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ # Changelog
2
+
3
+ All notable changes to **glyphdust** are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/), and the
5
+ project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.3.0] — 2026-06-28
8
+
9
+ Non-breaking feature release. Defaults reproduce 0.2.1 exactly.
10
+
11
+ ### Added
12
+
13
+ - **Mixed fonts in one glyph (`segments`)** — a `TextKeyframe` can now carry a
14
+ `segments: { text, font? }[]` array. Each run is stamped with its own font and
15
+ flows inline (a `\n` inside any run breaks the line; the next run continues on the
16
+ new line), so a single particle glyph can blend, e.g., a bold serif word with a
17
+ light sans one. `text` stays the accessible/`resolveToDom` string; per-run `font`
18
+ falls back to the keyframe `font`. Works on the normal and `dense` sampling paths;
19
+ ignored under `domSelector` (the DOM provides layout). Defaults unchanged —
20
+ omitting `segments` reproduces prior behavior exactly.
21
+ _Why: particles only ride "ink", so the stamp was never font-bound — the limit was
22
+ the API exposing one font. (提案者: 凜さん)_
23
+
24
+ ## [0.2.1] — 2026-06-26
25
+
26
+ Flexibility & polish release. Glyphdust is no longer scroll-and-hero only — it now
27
+ drops into any box, plays without scroll, and ships tasteful presets you can override.
28
+ **Defaults reproduce 0.2.0 exactly**, so upgrading is non-breaking.
29
+
30
+ ### Added
31
+
32
+ - **`autoplay` driver** — time-based progress with no scroll choreography. Fits its
33
+ parent box and starts when scrolled into view (`playOnView`, default on). Options:
34
+ `duration`, `delay`, `loop`, `pingpong`, `playOnView`. Exposed
35
+ `computeAutoplayProgress()` for custom rigs.
36
+ - **`preset` prop** — `"default" | "minimal" | "lively" | "glow"`: a tasteful bundle
37
+ of look + motion.
38
+ - **`style` prop** — per-field overrides on top of the preset:
39
+ `size`, `blend` (`"normal" | "additive"`), `drift`, `sparkle`. Backed by new shader
40
+ uniforms (`uSizeScale`, `uDrift`, `uSparkle`); `additive` enables glow blending for
41
+ dark backgrounds.
42
+
43
+ ### Changed
44
+
45
+ - Particles render finer and crisper on high-DPI screens: point-size base ×0.62,
46
+ clamp lowered to 4–5 px, and `devicePixelRatio` cap raised 2 → 3. (Validated on the
47
+ LINNO corporate site.)
48
+ - Scroll follow no longer lags: stage progress is applied directly instead of an
49
+ internal lerp. Add inertia in your driver (e.g. Lenis) if you want it.
50
+
51
+ ### Fixed
52
+
53
+ - No more blank gap when the **first keyframe is text** — particles now start in the
54
+ formed glyph and dissolve outward, instead of appearing only after the real text
55
+ fades.
56
+ - `VERSION` export corrected (was a stale `"0.1.0"`).
57
+
58
+ ## [0.2.0] — 2026-06-23
59
+
60
+ - Resolve to real DOM elements with pixel alignment; scrollbar & baseline fixes.
61
+
62
+ ## [0.1.0]
63
+
64
+ - Initial public release: text → particles → glyph → real-text resolve, scroll-driven.
package/README.md CHANGED
@@ -85,7 +85,34 @@ export function Hero() {
85
85
  - `domSelector` makes particles land exactly on an existing element's box and font — no jump on cross-fade.
86
86
  - `resolveToDom` on the final keyframe hands off from particles to crisp real text.
87
87
 
88
- ### Drive it yourself (no scroll)
88
+ ### Just drop in text — no scroll choreography
89
+
90
+ For anything that isn't a full-screen scroll hero, use the **`autoplay`** driver. It
91
+ fits its parent box and plays once when it scrolls into view — drop it anywhere and it
92
+ just animates:
93
+
94
+ ```tsx
95
+ <div style={{ width: 480, height: 220 }}>
96
+ <GlyphDust
97
+ driver={{ type: "autoplay", duration: 3.5 }} // loop / pingpong / delay too
98
+ preset="minimal" // tasteful out of the box
99
+ keyframes={[
100
+ { type: "scatter" },
101
+ { type: "text", text: "glyphdust", dense: true },
102
+ ]}
103
+ />
104
+ </div>
105
+ ```
106
+
107
+ ### Pick a look with presets (then tweak)
108
+
109
+ ```tsx
110
+ <GlyphDust preset="glow" style={{ size: 1.2 }} keyframes={[/* … */]} />
111
+ ```
112
+
113
+ `preset` is a tasteful starting point; `style` overrides just the fields you name.
114
+
115
+ ### Drive it yourself (manual)
89
116
 
90
117
  ```tsx
91
118
  const [p, setP] = useState(0); // 0 → 1 from time, GSAP, a slider, anything
@@ -105,7 +132,9 @@ const [p, setP] = useState(0); // 0 → 1 from time, GSAP, a slider, anything
105
132
  | prop | type | default | description |
106
133
  |---|---|---|---|
107
134
  | `keyframes` | `Keyframe[]` | — (required) | The animation timeline. Minimum 1; typically `text → scatter → text`. |
108
- | `driver` | `DriverConfig` | `{ type: "scroll" }` | Progress source: `scroll` or `manual`. |
135
+ | `driver` | `DriverConfig` | `{ type: "scroll" }` | Progress source: `scroll`, `autoplay`, or `manual`. |
136
+ | `preset` | `GlyphPreset` | `"default"` | Look/motion preset: `default`, `minimal`, `lively`, `glow`. |
137
+ | `style` | `GlyphStyle` | — | Per-field overrides on top of `preset` (see below). |
109
138
  | `colors` | `GlyphColors` | see below | Particle ink / accent colors. |
110
139
  | `count` | `GlyphCount` | `{ desktop: 11000, mobile: 5200 }` | Particle count per device class. |
111
140
  | `timing` | `number[]` | even spacing | Normalized time `0..1` per keyframe (interpolation boundaries). Length must match `keyframes`. |
@@ -126,13 +155,35 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
126
155
  | field | type | description |
127
156
  |---|---|---|
128
157
  | `text` | `string` | Text to render. Use `\n` for line breaks. |
158
+ | `segments` | `{ text, font? }[]?` | Mix fonts in one glyph (see below). Particles are stamped from the runs; `text` stays the accessible / `resolveToDom` string. |
129
159
  | `domSelector` | `string?` | Selector of a real element; particles align pixel-perfect to its rect & font. |
130
160
  | `resolveToDom` | `boolean?` | At the finale, cross-fade particles → real DOM text (usually the last keyframe). |
131
161
  | `dense` | `boolean?` | High-density, uniform sampling (best for solid wordmarks). |
132
- | `font` | `string?` | Canvas2D `font` string. Defaults to a density-appropriate value. |
162
+ | `font` | `string?` | Canvas2D `font` string. Defaults to a density-appropriate value. Also the default for `segments` runs. |
133
163
  | `worldW` | `number?` | Visible world width to fit the glyph into. |
134
164
  | `offsetX` / `offsetY` | `number?` | World-space offset (right / up are positive). |
135
165
 
166
+ #### Mix fonts in one glyph (`segments`)
167
+
168
+ Particles just ride wherever there's "ink", so a single glyph isn't bound to one
169
+ typeface. Split a keyframe into `segments` and each run gets its own `font`,
170
+ flowing inline (a `\n` inside a run starts a new line; the next run continues there):
171
+
172
+ ```tsx
173
+ {
174
+ type: "text",
175
+ text: "Mix fonts", // accessible / resolve string
176
+ segments: [
177
+ { text: "Mix ", font: "900 200px Georgia, serif" }, // bold serif
178
+ { text: "fonts", font: "300 150px 'Helvetica Neue', sans-serif" }, // light sans
179
+ ],
180
+ }
181
+ ```
182
+
183
+ Runs without a `font` fall back to the keyframe `font`. `segments` is ignored when
184
+ `domSelector` is set (the DOM provides the layout there). Particle **color** is still
185
+ governed globally by `colors` (ink/accent ratio), not per segment.
186
+
136
187
  **`ScatterKeyframe`** (`type: "scatter"`) — scatters particles into a random cloud:
137
188
 
138
189
  | field | type | description |
@@ -142,10 +193,35 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
142
193
  ### Drivers
143
194
 
144
195
  ```ts
145
- { type: "scroll", triggerHeight?: number } // default triggerHeight: 2 (×100vh)
196
+ { type: "scroll", triggerHeight?: number } // full-screen sticky hero. default triggerHeight: 2 (×100vh)
146
197
  { type: "manual", progress: number } // you supply 0..1
198
+ { type: "autoplay", // time-based; fits its parent box
199
+ duration?: number, // seconds for 0→1 (default 4)
200
+ delay?: number, // start delay (default 0)
201
+ loop?: boolean, // repeat (default false)
202
+ pingpong?: boolean, // 0→1→0 when looping (default false)
203
+ playOnView?: boolean, // start when scrolled into view (default true)
204
+ }
147
205
  ```
148
206
 
207
+ `scroll` builds a tall sticky wrapper for a full-screen hero. `manual` and `autoplay`
208
+ simply **fill their parent**, so you can place them in any sized container.
209
+
210
+ ### Presets & style
211
+
212
+ ```ts
213
+ preset: "default" | "minimal" | "lively" | "glow"
214
+
215
+ style: {
216
+ size?: number, // point-size multiplier (default 1)
217
+ blend?: "normal" | "additive", // "additive" = glow, for dark backgrounds
218
+ drift?: number, // idle/scatter wander 0..1 (default 1; 0 = still)
219
+ sparkle?: number, // sparkle strength 0..1 (default 1; 0 = off)
220
+ }
221
+ ```
222
+
223
+ `style` always wins over `preset`. Defaults reproduce the original look exactly.
224
+
149
225
  ### Colors
150
226
 
151
227
  ```ts
@@ -156,7 +232,7 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
156
232
 
157
233
  ### Low-level helpers
158
234
 
159
- For custom rigs, the building blocks are exported too: `buildTextTargets`, `buildDenseTextTargets`, `buildVertexShader`, `FRAGMENT_SHADER`, `createScrollProgress`, `useScrollProgress`, `useReducedMotion`, `prefersReducedMotion`, `viewSizeAtZ0`, `buildGlyphFromDOM`, `computeScreenRect`.
235
+ For custom rigs, the building blocks are exported too: `buildTextTargets`, `buildDenseTextTargets`, `buildVertexShader`, `FRAGMENT_SHADER`, `createScrollProgress`, `useScrollProgress`, `computeAutoplayProgress`, `useReducedMotion`, `prefersReducedMotion`, `viewSizeAtZ0`, `buildGlyphFromDOM`, `computeScreenRect`.
160
236
 
161
237
  ---
162
238
 
@@ -171,7 +247,7 @@ For custom rigs, the building blocks are exported too: `buildTextTargets`, `buil
171
247
 
172
248
  ## Status
173
249
 
174
- `0.1.0` — the component and API above are implemented and demoed (see [`examples/`](./examples)). Published from [LINNO](https://linno.co.jp). Semantic-versioned; expect minor API polish before `1.0`.
250
+ `0.2.1` — the component and API above are implemented and demoed (see [`examples/`](./examples)) and [`CHANGELOG.md`](./CHANGELOG.md). Published from [LINNO](https://linno.co.jp). Semantic-versioned; expect minor API polish before `1.0`.
175
251
 
176
252
  ## License
177
253
 
package/dist/index.cjs CHANGED
@@ -54,6 +54,38 @@ function fillScatterCluster(out, count, offsetX, offsetY, random) {
54
54
  out[i * 3 + 2] = (random() - 0.5) * 0.2;
55
55
  }
56
56
  }
57
+ function segmentsToRunLines(segments, defaultFont) {
58
+ const lines = [[]];
59
+ for (const seg of segments) {
60
+ const font = seg.font ?? defaultFont;
61
+ const parts = seg.text.split("\n");
62
+ parts.forEach((part, i) => {
63
+ if (i > 0) lines.push([]);
64
+ if (part.length > 0) lines[lines.length - 1].push({ text: part, font });
65
+ });
66
+ }
67
+ return lines;
68
+ }
69
+ function drawSegmentedLines(ctx, runLines, cw, ch, lineHeight, align, leftPad) {
70
+ ctx.fillStyle = "#000";
71
+ ctx.textBaseline = "middle";
72
+ ctx.textAlign = "left";
73
+ const blockH = lineHeight * (runLines.length - 1);
74
+ runLines.forEach((runs, i) => {
75
+ const y = ch / 2 - blockH / 2 + i * lineHeight;
76
+ let total = 0;
77
+ for (const r of runs) {
78
+ ctx.font = r.font;
79
+ total += ctx.measureText(r.text).width;
80
+ }
81
+ let x = align === "left" ? leftPad : cw / 2 - total / 2;
82
+ for (const r of runs) {
83
+ ctx.font = r.font;
84
+ ctx.fillText(r.text, x, y);
85
+ x += ctx.measureText(r.text).width;
86
+ }
87
+ });
88
+ }
57
89
  function buildTextTargets(count, lines, opts) {
58
90
  const out = new Float32Array(count * 3);
59
91
  const random = opts.random ?? Math.random;
@@ -62,17 +94,22 @@ function buildTextTargets(count, lines, opts) {
62
94
  const ctx = createSamplingContext(cw, ch);
63
95
  if (!ctx) return out;
64
96
  const align = opts.align ?? "center";
65
- ctx.clearRect(0, 0, cw, ch);
66
- ctx.fillStyle = "#000";
67
- ctx.textAlign = align === "left" ? "left" : "center";
68
- ctx.textBaseline = "middle";
69
- ctx.font = opts.font;
70
- const drawX = align === "left" ? cw * 0.04 : cw / 2;
71
97
  const lh = opts.lineHeight;
72
- const blockH = lh * (lines.length - 1);
73
- lines.forEach((line, i) => {
74
- ctx.fillText(line, drawX, ch / 2 - blockH / 2 + i * lh);
75
- });
98
+ ctx.clearRect(0, 0, cw, ch);
99
+ if (opts.segments && opts.segments.length > 0) {
100
+ const runLines = segmentsToRunLines(opts.segments, opts.font);
101
+ drawSegmentedLines(ctx, runLines, cw, ch, lh, align, cw * 0.04);
102
+ } else {
103
+ ctx.fillStyle = "#000";
104
+ ctx.textAlign = align === "left" ? "left" : "center";
105
+ ctx.textBaseline = "middle";
106
+ ctx.font = opts.font;
107
+ const drawX = align === "left" ? cw * 0.04 : cw / 2;
108
+ const blockH = lh * (lines.length - 1);
109
+ lines.forEach((line, i) => {
110
+ ctx.fillText(line, drawX, ch / 2 - blockH / 2 + i * lh);
111
+ });
112
+ }
76
113
  const step = opts.step ?? 2;
77
114
  const pts = collectFilledPixels(ctx, cw, ch, step);
78
115
  const filled = pts.length / 2;
@@ -106,15 +143,20 @@ function buildDenseTextTargets(count, lines, opts) {
106
143
  const ctx = createSamplingContext(cw, ch);
107
144
  if (!ctx) return out;
108
145
  ctx.clearRect(0, 0, cw, ch);
109
- ctx.fillStyle = "#000";
110
- ctx.textAlign = "center";
111
- ctx.textBaseline = "middle";
112
- ctx.font = opts.font;
113
146
  const lh = ch * (opts.lineHeightRatio ?? 0.46);
114
- const blockH = lh * (lines.length - 1);
115
- lines.forEach((line, i) => {
116
- ctx.fillText(line, cw / 2, ch / 2 - blockH / 2 + i * lh);
117
- });
147
+ if (opts.segments && opts.segments.length > 0) {
148
+ const runLines = segmentsToRunLines(opts.segments, opts.font);
149
+ drawSegmentedLines(ctx, runLines, cw, ch, lh, "center", cw * 0.04);
150
+ } else {
151
+ ctx.fillStyle = "#000";
152
+ ctx.textAlign = "center";
153
+ ctx.textBaseline = "middle";
154
+ ctx.font = opts.font;
155
+ const blockH = lh * (lines.length - 1);
156
+ lines.forEach((line, i) => {
157
+ ctx.fillText(line, cw / 2, ch / 2 - blockH / 2 + i * lh);
158
+ });
159
+ }
118
160
  const step = opts.step ?? 1;
119
161
  const pts = collectFilledPixels(ctx, cw, ch, step);
120
162
  const filled = pts.length / 2;
@@ -182,6 +224,8 @@ function buildVertexShader(keyframeCount) {
182
224
  uniform vec3 uPointer;
183
225
  uniform float uPointerActive;
184
226
  uniform float uSize;
227
+ uniform float uSizeScale;
228
+ uniform float uDrift;
185
229
  uniform float uPixelRatio;
186
230
 
187
231
  ${attributeDecls}
@@ -217,7 +261,7 @@ ${mixChain}
217
261
 
218
262
  // \u30A2\u30A4\u30C9\u30EB\u306E\u6F02\u3044\uFF08\u6574\u5217\u6642 settle / \u5B57\u5F62\u6642 form \u3067\u5F31\u3081\u308B\uFF09\u3002
219
263
  vSettle = uSettle;
220
- float drift = (1.0 - uReduced) * (1.0 - uSettle * 0.9) * (1.0 - uForm);
264
+ float drift = (1.0 - uReduced) * (1.0 - uSettle * 0.9) * (1.0 - uForm) * uDrift;
221
265
  pos.x += sin(uTime * 0.35 + ph) * 0.06 * drift;
222
266
  pos.y += cos(uTime * 0.30 + ph * 1.7) * 0.06 * drift;
223
267
  pos.z += sin(uTime * 0.27 + ph * 2.3) * 0.06 * drift;
@@ -245,9 +289,11 @@ ${mixChain}
245
289
  float sizeVar = mix(0.55 + aSeed * 0.9, 0.72 + aSeed * 0.35, uSettle);
246
290
  // \u5B57\u5F62\u53CE\u675F\u6642\u306F\u96A3\u63A5\u7C92\u5B50\u3067\u9699\u9593\u3092\u57CB\u3081\u308B\u305F\u3081\u308F\u305A\u304B\u306B\u5927\u304D\u3081\uFF06\u5747\u4E00\u306B\u3002
247
291
  sizeVar = mix(sizeVar, 0.95 + aSeed * 0.18, uForm);
248
- float s = uSize * sizeVar;
292
+ // \u9AD8 dpr \u74B0\u5883\u3067\u306F\u5C0F\u7C92\u30FB\u4E0A\u9650\u4F4E\u3081\u306E\u65B9\u304C\u30A8\u30C3\u30B8\u304C\u7DE0\u307E\u308A\u9AD8\u7CBE\u7D30\u306B\u898B\u3048\u308B
293
+ // \uFF08\u30B3\u30FC\u30DD\u30EC\u30FC\u30C8\u30B5\u30A4\u30C8\u5B9F\u88C5\u3067\u5B9F\u8A3C\u30020.62 \u3068 clamp 4\u301C5 \u304C\u6700\u3082\u300C\u971E\u307E\u306A\u3044\u300D\uFF09\u3002
294
+ float s = uSize * sizeVar * 0.62 * uSizeScale;
249
295
  gl_PointSize = s * uPixelRatio * (1.0 / -mvPosition.z);
250
- gl_PointSize = clamp(gl_PointSize, 1.0, mix(7.0, 9.0, uForm) * uPixelRatio);
296
+ gl_PointSize = clamp(gl_PointSize, 1.0, mix(4.0, 5.0, uForm) * uPixelRatio);
251
297
  }
252
298
  `
253
299
  );
@@ -257,6 +303,7 @@ var FRAGMENT_SHADER = (
257
303
  `
258
304
  uniform vec3 uColorInk;
259
305
  uniform vec3 uColorAccent;
306
+ uniform float uSparkle;
260
307
 
261
308
  varying float vSeed;
262
309
  varying float vAccent;
@@ -278,7 +325,7 @@ var FRAGMENT_SHADER = (
278
325
 
279
326
  // \u4E00\u90E8\u306E\u7C92\u306B\u660E\u308B\u3044\u304D\u3089\u3081\u304D\uFF08\u98DB\u6563\u6642\u306B\u6620\u3048\u308B\uFF09\u3002\u6574\u5217\u6642\u306F\u63A7\u3048\u3081\u3002
280
327
  float spark = step(0.94, vSeed);
281
- col = mix(col, uColorAccent, spark * mix(0.45, 0.15, vSettle));
328
+ col = mix(col, uColorAccent, spark * mix(0.45, 0.15, vSettle) * uSparkle);
282
329
 
283
330
  // \u5965\u884C\u304D\u3067\u6FC3\u6DE1\uFF08\u660E\u80CC\u666F\u3067\u306E\u8996\u8A8D\u6027\u78BA\u4FDD\u306E\u305F\u3081\u4E0B\u9650\u3092\u6301\u305F\u305B\u308B\uFF09\u3002
284
331
  float floorFade = mix(0.45, 0.78, vSettle);
@@ -412,6 +459,21 @@ function computeScreenRect(targets, viewportW, viewportH, visibleWorldW) {
412
459
  };
413
460
  }
414
461
  var DEFAULT_TRIGGER_HEIGHT = 2;
462
+ function triangle(x) {
463
+ const t = x % 2;
464
+ return t <= 1 ? t : 2 - t;
465
+ }
466
+ function computeAutoplayProgress(elapsedSec, cfg) {
467
+ const duration = cfg.duration && cfg.duration > 0 ? cfg.duration : 4;
468
+ const delay = cfg.delay && cfg.delay > 0 ? cfg.delay : 0;
469
+ const t = elapsedSec - delay;
470
+ if (t <= 0) return 0;
471
+ const raw = t / duration;
472
+ if (cfg.loop) {
473
+ return cfg.pingpong ? triangle(raw) : raw % 1;
474
+ }
475
+ return clamp01(raw);
476
+ }
415
477
  function clamp01(x) {
416
478
  return x < 0 ? 0 : x > 1 ? 1 : x;
417
479
  }
@@ -497,6 +559,7 @@ function buildKeyframeTargets(kf, count, ctx) {
497
559
  if (kf.dense) {
498
560
  return buildDenseTextTargets(count, lines, {
499
561
  font: kf.font ?? DEFAULT_DENSE_FONT,
562
+ segments: kf.segments,
500
563
  worldW: kf.worldW ?? ctx.visW * (ctx.mobile ? 0.86 : 0.62),
501
564
  offsetX: kf.offsetX ?? 0,
502
565
  offsetY: kf.offsetY ?? 0,
@@ -508,6 +571,7 @@ function buildKeyframeTargets(kf, count, ctx) {
508
571
  }
509
572
  return buildTextTargets(count, lines, {
510
573
  font: kf.font ?? DEFAULT_TEXT_FONT,
574
+ segments: kf.segments,
511
575
  worldW: kf.worldW ?? ctx.visW * 0.7,
512
576
  lineHeight: 178,
513
577
  offsetX: kf.offsetX ?? 0,
@@ -523,6 +587,7 @@ function GlyphPoints(props) {
523
587
  keyframes,
524
588
  count,
525
589
  colors,
590
+ style,
526
591
  cameraZ,
527
592
  cameraFov,
528
593
  pointer: pointerEnabled,
@@ -606,6 +671,9 @@ function GlyphPoints(props) {
606
671
  uPointer: { value: new THREE__namespace.Vector3(0, 0, 0) },
607
672
  uPointerActive: { value: 0 },
608
673
  uSize: { value: 1 },
674
+ uSizeScale: { value: style.size },
675
+ uDrift: { value: style.drift },
676
+ uSparkle: { value: style.sparkle },
609
677
  uPixelRatio: { value: 1 },
610
678
  uColorInk: { value: colors.ink.clone() },
611
679
  uColorAccent: { value: colors.accent.clone() }
@@ -803,9 +871,19 @@ function GlyphPoints(props) {
803
871
  const mat = matRef.current;
804
872
  if (!mat) return;
805
873
  const u = mat.uniforms;
806
- u.uPixelRatio.value = Math.min(window.devicePixelRatio || 1, 2);
874
+ u.uPixelRatio.value = Math.min(window.devicePixelRatio || 1, 3);
807
875
  u.uSize.value = Math.min(size.height / 18, 26);
808
876
  }, [size]);
877
+ react.useEffect(() => {
878
+ const mat = matRef.current;
879
+ if (!mat) return;
880
+ const u = mat.uniforms;
881
+ u.uSizeScale.value = style.size;
882
+ u.uDrift.value = style.drift;
883
+ u.uSparkle.value = style.sparkle;
884
+ mat.blending = style.blend === "additive" ? THREE__namespace.AdditiveBlending : THREE__namespace.NormalBlending;
885
+ mat.needsUpdate = true;
886
+ }, [style.size, style.drift, style.sparkle, style.blend]);
809
887
  fiber.useFrame((state, delta) => {
810
888
  const p = pointsRef.current;
811
889
  const mat = matRef.current;
@@ -813,7 +891,7 @@ function GlyphPoints(props) {
813
891
  const u = mat.uniforms;
814
892
  const d = Math.min(delta, 0.05);
815
893
  const raw = THREE__namespace.MathUtils.clamp(getProgress(), 0, 1);
816
- stage.current = THREE__namespace.MathUtils.lerp(stage.current, raw, 0.1);
894
+ stage.current = raw;
817
895
  const s = stage.current;
818
896
  let settle = 0;
819
897
  let burst = 0;
@@ -830,6 +908,11 @@ function GlyphPoints(props) {
830
908
  if (lastIsText && n >= 2) {
831
909
  form = smooth(times[n - 2] ?? 0, times[n - 1] ?? 1, s);
832
910
  }
911
+ const firstIsText = timeline.isText[0] === true;
912
+ if (firstIsText && n >= 2) {
913
+ const formStart = 1 - smooth(times[0] ?? 0, times[1] ?? 1, s);
914
+ form = Math.max(form, formStart);
915
+ }
833
916
  const guard = THREE__namespace.MathUtils.clamp(Math.max(settle, form), 0, 1);
834
917
  guardRef.current = guard;
835
918
  const swapped = raw >= timeline.swapAt ? 1 : 0;
@@ -880,7 +963,7 @@ function GlyphPoints(props) {
880
963
  uniforms,
881
964
  transparent: true,
882
965
  depthWrite: false,
883
- blending: THREE__namespace.NormalBlending,
966
+ blending: style.blend === "additive" ? THREE__namespace.AdditiveBlending : THREE__namespace.NormalBlending,
884
967
  vertexShader,
885
968
  fragmentShader: FRAGMENT_SHADER
886
969
  }
@@ -894,6 +977,12 @@ var DEFAULT_COUNT_MOBILE = 5200;
894
977
  var DEFAULT_CAMERA_Z = 7;
895
978
  var DEFAULT_CAMERA_FOV = 42;
896
979
  var DEFAULT_DPR = [1, 1.75];
980
+ var PRESETS = {
981
+ default: { size: 1, blend: "normal", drift: 1, sparkle: 1 },
982
+ minimal: { size: 0.92, blend: "normal", drift: 0.35, sparkle: 0 },
983
+ lively: { size: 1.05, blend: "normal", drift: 1.4, sparkle: 1.4 },
984
+ glow: { size: 1.1, blend: "additive", drift: 1.1, sparkle: 1.5 }
985
+ };
897
986
  function clamp012(x) {
898
987
  return x < 0 ? 0 : x > 1 ? 1 : x;
899
988
  }
@@ -912,6 +1001,8 @@ function GlyphDust(props) {
912
1001
  const {
913
1002
  keyframes,
914
1003
  driver = { type: "scroll" },
1004
+ preset = "default",
1005
+ style,
915
1006
  colors,
916
1007
  count,
917
1008
  dpr = DEFAULT_DPR,
@@ -935,15 +1026,62 @@ function GlyphDust(props) {
935
1026
  const resolveRef = react.useRef(null);
936
1027
  const manualRef = react.useRef(0);
937
1028
  if (driver.type === "manual") manualRef.current = clamp012(driver.progress);
1029
+ const autoplay = driver.type === "autoplay" ? driver : null;
1030
+ const playingRef = react.useRef(false);
1031
+ const startMsRef = react.useRef(null);
1032
+ const lastAutoRef = react.useRef(0);
1033
+ react.useEffect(() => {
1034
+ if (!autoplay) return;
1035
+ if (autoplay.playOnView === false) {
1036
+ playingRef.current = true;
1037
+ return;
1038
+ }
1039
+ const el = wrapperRef.current;
1040
+ if (el === null || typeof IntersectionObserver === "undefined") {
1041
+ playingRef.current = true;
1042
+ return;
1043
+ }
1044
+ const io = new IntersectionObserver(
1045
+ (entries) => {
1046
+ for (const e of entries) {
1047
+ if (e.isIntersecting && !playingRef.current) {
1048
+ playingRef.current = true;
1049
+ startMsRef.current = null;
1050
+ }
1051
+ }
1052
+ },
1053
+ { threshold: 0.25 }
1054
+ );
1055
+ io.observe(el);
1056
+ return () => io.disconnect();
1057
+ }, [autoplay?.playOnView]);
938
1058
  const getProgress = react.useCallback(() => {
939
1059
  if (driver.type === "manual") return manualRef.current;
1060
+ if (driver.type === "autoplay") {
1061
+ if (!playingRef.current || typeof performance === "undefined") {
1062
+ return lastAutoRef.current;
1063
+ }
1064
+ if (startMsRef.current === null) startMsRef.current = performance.now();
1065
+ const elapsed = (performance.now() - startMsRef.current) / 1e3;
1066
+ lastAutoRef.current = computeAutoplayProgress(elapsed, driver);
1067
+ return lastAutoRef.current;
1068
+ }
940
1069
  const el = wrapperRef.current;
941
1070
  if (el === null || typeof window === "undefined") return 0;
942
1071
  const rect = el.getBoundingClientRect();
943
1072
  const total = rect.height - window.innerHeight;
944
1073
  if (total <= 0) return 0;
945
1074
  return clamp012(-rect.top / total);
946
- }, [driver.type]);
1075
+ }, [driver]);
1076
+ const resolvedStyle = react.useMemo(() => {
1077
+ const base = PRESETS[preset] ?? PRESETS.default;
1078
+ return {
1079
+ size: style?.size ?? base.size,
1080
+ blend: style?.blend ?? base.blend,
1081
+ drift: style?.drift ?? base.drift,
1082
+ sparkle: style?.sparkle ?? base.sparkle
1083
+ };
1084
+ }, [preset, style?.size, style?.blend, style?.drift, style?.sparkle]);
947
1085
  const resolvedColors = react.useMemo(
948
1086
  () => ({
949
1087
  ink: new THREE__namespace.Color(colors?.ink ?? DEFAULT_INK),
@@ -980,6 +1118,7 @@ function GlyphDust(props) {
980
1118
  keyframes,
981
1119
  count: particleCount,
982
1120
  colors: resolvedColors,
1121
+ style: resolvedStyle,
983
1122
  cameraZ,
984
1123
  cameraFov,
985
1124
  pointer: pointerEnabled,
@@ -1013,10 +1152,11 @@ function GlyphDust(props) {
1013
1152
  }
1014
1153
  ) : null
1015
1154
  ] });
1016
- if (driver.type === "manual") {
1155
+ if (driver.type === "manual" || driver.type === "autoplay") {
1017
1156
  return /* @__PURE__ */ jsxRuntime.jsx(
1018
1157
  "div",
1019
1158
  {
1159
+ ref: wrapperRef,
1020
1160
  className,
1021
1161
  style: { position: "relative", width: "100%", height: "100%" },
1022
1162
  children: scene
@@ -1048,7 +1188,7 @@ function GlyphDust(props) {
1048
1188
  }
1049
1189
 
1050
1190
  // src/index.ts
1051
- var VERSION = "0.1.0";
1191
+ var VERSION = "0.3.0";
1052
1192
 
1053
1193
  exports.DEFAULT_TRIGGER_HEIGHT = DEFAULT_TRIGGER_HEIGHT;
1054
1194
  exports.FRAGMENT_SHADER = FRAGMENT_SHADER;
@@ -1059,6 +1199,7 @@ exports.buildDenseTextTargets = buildDenseTextTargets;
1059
1199
  exports.buildGlyphFromDOM = buildGlyphFromDOM;
1060
1200
  exports.buildTextTargets = buildTextTargets;
1061
1201
  exports.buildVertexShader = buildVertexShader;
1202
+ exports.computeAutoplayProgress = computeAutoplayProgress;
1062
1203
  exports.computeScreenRect = computeScreenRect;
1063
1204
  exports.createScrollProgress = createScrollProgress;
1064
1205
  exports.glyphPositionAttribute = glyphPositionAttribute;