glyphdust 0.1.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
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.2.1] — 2026-06-26
8
+
9
+ Flexibility & polish release. Glyphdust is no longer scroll-and-hero only — it now
10
+ drops into any box, plays without scroll, and ships tasteful presets you can override.
11
+ **Defaults reproduce 0.2.0 exactly**, so upgrading is non-breaking.
12
+
13
+ ### Added
14
+
15
+ - **`autoplay` driver** — time-based progress with no scroll choreography. Fits its
16
+ parent box and starts when scrolled into view (`playOnView`, default on). Options:
17
+ `duration`, `delay`, `loop`, `pingpong`, `playOnView`. Exposed
18
+ `computeAutoplayProgress()` for custom rigs.
19
+ - **`preset` prop** — `"default" | "minimal" | "lively" | "glow"`: a tasteful bundle
20
+ of look + motion.
21
+ - **`style` prop** — per-field overrides on top of the preset:
22
+ `size`, `blend` (`"normal" | "additive"`), `drift`, `sparkle`. Backed by new shader
23
+ uniforms (`uSizeScale`, `uDrift`, `uSparkle`); `additive` enables glow blending for
24
+ dark backgrounds.
25
+
26
+ ### Changed
27
+
28
+ - Particles render finer and crisper on high-DPI screens: point-size base ×0.62,
29
+ clamp lowered to 4–5 px, and `devicePixelRatio` cap raised 2 → 3. (Validated on the
30
+ LINNO corporate site.)
31
+ - Scroll follow no longer lags: stage progress is applied directly instead of an
32
+ internal lerp. Add inertia in your driver (e.g. Lenis) if you want it.
33
+
34
+ ### Fixed
35
+
36
+ - No more blank gap when the **first keyframe is text** — particles now start in the
37
+ formed glyph and dissolve outward, instead of appearing only after the real text
38
+ fades.
39
+ - `VERSION` export corrected (was a stale `"0.1.0"`).
40
+
41
+ ## [0.2.0] — 2026-06-23
42
+
43
+ - Resolve to real DOM elements with pixel alignment; scrollbar & baseline fixes.
44
+
45
+ ## [0.1.0]
46
+
47
+ - 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`. |
@@ -142,10 +171,35 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
142
171
  ### Drivers
143
172
 
144
173
  ```ts
145
- { type: "scroll", triggerHeight?: number } // default triggerHeight: 2 (×100vh)
174
+ { type: "scroll", triggerHeight?: number } // full-screen sticky hero. default triggerHeight: 2 (×100vh)
146
175
  { type: "manual", progress: number } // you supply 0..1
176
+ { type: "autoplay", // time-based; fits its parent box
177
+ duration?: number, // seconds for 0→1 (default 4)
178
+ delay?: number, // start delay (default 0)
179
+ loop?: boolean, // repeat (default false)
180
+ pingpong?: boolean, // 0→1→0 when looping (default false)
181
+ playOnView?: boolean, // start when scrolled into view (default true)
182
+ }
147
183
  ```
148
184
 
185
+ `scroll` builds a tall sticky wrapper for a full-screen hero. `manual` and `autoplay`
186
+ simply **fill their parent**, so you can place them in any sized container.
187
+
188
+ ### Presets & style
189
+
190
+ ```ts
191
+ preset: "default" | "minimal" | "lively" | "glow"
192
+
193
+ style: {
194
+ size?: number, // point-size multiplier (default 1)
195
+ blend?: "normal" | "additive", // "additive" = glow, for dark backgrounds
196
+ drift?: number, // idle/scatter wander 0..1 (default 1; 0 = still)
197
+ sparkle?: number, // sparkle strength 0..1 (default 1; 0 = off)
198
+ }
199
+ ```
200
+
201
+ `style` always wins over `preset`. Defaults reproduce the original look exactly.
202
+
149
203
  ### Colors
150
204
 
151
205
  ```ts
@@ -156,7 +210,7 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
156
210
 
157
211
  ### Low-level helpers
158
212
 
