glyphdust 0.2.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,9 +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);
254
+ gl_PointSize = clamp(gl_PointSize, 1.0, mix(4.0, 5.0, uForm) * uPixelRatio);
251
255
  }
252
256
  `
253
257
  );
@@ -257,6 +261,7 @@ var FRAGMENT_SHADER = (
257
261
  `
258
262
  uniform vec3 uColorInk;
259
263
  uniform vec3 uColorAccent;
264
+ uniform float uSparkle;
260
265
 
261
266
  varying float vSeed;
262
267
  varying float vAccent;
@@ -278,7 +283,7 @@ var FRAGMENT_SHADER = (
278
283
 
279
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
280
285
  float spark = step(0.94, vSeed);
281
- col = mix(col, uColorAccent, spark * mix(0.45, 0.15, vSettle));
286
+ col = mix(col, uColorAccent, spark * mix(0.45, 0.15, vSettle) * uSparkle);
282
287
 
283
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
284
289
  float floorFade = mix(0.45, 0.78, vSettle);
@@ -412,6 +417,21 @@ function computeScreenRect(targets, viewportW, viewportH, visibleWorldW) {
412
417
  };
413
418
  }
414
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
+ }
415
435
  function clamp01(x) {
416
436
  return x < 0 ? 0 : x > 1 ? 1 : x;
417
437
  }