159
- For custom rigs, the building blocks are exported too: `buildTextTargets`, `buildDenseTextTargets`, `buildVertexShader`, `FRAGMENT_SHADER`, `createScrollProgress`, `useScrollProgress`, `useReducedMotion`, `prefersReducedMotion`, `viewSizeAtZ0`, `buildGlyphFromDOM`, `computeScreenRect`.
213
+ For custom rigs, the building blocks are exported too: `buildTextTargets`, `buildDenseTextTargets`, `buildVertexShader`, `FRAGMENT_SHADER`, `createScrollProgress`, `useScrollProgress`, `computeAutoplayProgress`, `useReducedMotion`, `prefersReducedMotion`, `viewSizeAtZ0`, `buildGlyphFromDOM`, `computeScreenRect`.
160
214
 
161
215
  ---
162
216
 
@@ -171,7 +225,7 @@ For custom rigs, the building blocks are exported too: `buildTextTargets`, `buil
171
225
 
172
226
  ## Status
173
227
 
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`.
228
+ `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
229
 
176
230
  ## License
177
231
 
package/dist/index.cjs CHANGED
@@ -182,6 +182,8 @@ function buildVertexShader(keyframeCount) {
182
182
  uniform vec3 uPointer;
183
183
  uniform float uPointerActive;
184
184
  uniform float uSize;
185
+ uniform float uSizeScale;
186
+ uniform float uDrift;
185
187
  uniform float uPixelRatio;
186
188
 
187
189
  ${attributeDecls}
@@ -217,7 +219,7 @@ ${mixChain}
217
219
 
218
220
  // \u30A2\u30A4\u30C9\u30EB\u306E\u6F02\u3044\uFF08\u6574\u5217\u6642 settle / \u5B57\u5F62\u6642 form \u3067\u5F31\u3081\u308B\uFF09\u3002
219
221
  vSettle = uSettle;
220
- float drift = (1.0 - uReduced) * (1.0 - uSettle * 0.9) * (1.0 - uForm);
222
+ float drift = (1.0 - uReduced) * (1.0 - uSettle * 0.9) * (1.0 - uForm) * uDrift;
221
223
  pos.x += sin(uTime * 0.35 + ph) * 0.06 * drift;
222
224
  pos.y += cos(uTime * 0.30 + ph * 1.7) * 0.06 * drift;
223
225
  pos.z += sin(uTime * 0.27 + ph * 2.3) * 0.06 * drift;
@@ -245,10 +247,11 @@ ${mixChain}
245
247
  float sizeVar = mix(0.55 + aSeed * 0.9, 0.72 + aSeed * 0.35, uSettle);
246
248
  // \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
249
  sizeVar = mix(sizeVar, 0.95 + aSeed * 0.18, uForm);
248
- float s = uSize * sizeVar;
250
+ // \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
251
+ // \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
252
+ float s = uSize * sizeVar * 0.62 * uSizeScale;
249
253
  gl_PointSize = s * uPixelRatio * (1.0 / -mvPosition.z);
250
- gl_PointSize = clamp(gl_PointSize, 1.0, mix(7.0, 9.0, uForm) * uPixelRatio);
251
- gl_PointSize = 10.0; // DEBUG4
254
+ gl_PointSize = clamp(gl_PointSize, 1.0, mix(4.0, 5.0, uForm) * uPixelRatio);
252
255
  }
253
256
  `
254
257
  );
@@ -258,6 +261,7 @@ var FRAGMENT_SHADER = (
258
261
  `
259
262
  uniform vec3 uColorInk;
260
263
  uniform vec3 uColorAccent;
264
+ uniform float uSparkle;
261
265
 
262
266
  varying float vSeed;
263
267
  varying float vAccent;
@@ -279,7 +283,7 @@ var FRAGMENT_SHADER = (
279
283
 
280
284
  // \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
281
285
  float spark = step(0.94, vSeed);
282
- col = mix(col, uColorAccent, spark * mix(0.45, 0.15, vSettle));
286
+ col = mix(col, uColorAccent, spark * mix(0.45, 0.15, vSettle) * uSparkle);
283
287
 
284
288
  // \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
285
289
  float floorFade = mix(0.45, 0.78, vSettle);
@@ -341,10 +345,15 @@ function buildGlyphFromDOM(count, lines, opts) {
341
345
  } catch {
342
346
  }
343
347
  }
344
- const ascent = fontSize * (opts.ascentRatio ?? 0.82);
348
+ const fm = ctx.measureText(lines[0] ?? "M");
349
+ const fbAsc = fm.fontBoundingBoxAscent;
350
+ const fbDesc = fm.fontBoundingBoxDescent;
351
+ const useMetrics = Number.isFinite(fbAsc) && Number.isFinite(fbDesc);
352
+ const fallbackAscent = fontSize * (opts.ascentRatio ?? 0.82);
345
353
  lines.forEach((line, i) => {
346
354
  const lineTop = i * lineHeight;
347
- ctx.fillText(line, 0, lineTop + (lineHeight - fontSize) / 2 + ascent);
355
+ const baseline = useMetrics ? lineTop + (lineHeight - (fbAsc + fbDesc)) / 2 + fbAsc : lineTop + (lineHeight - fontSize) / 2 + fallbackAscent;
356
+ ctx.fillText(line, 0, baseline);
348
357
  });
349
358
  const { data } = ctx.getImageData(0, 0, cw, ch);
350
359
  const pts = [];
@@ -356,8 +365,8 @@ function buildGlyphFromDOM(count, lines, opts) {
356
365
  }
357
366
  const filled = pts.length / 2;
358
367
  if (filled === 0) return null;
359
- const vpW = window.innerWidth;
360
- const vpH = window.innerHeight;
368
+ const vpW = opts.viewportW ?? window.innerWidth;
369
+ const vpH = opts.viewportH ?? window.innerHeight;
361
370
  const { worldW, worldH } = viewSizeAtZ0(vpW, vpH, opts.fovDeg, opts.cameraZ);
362
371
  const pxToWorld = worldW / vpW;
363
372
  const thickness = opts.thickness ?? 0.14;
@@ -408,6 +417,21 @@ function computeScreenRect(targets, viewportW, viewportH, visibleWorldW) {
408
417
  };
409
418
  }
410
419
  var DEFAULT_TRIGGER_HEIGHT = 2;
420
+ function triangle(x) {
421
+ const t = x % 2;
422
+ return t <= 1 ? t : 2 - t;
423
+ }
424
+ function computeAutoplayProgress(elapsedSec, cfg) {
425
+ const duration = cfg.duration && cfg.duration > 0 ? cfg.duration : 4;
426
+ const delay = cfg.delay && cfg.delay > 0 ? cfg.delay : 0;
427
+ const t = elapsedSec - delay;
428
+ if (t <= 0) return 0;
429
+ const raw = t / duration;
430
+ if (cfg.loop) {
431
+ return cfg.pingpong ? triangle(raw) : raw % 1;
432
+ }
433
+ return clamp01(raw);
434
+ }
411
435
  function clamp01(x) {
412
436
  return x < 0 ? 0 : x > 1 ? 1 : x;
413
437
  }
@@ -519,15 +543,18 @@ function GlyphPoints(props) {
519
543
  keyframes,
520
544
  count,
521
545
  colors,
546
+ style,
522
547
  cameraZ,
523
548
  cameraFov,
524
549
  pointer: pointerEnabled,
525
550
  drag: dragEnabled,
526
551
  getProgress,
527
552
  timing,
528
- resolveRef
553
+ resolveRef,
554
+ resolveDomSelector
529
555
  } = props;
530
556
  const pointsRef = react.useRef(null);
557
+ const resolveDomElRef = react.useRef(null);
531
558
  const matRef = react.useRef(null);
532
559
  const { size, gl } = fiber.useThree();
533
560
  const pointer = react.useRef({ x: 0, y: 0, active: 0 });
@@ -600,6 +627,9 @@ function GlyphPoints(props) {
600
627
  uPointer: { value: new THREE__namespace.Vector3(0, 0, 0) },
601
628
  uPointerActive: { value: 0 },
602
629
  uSize: { value: 1 },
630
+ uSizeScale: { value: style.size },
631
+ uDrift: { value: style.drift },
632
+ uSparkle: { value: style.sparkle },
603
633
  uPixelRatio: { value: 1 },
604
634
  uColorInk: { value: colors.ink.clone() },
605
635
  uColorAccent: { value: colors.accent.clone() }
@@ -617,11 +647,93 @@ function GlyphPoints(props) {
617
647
  const { worldW: visW } = viewSizeAtZ0(vpW, vpH, cameraFov, cameraZ);
618
648
  const rect = computeScreenRect(finalBuf, vpW, vpH, visW);
619
649
  if (!rect) return;
620
- el.style.left = `${rect.left}px`;
621
- el.style.top = `${rect.top}px`;
622
- el.style.width = `${rect.width}px`;
623
- el.style.height = `${rect.height}px`;
624
- el.style.fontSize = `${rect.height * 0.92}px`;
650
+ const finalKf = keyframes[n - 1];
651
+ const fontStr = finalKf?.type === "text" && finalKf.font ? finalKf.font : DEFAULT_DENSE_FONT;
652
+ const fontMatch = fontStr.match(/^\s*(\d+)\s+[\d.]+px\s+(.+)$/);
653
+ const fontWeight = fontMatch?.[1] ?? "900";
654
+ const fontFamily = fontMatch?.[2] ?? "sans-serif";
655
+ const text = timeline.resolveText;
656
+ const ctx = document.createElement("canvas").getContext("2d", {
657
+ willReadFrequently: true
658
+ });
659
+ let positioned = false;
660
+ if (ctx && text) {
661
+ const baseSize = 200;
662
+ ctx.font = `${fontWeight} ${baseSize}px ${fontFamily}`;
663
+ const advBase = ctx.measureText(text).width;
664
+ const pad = Math.ceil(baseSize * 0.6);
665
+ const cw = Math.ceil(advBase + pad * 2);
666
+ const ch = Math.ceil(baseSize * 1.8);
667
+ const oc = document.createElement("canvas");
668
+ oc.width = cw;
669
+ oc.height = ch;
670
+ const octx = oc.getContext("2d", { willReadFrequently: true });
671
+ if (octx) {
672
+ const drawX = pad;
673
+ const drawY = Math.round(ch * 0.72);
674
+ octx.font = `${fontWeight} ${baseSize}px ${fontFamily}`;
675
+ octx.textAlign = "left";
676
+ octx.textBaseline = "alphabetic";
677
+ octx.fillStyle = "#000";
678
+ octx.fillText(text, drawX, drawY);
679
+ const data = octx.getImageData(0, 0, cw, ch).data;
680
+ let minX = cw;
681
+ let maxX = 0;
682
+ let minY = ch;
683
+ let maxY = 0;
684
+ let found = 0;
685
+ for (let y = 0; y < ch; y++) {
686
+ for (let x = 0; x < cw; x++) {
687
+ if (data[(y * cw + x) * 4 + 3] > 20) {
688
+ if (x < minX) minX = x;
689
+ if (x > maxX) maxX = x;
690
+ if (y < minY) minY = y;
691
+ if (y > maxY) maxY = y;
692
+ found++;
693
+ }
694
+ }
695
+ }
696
+ if (found > 0) {
697
+ const fontSize = baseSize * (rect.width / (maxX - minX));
698
+ const scale = fontSize / baseSize;
699
+ const inkCenterXFromStart = ((minX + maxX) / 2 - drawX) * scale;
700
+ const inkCenterYFromBaseline = ((minY + maxY) / 2 - drawY) * scale;
701
+ ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
702
+ const fm = ctx.measureText(text);
703
+ const leading = fontSize - (fm.fontBoundingBoxAscent + fm.fontBoundingBoxDescent);
704
+ const baselineFromTop = leading / 2 + fm.fontBoundingBoxAscent;
705
+ const targetCx = rect.left + rect.width / 2;
706
+ const targetCy = rect.top + rect.height / 2;
707
+ el.style.display = "block";
708
+ el.style.textAlign = "left";
709
+ el.style.whiteSpace = "nowrap";
710
+ el.style.width = "auto";
711
+ el.style.height = "auto";
712
+ el.style.fontFamily = fontFamily;
713
+ el.style.fontWeight = fontWeight;
714
+ el.style.fontSize = `${fontSize}px`;
715
+ el.style.left = `${targetCx - inkCenterXFromStart}px`;
716
+ el.style.top = `${targetCy - inkCenterYFromBaseline - baselineFromTop}px`;
717
+ positioned = true;
718
+ }
719
+ }
720
+ }
721
+ if (!positioned) {
722
+ const measureSize = 100;
723
+ let fontSize = rect.height * 0.92;
724
+ if (ctx && text) {
725
+ ctx.font = `${fontWeight} ${measureSize}px ${fontFamily}`;
726
+ const w = ctx.measureText(text).width;
727
+ if (w > 0) fontSize = measureSize * (rect.width / w);
728
+ }
729
+ el.style.left = `${rect.left}px`;
730
+ el.style.top = `${rect.top}px`;
731
+ el.style.width = `${rect.width}px`;
732
+ el.style.height = `${rect.height}px`;
733
+ el.style.fontFamily = fontFamily;
734
+ el.style.fontWeight = fontWeight;
735
+ el.style.fontSize = `${fontSize}px`;
736
+ }
625
737
  };
626
738
  const rebuildDomGlyphs = () => {
627
739
  keyframes.forEach((kf, i) => {
@@ -629,7 +741,11 @@ function GlyphPoints(props) {
629
741
  const next = buildGlyphFromDOM(count, kf.text.split("\n"), {
630
742
  selector: kf.domSelector,
631
743
  fovDeg: cameraFov,
632
- cameraZ
744
+ cameraZ,
745
+ // 粒子がレンダリングされる canvas の実寸(CSS px)。
746
+ // window.innerWidth だとスクロールバー分ずれるため size を使う。
747
+ viewportW: size.width,
748
+ viewportH: size.height
633
749
  });
634
750
  if (!next) return;
635
751
  const attr = built.geo.getAttribute(glyphPositionAttribute(i));
@@ -711,9 +827,19 @@ function GlyphPoints(props) {
711
827
  const mat = matRef.current;
712
828
  if (!mat) return;
713
829
  const u = mat.uniforms;
714
- u.uPixelRatio.value = Math.min(window.devicePixelRatio || 1, 2);
830
+ u.uPixelRatio.value = Math.min(window.devicePixelRatio || 1, 3);
715
831
  u.uSize.value = Math.min(size.height / 18, 26);
716
832
  }, [size]);
833
+ react.useEffect(() => {
834
+ const mat = matRef.current;
835
+ if (!mat) return;
836
+ const u = mat.uniforms;
837
+ u.uSizeScale.value = style.size;
838
+ u.uDrift.value = style.drift;
839
+ u.uSparkle.value = style.sparkle;
840
+ mat.blending = style.blend === "additive" ? THREE__namespace.AdditiveBlending : THREE__namespace.NormalBlending;
841
+ mat.needsUpdate = true;
842
+ }, [style.size, style.drift, style.sparkle, style.blend]);
717
843
  fiber.useFrame((state, delta) => {
718
844
  const p = pointsRef.current;
719
845
  const mat = matRef.current;
@@ -721,7 +847,7 @@ function GlyphPoints(props) {
721
847
  const u = mat.uniforms;
722
848
  const d = Math.min(delta, 0.05);
723
849
  const raw = THREE__namespace.MathUtils.clamp(getProgress(), 0, 1);
724
- stage.current = THREE__namespace.MathUtils.lerp(stage.current, raw, 0.1);
850
+ stage.current = raw;
725
851
  const s = stage.current;
726
852
  let settle = 0;
727
853
  let burst = 0;
@@ -738,10 +864,16 @@ function GlyphPoints(props) {
738
864
  if (lastIsText && n >= 2) {
739
865
  form = smooth(times[n - 2] ?? 0, times[n - 1] ?? 1, s);
740
866
  }
867
+ const firstIsText = timeline.isText[0] === true;
868
+ if (firstIsText && n >= 2) {
869
+ const formStart = 1 - smooth(times[0] ?? 0, times[1] ?? 1, s);
870
+ form = Math.max(form, formStart);
871
+ }
741
872
  const guard = THREE__namespace.MathUtils.clamp(Math.max(settle, form), 0, 1);
742
873
  guardRef.current = guard;
743
874
  const swapped = raw >= timeline.swapAt ? 1 : 0;
744
875
  const resolve = timeline.hasResolve ? smooth(0.9, 0.98, raw) : 0;
876
+ const textReveal = timeline.hasResolve ? smooth(0.92, 1, raw) : 0;
745
877
  u.uTime.value = state.clock.elapsedTime;
746
878
  u.uStage.value = s;
747
879
  u.uForm.value = form;
@@ -769,8 +901,16 @@ function GlyphPoints(props) {
769
901
  rot.current.x = THREE__namespace.MathUtils.lerp(rot.current.x, 0, 0.04 + guard * 0.14);
770
902
  p.rotation.x = rot.current.x;
771
903
  p.rotation.y = rot.current.y;
772
- const overlay = resolveRef?.current;
773
- if (overlay && timeline.hasResolve) overlay.style.opacity = String(resolve);
904
+ if (timeline.hasResolve) {
905
+ let target = resolveRef?.current ?? null;
906
+ if (!target && resolveDomSelector) {
907
+ if (!resolveDomElRef.current) {
908
+ resolveDomElRef.current = document.querySelector(resolveDomSelector);
909
+ }
910
+ target = resolveDomElRef.current;
911
+ }
912
+ if (target) target.style.opacity = String(textReveal);
913
+ }
774
914
  });
775
915
  return /* @__PURE__ */ jsxRuntime.jsx("points", { ref: pointsRef, geometry: built.geo, frustumCulled: false, children: /* @__PURE__ */ jsxRuntime.jsx(
776
916
  "shaderMaterial",
@@ -779,7 +919,7 @@ function GlyphPoints(props) {
779
919
  uniforms,
780
920
  transparent: true,
781
921
  depthWrite: false,
782
- blending: THREE__namespace.NormalBlending,
922
+ blending: style.blend === "additive" ? THREE__namespace.AdditiveBlending : THREE__namespace.NormalBlending,
783
923
  vertexShader,
784
924
  fragmentShader: FRAGMENT_SHADER
785
925
  }
@@ -793,6 +933,12 @@ var DEFAULT_COUNT_MOBILE = 5200;
793
933
  var DEFAULT_CAMERA_Z = 7;
794
934
  var DEFAULT_CAMERA_FOV = 42;
795
935
  var DEFAULT_DPR = [1, 1.75];
936
+ var PRESETS = {
937
+ default: { size: 1, blend: "normal", drift: 1, sparkle: 1 },
938
+ minimal: { size: 0.92, blend: "normal", drift: 0.35, sparkle: 0 },
939
+ lively: { size: 1.05, blend: "normal", drift: 1.4, sparkle: 1.4 },
940
+ glow: { size: 1.1, blend: "additive", drift: 1.1, sparkle: 1.5 }
941
+ };
796
942
  function clamp012(x) {
797
943
  return x < 0 ? 0 : x > 1 ? 1 : x;
798
944
  }
@@ -811,6 +957,8 @@ function GlyphDust(props) {
811
957
  const {
812
958
  keyframes,
813
959
  driver = { type: "scroll" },
960
+ preset = "default",
961
+ style,
814
962
  colors,
815
963
  count,
816
964
  dpr = DEFAULT_DPR,
@@ -834,15 +982,62 @@ function GlyphDust(props) {
834
982
  const resolveRef = react.useRef(null);
835
983
  const manualRef = react.useRef(0);
836
984
  if (driver.type === "manual") manualRef.current = clamp012(driver.progress);
985
+ const autoplay = driver.type === "autoplay" ? driver : null;
986
+ const playingRef = react.useRef(false);
987
+ const startMsRef = react.useRef(null);
988
+ const lastAutoRef = react.useRef(0);
989
+ react.useEffect(() => {
990
+ if (!autoplay) return;
991
+ if (autoplay.playOnView === false) {
992
+ playingRef.current = true;
993
+ return;
994
+ }
995
+ const el = wrapperRef.current;
996
+ if (el === null || typeof IntersectionObserver === "undefined") {
997
+ playingRef.current = true;
998
+ return;
999
+ }
1000
+ const io = new IntersectionObserver(
1001
+ (entries) => {
1002
+ for (const e of entries) {
1003
+ if (e.isIntersecting && !playingRef.current) {
1004
+ playingRef.current = true;
1005
+ startMsRef.current = null;
1006
+ }
1007
+ }
1008
+ },
1009
+ { threshold: 0.25 }
1010
+ );
1011
+ io.observe(el);
1012
+ return () => io.disconnect();
1013
+ }, [autoplay?.playOnView]);
837
1014
  const getProgress = react.useCallback(() => {
838
1015
  if (driver.type === "manual") return manualRef.current;
1016
+ if (driver.type === "autoplay") {
1017
+ if (!playingRef.current || typeof performance === "undefined") {
1018
+ return lastAutoRef.current;
1019
+ }
1020
+ if (startMsRef.current === null) startMsRef.current = performance.now();
1021
+ const elapsed = (performance.now() - startMsRef.current) / 1e3;
1022
+ lastAutoRef.current = computeAutoplayProgress(elapsed, driver);
1023
+ return lastAutoRef.current;
1024
+ }
839
1025
  const el = wrapperRef.current;
840
1026
  if (el === null || typeof window === "undefined") return 0;
841
1027
  const rect = el.getBoundingClientRect();
842
1028
  const total = rect.height - window.innerHeight;
843
1029
  if (total <= 0) return 0;
844
1030
  return clamp012(-rect.top / total);
845
- }, [driver.type]);
1031
+ }, [driver]);
1032
+ const resolvedStyle = react.useMemo(() => {
1033
+ const base = PRESETS[preset] ?? PRESETS.default;
1034
+ return {
1035
+ size: style?.size ?? base.size,
1036
+ blend: style?.blend ?? base.blend,
1037
+ drift: style?.drift ?? base.drift,
1038
+ sparkle: style?.sparkle ?? base.sparkle
1039
+ };
1040
+ }, [preset, style?.size, style?.blend, style?.drift, style?.sparkle]);
846
1041
  const resolvedColors = react.useMemo(
847
1042
  () => ({
848
1043
  ink: new THREE__namespace.Color(colors?.ink ?? DEFAULT_INK),
@@ -858,6 +1053,8 @@ function GlyphDust(props) {
858
1053
  const dragEnabled = interaction?.drag ?? true;
859
1054
  const finalKf = keyframes[keyframes.length - 1];
860
1055
  const hasResolve = finalKf?.type === "text" && finalKf.resolveToDom === true;
1056
+ const resolveDomSelector = finalKf?.type === "text" && finalKf.resolveToDom === true && finalKf.domSelector ? finalKf.domSelector : void 0;
1057
+ const useOwnOverlay = hasResolve && !resolveDomSelector;
861
1058
  const resolveText = finalKf?.type === "text" ? finalKf.text.replace(/\n/g, " ") : "";
862
1059
  if (reduced || !webgl) {
863
1060
  return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: fallback });
@@ -877,18 +1074,20 @@ function GlyphDust(props) {
877
1074
  keyframes,
878
1075
  count: particleCount,
879
1076
  colors: resolvedColors,
1077
+ style: resolvedStyle,
880
1078
  cameraZ,
881
1079
  cameraFov,
882
1080
  pointer: pointerEnabled,
883
1081
  drag: dragEnabled,
884
1082
  getProgress,
885
1083
  timing,
886
- resolveRef: hasResolve ? resolveRef : void 0
1084
+ resolveRef: useOwnOverlay ? resolveRef : void 0,
1085
+ resolveDomSelector
887
1086
  }
888
1087
  )
889
1088
  }
890
1089
  ),
891
- hasResolve ? /* @__PURE__ */ jsxRuntime.jsx(
1090
+ useOwnOverlay ? /* @__PURE__ */ jsxRuntime.jsx(
892
1091
  "div",
893
1092
  {
894
1093
  ref: resolveRef,
@@ -909,10 +1108,11 @@ function GlyphDust(props) {
909
1108
  }
910
1109
  ) : null
911
1110
  ] });
912
- if (driver.type === "manual") {
1111
+ if (driver.type === "manual" || driver.type === "autoplay") {
913
1112
  return /* @__PURE__ */ jsxRuntime.jsx(
914
1113
  "div",
915
1114
  {
1115
+ ref: wrapperRef,
916
1116
  className,
917
1117
  style: { position: "relative", width: "100%", height: "100%" },
918
1118
  children: scene
@@ -944,7 +1144,7 @@ function GlyphDust(props) {
944
1144
  }
945
1145
 
946
1146
  // src/index.ts
947
- var VERSION = "0.1.0";
1147
+ var VERSION = "0.2.1";
948
1148
 
949
1149
  exports.DEFAULT_TRIGGER_HEIGHT = DEFAULT_TRIGGER_HEIGHT;
950
1150
  exports.FRAGMENT_SHADER = FRAGMENT_SHADER;
@@ -955,6 +1155,7 @@ exports.buildDenseTextTargets = buildDenseTextTargets;
955
1155
  exports.buildGlyphFromDOM = buildGlyphFromDOM;
956
1156
  exports.buildTextTargets = buildTextTargets;
957
1157
  exports.buildVertexShader = buildVertexShader;
1158
+ exports.computeAutoplayProgress = computeAutoplayProgress;
958
1159
  exports.computeScreenRect = computeScreenRect;
959
1160
  exports.createScrollProgress = createScrollProgress;
960
1161
  exports.glyphPositionAttribute = glyphPositionAttribute;