@@ -523,6 +543,7 @@ function GlyphPoints(props) {
523
543
  keyframes,
524
544
  count,
525
545
  colors,
546
+ style,
526
547
  cameraZ,
527
548
  cameraFov,
528
549
  pointer: pointerEnabled,
@@ -606,6 +627,9 @@ function GlyphPoints(props) {
606
627
  uPointer: { value: new THREE__namespace.Vector3(0, 0, 0) },
607
628
  uPointerActive: { value: 0 },
608
629
  uSize: { value: 1 },
630
+ uSizeScale: { value: style.size },
631
+ uDrift: { value: style.drift },
632
+ uSparkle: { value: style.sparkle },
609
633
  uPixelRatio: { value: 1 },
610
634
  uColorInk: { value: colors.ink.clone() },
611
635
  uColorAccent: { value: colors.accent.clone() }
@@ -803,9 +827,19 @@ function GlyphPoints(props) {
803
827
  const mat = matRef.current;
804
828
  if (!mat) return;
805
829
  const u = mat.uniforms;
806
- u.uPixelRatio.value = Math.min(window.devicePixelRatio || 1, 2);
830
+ u.uPixelRatio.value = Math.min(window.devicePixelRatio || 1, 3);
807
831
  u.uSize.value = Math.min(size.height / 18, 26);
808
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]);
809
843
  fiber.useFrame((state, delta) => {
810
844
  const p = pointsRef.current;
811
845
  const mat = matRef.current;
@@ -813,7 +847,7 @@ function GlyphPoints(props) {
813
847
  const u = mat.uniforms;
814
848
  const d = Math.min(delta, 0.05);
815
849
  const raw = THREE__namespace.MathUtils.clamp(getProgress(), 0, 1);
816
- stage.current = THREE__namespace.MathUtils.lerp(stage.current, raw, 0.1);
850
+ stage.current = raw;
817
851
  const s = stage.current;
818
852
  let settle = 0;
819
853
  let burst = 0;
@@ -830,6 +864,11 @@ function GlyphPoints(props) {
830
864
  if (lastIsText && n >= 2) {
831
865
  form = smooth(times[n - 2] ?? 0, times[n - 1] ?? 1, s);
832
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
+ }
833
872
  const guard = THREE__namespace.MathUtils.clamp(Math.max(settle, form), 0, 1);
834
873
  guardRef.current = guard;
835
874
  const swapped = raw >= timeline.swapAt ? 1 : 0;
@@ -880,7 +919,7 @@ function GlyphPoints(props) {
880
919
  uniforms,
881
920
  transparent: true,
882
921
  depthWrite: false,
883
- blending: THREE__namespace.NormalBlending,
922
+ blending: style.blend === "additive" ? THREE__namespace.AdditiveBlending : THREE__namespace.NormalBlending,
884
923
  vertexShader,
885
924
  fragmentShader: FRAGMENT_SHADER
886
925
  }
@@ -894,6 +933,12 @@ var DEFAULT_COUNT_MOBILE = 5200;
894
933
  var DEFAULT_CAMERA_Z = 7;
895
934
  var DEFAULT_CAMERA_FOV = 42;
896
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
+ };
897
942
  function clamp012(x) {
898
943
  return x < 0 ? 0 : x > 1 ? 1 : x;
899
944
  }
@@ -912,6 +957,8 @@ function GlyphDust(props) {
912
957
  const {
913
958
  keyframes,
914
959
  driver = { type: "scroll" },
960
+ preset = "default",
961
+ style,
915
962
  colors,
916
963
  count,
917
964
  dpr = DEFAULT_DPR,
@@ -935,15 +982,62 @@ function GlyphDust(props) {
935
982
  const resolveRef = react.useRef(null);
936
983
  const manualRef = react.useRef(0);
937
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]);
938
1014
  const getProgress = react.useCallback(() => {
939
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
+ }
940
1025
  const el = wrapperRef.current;
941
1026
  if (el === null || typeof window === "undefined") return 0;
942
1027
  const rect = el.getBoundingClientRect();
943
1028
  const total = rect.height - window.innerHeight;
944
1029
  if (total <= 0) return 0;
945
1030
  return clamp012(-rect.top / total);
946
- }, [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]);
947
1041
  const resolvedColors = react.useMemo(
948
1042
  () => ({
949
1043
  ink: new THREE__namespace.Color(colors?.ink ?? DEFAULT_INK),
@@ -980,6 +1074,7 @@ function GlyphDust(props) {
980
1074
  keyframes,
981
1075
  count: particleCount,
982
1076
  colors: resolvedColors,
1077
+ style: resolvedStyle,
983
1078
  cameraZ,
984
1079
  cameraFov,
985
1080
  pointer: pointerEnabled,
@@ -1013,10 +1108,11 @@ function GlyphDust(props) {
1013
1108
  }
1014
1109
  ) : null
1015
1110
  ] });
1016
- if (driver.type === "manual") {
1111
+ if (driver.type === "manual" || driver.type === "autoplay") {
1017
1112
  return /* @__PURE__ */ jsxRuntime.jsx(
1018
1113
  "div",
1019
1114
  {
1115
+ ref: wrapperRef,
1020
1116
  className,
1021
1117
  style: { position: "relative", width: "100%", height: "100%" },
1022
1118
  children: scene
@@ -1048,7 +1144,7 @@ function GlyphDust(props) {
1048
1144
  }
1049
1145
 
1050
1146
  // src/index.ts
1051
- var VERSION = "0.1.0";
1147
+ var VERSION = "0.2.1";
1052
1148
 
1053
1149
  exports.DEFAULT_TRIGGER_HEIGHT = DEFAULT_TRIGGER_HEIGHT;
1054
1150
  exports.FRAGMENT_SHADER = FRAGMENT_SHADER;
@@ -1059,6 +1155,7 @@ exports.buildDenseTextTargets = buildDenseTextTargets;
1059
1155
  exports.buildGlyphFromDOM = buildGlyphFromDOM;
1060
1156
  exports.buildTextTargets = buildTextTargets;
1061
1157
  exports.buildVertexShader = buildVertexShader;
1158
+ exports.computeAutoplayProgress = computeAutoplayProgress;
1062
1159
  exports.computeScreenRect = computeScreenRect;
1063
1160
  exports.createScrollProgress = createScrollProgress;
1064
1161
  exports.glyphPositionAttribute = glyphPositionAttribute;