particle-network-bg 1.0.0 → 1.0.2

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 CHANGED
@@ -89,6 +89,8 @@ function App() {
89
89
  ```
90
90
 
91
91
  > **Note:** Config is applied on mount. The canvas resizes to the window. When gradients are enabled, a background `<div>` is automatically created behind the canvas for smooth CSS-based gradient rendering.
92
+ >
93
+ > Pulse animations are automatically phase-randomized so particles don't pulse in sync.
92
94
 
93
95
  ## Configuration
94
96
 
@@ -160,29 +162,63 @@ new ParticleNetwork(canvas, {
160
162
  secondaryHighlightPosition: "bottom-right",
161
163
  minRadius: 20, // min size for liquid glass particles (overrides root minRadius)
162
164
  maxRadius: 40, // max size for liquid glass particles (overrides root maxRadius)
165
+ blobSpeed: 1, // blob deformation speed multiplier (0 = frozen, 2 = double speed)
163
166
  },
164
167
  });
165
168
  ```
166
169
 
167
170
  ## Particle Types
168
171
 
169
- Use `particleTypes` to mix circle, asset, and liquid glass particles with full control:
172
+ Use `particleTypes` to mix circle, asset, and liquid glass particles with full control. Each entry can carry **per-type overrides** that shadow the global config:
170
173
 
171
174
  ```js
172
175
  new ParticleNetwork(canvas, {
176
+ particleCount: 100,
177
+ particleColor: "#000000",
178
+ particleOpacity: 1,
179
+ pulseEnabled: false,
173
180
  particleTypes: [
174
- { type: "circle", percentage: 50 },
175
- { type: "liquidGlass", percentage: 30 },
176
- { type: "asset", asset: "star", count: 20, liquidGlass: true },
181
+ // 50% circles override color and enable pulse just for these
182
+ { type: "circle", percentage: 50, color: "#ff6600", pulse: true, pulseSpeed: 0.03 },
183
+ // 30% liquid glass override glass config just for these
184
+ { type: "liquidGlass", percentage: 30, liquidGlass: { color: "#00ccff", opacity: 0.8 } },
185
+ // 20 asset particles with custom opacity
186
+ { type: "asset", asset: "star", count: 20, opacity: 0.7 },
177
187
  ],
178
188
  assets: { star: "https://..." },
179
- liquidGlass: { color: "#88ccff", blur: 12, contrast: 25, ... },
189
+ liquidGlass: { color: "#88ccff", blur: 12, contrast: 25 },
180
190
  });
181
191
  ```
182
192
 
183
- - **`circle`**: Normal circles
184
- - **`liquidGlass`**: Liquid glass blobs (merge when close)
185
- - **`asset`**: Icons/images; use `liquidGlass: true` to combine with glass
193
+ Per-type values are a **shallow override** on top of the root config. Omitted fields fall back to the global value, so existing usage without `particleTypes` stays unchanged.
194
+
195
+ ### Circle type overrides
196
+
197
+ | Field | Type | Description |
198
+ | ------------ | ------- | -------------------------------------------- |
199
+ | `color` | string | Particle color (hex). Overrides `particleColor` |
200
+ | `opacity` | number | Particle opacity (0–1). Overrides `particleOpacity` |
201
+ | `minRadius` | number | Min radius (px). Overrides root `minRadius` |
202
+ | `maxRadius` | number | Max radius (px). Overrides root `maxRadius` |
203
+ | `pulse` | boolean | Enable pulse. Overrides `pulseEnabled` |
204
+ | `pulseSpeed` | number | Pulse speed. Overrides root `pulseSpeed` |
205
+
206
+ ### Liquid glass type overrides
207
+
208
+ | Field | Type | Description |
209
+ | ------------ | ------------------------- | -------------------------------------------------------------- |
210
+ | `liquidGlass`| `Partial<LiquidGlassConfig>` | Shallow-merged over root `liquidGlass` (color, opacity, minRadius, etc.) |
211
+ | `pulse` | boolean | Enable pulse. Overrides `pulseEnabled` |
212
+ | `pulseSpeed` | number | Pulse speed. Overrides root `pulseSpeed` |
213
+
214
+ ### Asset type overrides
215
+
216
+ | Field | Type | Description |
217
+ | ------------ | ---------------------------------------- | ----------------------------------------------------------------- |
218
+ | `liquidGlass`| `boolean \| Partial<LiquidGlassConfig>` | `true` for glass rendering, or partial config for per-type glass |
219
+ | `opacity` | number | Particle opacity (0–1). Overrides `particleOpacity` |
220
+ | `pulse` | boolean | Enable pulse. Overrides `pulseEnabled` |
221
+ | `pulseSpeed` | number | Pulse speed. Overrides root `pulseSpeed` |
186
222
 
187
223
  ## Connection Rules
188
224
 
@@ -311,7 +347,7 @@ function App() {
311
347
  </ChildParticle>
312
348
 
313
349
  {/* Liquid glass blob particle */}
314
- <GlassChildParticle id="clock" x={600} y={400} radius={60}>
350
+ <GlassChildParticle id="clock" x={600} y={400} radius={60} glassColor="#ff6600" glassOpacity={0.8}>
315
351
  <span>🕐</span>
316
352
  </GlassChildParticle>
317
353
 
@@ -338,6 +374,9 @@ function App() {
338
374
  | `overflow` | string | `"hidden"` | CSS overflow for the child content container |
339
375
  | `anchorForce` | number | `0.05` | Spring force pulling back to anchor (0–1). Lower = more floaty |
340
376
  | `mouseInfluence` | number | `0.1` | Mouse influence multiplier (0–1). 0 = ignores mouse |
377
+ | `glassOpacity` | number | — | Opacity (0–1) for liquid glass. Overrides global `liquidGlass.opacity` |
378
+ | `glassColor` | string | — | Color (hex) for liquid glass. Overrides global `liquidGlass.color` |
379
+ | `liquidGlassConfig` | `Partial<LiquidGlassConfig>` | — | Full liquid glass config override, merged over global `liquidGlass` |
341
380
  | `children` | ReactNode | — | Content to render inside the particle |
342
381
  | `style` | CSSProperties | — | Style applied to the inner wrapper div |
343
382
  | `className` | string | — | Class applied to the inner wrapper div |
@@ -358,7 +397,9 @@ network.addChildParticle({
358
397
  radius: 50,
359
398
  anchorForce: 0.05,
360
399
  mouseInfluence: 0.1,
361
- liquidGlass: false,
400
+ liquidGlass: true,
401
+ glassOpacity: 0.7, // override global liquidGlass.opacity
402
+ glassColor: "#ff6600", // override global liquidGlass.color
362
403
  });
363
404
 
364
405
  // Update its anchor (e.g. on scroll/resize)
@@ -403,6 +444,9 @@ interface ChildParticleConfig {
403
444
  anchorForce?: number;
404
445
  mouseInfluence?: number;
405
446
  liquidGlass?: boolean;
447
+ glassOpacity?: number; // overrides global liquidGlass.opacity
448
+ glassColor?: string; // overrides global liquidGlass.color
449
+ liquidGlassConfig?: Partial<LiquidGlassConfig>; // merged over global liquidGlass
406
450
  }
407
451
 
408
452
  interface ChildParticlePosition {
@@ -445,6 +489,9 @@ import type {
445
489
  LiquidGlassConfig,
446
490
  LiquidGlassHighlightPosition,
447
491
  ParticleTypeEntry,
492
+ CircleTypeEntry,
493
+ LiquidGlassTypeEntry,
494
+ AssetTypeEntry,
448
495
  ParticleAssetConfig,
449
496
  ChildParticleConfig,
450
497
  ChildParticlePosition,
@@ -43,6 +43,7 @@ var DEFAULT_LIQUID_GLASS = {
43
43
  shadowStrength: 0.4,
44
44
  secondaryReflection: 0.25,
45
45
  secondaryHighlightPosition: "bottom-right",
46
+ blobSpeed: 1,
46
47
  minRadius: 20,
47
48
  maxRadius: 40
48
49
  };
@@ -158,7 +159,7 @@ var ParticleNetwork = class {
158
159
  const h = isRect ? particle.height : (particle.currentRadius ?? particle.radius) * 2;
159
160
  el.style.width = w + "px";
160
161
  el.style.height = h + "px";
161
- el.style.transform = `translate(${px - w / 2}px, ${py - h / 2}px)`;
162
+ el.style.transform = `translate3d(${Math.round(px - w / 2)}px, ${Math.round(py - h / 2)}px, 0)`;
162
163
  if (isRect) {
163
164
  const br = particle.borderRadius ?? Math.min(w, h) / 2;
164
165
  el.style.borderRadius = br + "px";
@@ -314,7 +315,14 @@ var ParticleNetwork = class {
314
315
  if (!Array.isArray(config.particleTypes)) {
315
316
  throw new Error("particleTypes must be an array");
316
317
  }
317
- if (!config.assets || typeof config.assets !== "object") {
318
+ let hasAssetEntries = false;
319
+ for (const entry of config.particleTypes) {
320
+ if (entry?.type === "asset") {
321
+ hasAssetEntries = true;
322
+ break;
323
+ }
324
+ }
325
+ if (hasAssetEntries && (!config.assets || typeof config.assets !== "object")) {
318
326
  throw new Error("assets map is required when using particleTypes with asset entries");
319
327
  }
320
328
  for (let i = 0; i < config.particleTypes.length; i++) {
@@ -334,8 +342,9 @@ var ParticleNetwork = class {
334
342
  if (typeof pt.asset !== "string" || !pt.asset) {
335
343
  throw new Error(`particleTypes[${i}].asset must be a non-empty string`);
336
344
  }
337
- if (!config.assets[pt.asset]) {
338
- throw new Error(`particleTypes[${i}]: asset "${pt.asset}" not found in assets`);
345
+ const assetKey = pt.asset;
346
+ if (!config.assets?.[assetKey]) {
347
+ throw new Error(`particleTypes[${i}]: asset "${assetKey}" not found in assets`);
339
348
  }
340
349
  }
341
350
  }
@@ -432,6 +441,8 @@ var ParticleNetwork = class {
432
441
  this.particles.forEach((p) => {
433
442
  delete p.assetId;
434
443
  delete p.liquidGlass;
444
+ delete p.typeConfig;
445
+ delete p.pulsePhase;
435
446
  });
436
447
  if (particleTypes?.length) {
437
448
  const counts = [];
@@ -444,15 +455,16 @@ var ParticleNetwork = class {
444
455
  }
445
456
  if (count > 0) {
446
457
  if (pt.type === "circle") {
447
- counts.push({ liquidGlass: false, count });
458
+ counts.push({ entry: pt, liquidGlass: false, count });
448
459
  } else if (pt.type === "asset") {
449
460
  counts.push({
461
+ entry: pt,
450
462
  assetId: pt.asset,
451
- liquidGlass: pt.liquidGlass ?? false,
463
+ liquidGlass: !!pt.liquidGlass,
452
464
  count
453
465
  });
454
466
  } else {
455
- counts.push({ liquidGlass: true, count });
467
+ counts.push({ entry: pt, liquidGlass: true, count });
456
468
  }
457
469
  }
458
470
  }
@@ -463,9 +475,10 @@ var ParticleNetwork = class {
463
475
  [indices[i], indices[j]] = [indices[j], indices[i]];
464
476
  }
465
477
  let idx = 0;
466
- for (const { assetId, liquidGlass, count } of counts) {
478
+ for (const { entry, assetId, liquidGlass, count } of counts) {
467
479
  for (let c = 0; c < count && idx < indices.length; c++, idx++) {
468
480
  const p = this.particles[indices[idx]];
481
+ p.typeConfig = entry;
469
482
  if (assetId) p.assetId = assetId;
470
483
  p.liquidGlass = liquidGlass;
471
484
  if (liquidGlass) this.resizeAsLiquidGlass(p);
@@ -498,6 +511,14 @@ var ParticleNetwork = class {
498
511
  if (!particleTypes?.length && (this.config.liquidGlassPercentage != null || this.config.liquidGlassCount != null)) {
499
512
  this.assignLiquidGlass();
500
513
  }
514
+ this.particles.forEach((p) => {
515
+ if (p.isChild) return;
516
+ const tc = p.typeConfig;
517
+ const doPulse = tc?.pulse ?? this.config.pulseEnabled;
518
+ if (doPulse) {
519
+ p.pulsePhase = Math.random() * Math.PI * 2;
520
+ }
521
+ });
501
522
  this.assignMouseBehavior();
502
523
  }
503
524
  assignLiquidGlass() {
@@ -524,8 +545,10 @@ var ParticleNetwork = class {
524
545
  }
525
546
  resizeAsLiquidGlass(p) {
526
547
  const lg = this.getLiquidGlassConfig();
527
- const min = lg.minRadius ?? DEFAULT_LIQUID_GLASS.minRadius;
528
- const max = lg.maxRadius ?? DEFAULT_LIQUID_GLASS.maxRadius;
548
+ const tc = p.typeConfig;
549
+ const perTypeLg = tc && "liquidGlass" in tc && typeof tc.liquidGlass === "object" ? tc.liquidGlass : void 0;
550
+ const min = perTypeLg?.minRadius ?? lg.minRadius ?? DEFAULT_LIQUID_GLASS.minRadius;
551
+ const max = perTypeLg?.maxRadius ?? lg.maxRadius ?? DEFAULT_LIQUID_GLASS.maxRadius;
529
552
  p.radius = Math.random() * (max - min) + min;
530
553
  delete p.currentRadius;
531
554
  this.initBlob(p);
@@ -596,9 +619,13 @@ var ParticleNetwork = class {
596
619
  if (particle.isChild) {
597
620
  particle.currentRadius = particle.radius;
598
621
  } else {
599
- if (this.config.pulseEnabled) {
600
- this.pulseAngle += this.config.pulseSpeed;
601
- const pulseScale = Math.sin(this.pulseAngle) * 0.5 + 1;
622
+ const tc = particle.typeConfig;
623
+ const doPulse = tc?.pulse ?? this.config.pulseEnabled;
624
+ if (doPulse) {
625
+ const speed = tc?.pulseSpeed ?? this.config.pulseSpeed;
626
+ this.pulseAngle += speed;
627
+ const phase = particle.pulsePhase ?? 0;
628
+ const pulseScale = Math.sin(this.pulseAngle + phase) * 0.5 + 1;
602
629
  particle.currentRadius = particle.radius * pulseScale;
603
630
  } else {
604
631
  particle.currentRadius = particle.radius;
@@ -798,7 +825,22 @@ var ParticleNetwork = class {
798
825
  blob.mouseStretchY *= 1 - smoothing;
799
826
  }
800
827
  draw3DFluidSphere(particle) {
801
- const lg = this.getLiquidGlassConfig();
828
+ const tc = particle.typeConfig;
829
+ let perTypeLg = tc && "liquidGlass" in tc && typeof tc.liquidGlass === "object" ? tc.liquidGlass : void 0;
830
+ if (particle.isChild && particle.childId) {
831
+ const childConfig = this.childParticleConfigs.get(particle.childId);
832
+ if (childConfig) {
833
+ const overrides = {
834
+ ...childConfig.liquidGlassConfig ?? {},
835
+ ...childConfig.glassOpacity != null && { opacity: childConfig.glassOpacity },
836
+ ...childConfig.glassColor != null && { color: childConfig.glassColor }
837
+ };
838
+ if (Object.keys(overrides).length > 0) {
839
+ perTypeLg = { ...perTypeLg ?? {}, ...overrides };
840
+ }
841
+ }
842
+ }
843
+ const lg = perTypeLg ? { ...this.getLiquidGlassConfig(), ...perTypeLg } : this.getLiquidGlassConfig();
802
844
  const r = particle.currentRadius ?? particle.radius;
803
845
  if (r <= 0) return;
804
846
  if (!particle.blob) this.initBlob(particle);
@@ -806,46 +848,60 @@ var ParticleNetwork = class {
806
848
  const cx = particle.x;
807
849
  const cy = particle.y;
808
850
  const isRect = particle.width != null && particle.height != null;
809
- let opacity = (lg.opacity ?? DEFAULT_LIQUID_GLASS.opacity) * this.config.particleOpacity;
810
- if (this.config.depthEffectEnabled) {
811
- opacity *= 0.6 + 0.4 * particle.z;
851
+ const hasExplicitChildOpacity = particle.isChild && particle.childId && (() => {
852
+ const cfg = this.childParticleConfigs.get(particle.childId);
853
+ return cfg?.glassOpacity != null || cfg?.liquidGlassConfig?.opacity != null;
854
+ })();
855
+ let opacity = lg.opacity ?? DEFAULT_LIQUID_GLASS.opacity;
856
+ if (!hasExplicitChildOpacity) {
857
+ opacity *= this.config.particleOpacity;
858
+ if (this.config.depthEffectEnabled) {
859
+ opacity *= 0.6 + 0.4 * particle.z;
860
+ }
812
861
  }
813
862
  if (opacity <= 0) return;
814
- blob.rotation += blob.rotSpeed;
863
+ const blobSpeed = lg.blobSpeed ?? 1;
864
+ blob.rotation += blob.rotSpeed * blobSpeed;
815
865
  for (let m = 0; m < blob.phases.length; m++) {
816
- blob.phases[m] += blob.phaseSpeeds[m];
866
+ blob.phases[m] += blob.phaseSpeeds[m] * blobSpeed;
817
867
  }
818
- blob.hlAngle += blob.hlAngleSpeed;
868
+ blob.hlAngle += blob.hlAngleSpeed * blobSpeed;
819
869
  this.updateBlobMouse(particle);
820
870
  const color = lg.color ?? DEFAULT_LIQUID_GLASS.color;
821
871
  const baseR = parseInt(color.slice(1, 3), 16);
822
872
  const baseG = parseInt(color.slice(3, 5), 16);
823
873
  const baseB = parseInt(color.slice(5, 7), 16);
824
874
  const shadowStr = lg.shadowStrength ?? DEFAULT_LIQUID_GLASS.shadowStrength;
875
+ const refl = (lg.reflectionStrength ?? DEFAULT_LIQUID_GLASS.reflectionStrength) / DEFAULT_LIQUID_GLASS.reflectionStrength;
825
876
  const gradR = isRect ? Math.max(particle.width, particle.height) / 2 : r;
826
877
  const hlDist = gradR * 0.35;
827
878
  const hlX = cx + Math.cos(blob.hlAngle) * hlDist;
828
879
  const hlY = cy + Math.sin(blob.hlAngle) * hlDist;
829
- const lr = Math.min(255, baseR + Math.round((255 - baseR) * 0.55));
830
- const lgr = Math.min(255, baseG + Math.round((255 - baseG) * 0.55));
831
- const lb = Math.min(255, baseB + Math.round((255 - baseB) * 0.55));
880
+ const lr = Math.min(255, baseR + Math.round((255 - baseR) * 0.55 * refl));
881
+ const lgr = Math.min(255, baseG + Math.round((255 - baseG) * 0.55 * refl));
882
+ const lb = Math.min(255, baseB + Math.round((255 - baseB) * 0.55 * refl));
832
883
  const dr = Math.max(0, Math.round(baseR * (1 - shadowStr * 0.35)));
833
884
  const dg = Math.max(0, Math.round(baseG * (1 - shadowStr * 0.35)));
834
885
  const db = Math.max(0, Math.round(baseB * (1 - shadowStr * 0.35)));
835
886
  this.ctx.save();
836
- const grad = this.ctx.createRadialGradient(
837
- hlX,
838
- hlY,
839
- gradR * 0.05,
840
- cx,
841
- cy,
842
- gradR * 1.05
843
- );
844
- grad.addColorStop(0, `rgba(${lr},${lgr},${lb}, ${opacity * 0.95})`);
845
- grad.addColorStop(0.4, `rgba(${baseR},${baseG},${baseB}, ${opacity * 0.8})`);
846
- grad.addColorStop(0.8, `rgba(${dr},${dg},${db}, ${opacity * 0.6})`);
847
- grad.addColorStop(1, `rgba(${dr},${dg},${db}, ${opacity * 0.25})`);
848
- this.ctx.fillStyle = grad;
887
+ this.ctx.globalAlpha = 1;
888
+ if (hasExplicitChildOpacity && opacity >= 0.99) {
889
+ this.ctx.fillStyle = `rgba(${baseR},${baseG},${baseB}, ${opacity})`;
890
+ } else {
891
+ const grad = this.ctx.createRadialGradient(
892
+ hlX,
893
+ hlY,
894
+ gradR * 0.05,
895
+ cx,
896
+ cy,
897
+ gradR * 1.05
898
+ );
899
+ grad.addColorStop(0, `rgba(${lr},${lgr},${lb}, ${opacity * 0.95})`);
900
+ grad.addColorStop(0.4, `rgba(${baseR},${baseG},${baseB}, ${opacity * 0.8})`);
901
+ grad.addColorStop(0.8, `rgba(${dr},${dg},${db}, ${opacity * 0.6})`);
902
+ grad.addColorStop(1, `rgba(${dr},${dg},${db}, ${opacity * 0.25})`);
903
+ this.ctx.fillStyle = grad;
904
+ }
849
905
  if (isRect) {
850
906
  const halfW = particle.width / 2;
851
907
  const halfH = particle.height / 2;
@@ -883,7 +939,9 @@ var ParticleNetwork = class {
883
939
  this.draw3DFluidSphere(particle);
884
940
  return;
885
941
  }
886
- let opacity = this.config.particleOpacity;
942
+ const tc = particle.typeConfig;
943
+ const tcOpacity = tc && "opacity" in tc ? tc.opacity : void 0;
944
+ let opacity = tcOpacity ?? this.config.particleOpacity;
887
945
  if (this.config.depthEffectEnabled) {
888
946
  opacity *= 0.6 + 0.4 * particle.z;
889
947
  }
@@ -900,8 +958,9 @@ var ParticleNetwork = class {
900
958
  this.ctx.drawImage(img, x, y, size, size);
901
959
  }
902
960
  } else if (particle.isChild && particle.width != null && particle.height != null) {
961
+ const color = (tc && "color" in tc ? tc.color : void 0) ?? defaultColor;
903
962
  this.ctx.globalAlpha = opacity;
904
- this.ctx.fillStyle = defaultColor;
963
+ this.ctx.fillStyle = color;
905
964
  const w = particle.width;
906
965
  const h = particle.height;
907
966
  const br = particle.borderRadius ?? Math.min(w, h) / 2;
@@ -911,8 +970,9 @@ var ParticleNetwork = class {
911
970
  this.ctx.roundRect(x, y, w, h, br);
912
971
  this.ctx.fill();
913
972
  } else {
973
+ const color = (tc && "color" in tc ? tc.color : void 0) ?? defaultColor;
914
974
  this.ctx.globalAlpha = opacity;
915
- this.ctx.fillStyle = defaultColor;
975
+ this.ctx.fillStyle = color;
916
976
  this.ctx.beginPath();
917
977
  this.ctx.arc(particle.x, particle.y, r, 0, Math.PI * 2);
918
978
  this.ctx.fill();
package/dist/index.d.mts CHANGED
@@ -38,23 +38,40 @@ interface LiquidGlassConfig {
38
38
  minRadius?: number;
39
39
  /** Maximum radius (px) for liquid glass particles (overrides root maxRadius). */
40
40
  maxRadius?: number;
41
+ /** Blob deformation speed multiplier (default 1). 0 = frozen, 2 = double speed. */
42
+ blobSpeed?: number;
41
43
  }
42
- /** Particle type entry for mixing circle, asset, liquidGlass. */
43
- type ParticleTypeEntry = {
44
+ interface CircleTypeEntry {
44
45
  type: "circle";
46
+ percentage?: number;
45
47
  count?: number;
48
+ color?: string;
49
+ opacity?: number;
50
+ minRadius?: number;
51
+ maxRadius?: number;
52
+ pulse?: boolean;
53
+ pulseSpeed?: number;
54
+ }
55
+ interface LiquidGlassTypeEntry {
56
+ type: "liquidGlass";
46
57
  percentage?: number;
47
- } | {
58
+ count?: number;
59
+ liquidGlass?: Partial<LiquidGlassConfig>;
60
+ pulse?: boolean;
61
+ pulseSpeed?: number;
62
+ }
63
+ interface AssetTypeEntry {
48
64
  type: "asset";
49
65
  asset: string;
50
- count?: number;
51
66
  percentage?: number;
52
- liquidGlass?: boolean;
53
- } | {
54
- type: "liquidGlass";
55
67
  count?: number;
56
- percentage?: number;
57
- };
68
+ liquidGlass?: boolean | Partial<LiquidGlassConfig>;
69
+ opacity?: number;
70
+ pulse?: boolean;
71
+ pulseSpeed?: number;
72
+ }
73
+ /** Particle type entry for mixing circle, asset, liquidGlass with optional per-type overrides. */
74
+ type ParticleTypeEntry = CircleTypeEntry | LiquidGlassTypeEntry | AssetTypeEntry;
58
75
  /** Configuration for a child particle that holds a UI component. */
59
76
  interface ChildParticleConfig {
60
77
  /** Unique identifier linking this particle to a React/DOM child. */
@@ -79,6 +96,12 @@ interface ChildParticleConfig {
79
96
  mouseInfluence?: number;
80
97
  /** Render as liquid glass particle. Default false. */
81
98
  liquidGlass?: boolean;
99
+ /** Opacity (0–1) for liquid glass child particles. Overrides global liquidGlass.opacity. */
100
+ glassOpacity?: number;
101
+ /** Color (hex) for liquid glass child particles. Overrides global liquidGlass.color. */
102
+ glassColor?: string;
103
+ /** Full liquid glass config override for child particles. Merged over global liquidGlass. */
104
+ liquidGlassConfig?: Partial<LiquidGlassConfig>;
82
105
  }
83
106
  /** Emitted position data for a child particle each frame. */
84
107
  interface ChildParticlePosition {
@@ -191,6 +214,10 @@ interface Particle {
191
214
  borderRadius?: number;
192
215
  /** CSS overflow for the child content overlay. */
193
216
  overflow?: string;
217
+ /** Per-type config entry (shallow overrides root config). */
218
+ typeConfig?: ParticleTypeEntry;
219
+ /** Random phase offset (0–2π) for pulse animation. Makes particles pulse out of sync. */
220
+ pulsePhase?: number;
194
221
  /** Smoothed x for overlay positioning. */
195
222
  smoothX?: number;
196
223
  /** Smoothed y for overlay positioning. */
@@ -276,4 +303,4 @@ declare class ParticleNetwork {
276
303
  reset(defaults: Partial<ParticleNetworkConfig>): void;
277
304
  }
278
305
 
279
- export { type ChildParticleConfig, type ChildParticlePosition, type ConnectionRules, type GradientType, type LiquidGlassConfig, type LiquidGlassHighlightPosition, type Particle, type ParticleAssetConfig, ParticleNetwork, type ParticleNetworkConfig, type ParticleTypeEntry };
306
+ export { type AssetTypeEntry, type ChildParticleConfig, type ChildParticlePosition, type CircleTypeEntry, type ConnectionRules, type GradientType, type LiquidGlassConfig, type LiquidGlassHighlightPosition, type LiquidGlassTypeEntry, type Particle, type ParticleAssetConfig, ParticleNetwork, type ParticleNetworkConfig, type ParticleTypeEntry };
package/dist/index.d.ts CHANGED
@@ -38,23 +38,40 @@ interface LiquidGlassConfig {
38
38
  minRadius?: number;
39
39
  /** Maximum radius (px) for liquid glass particles (overrides root maxRadius). */
40
40
  maxRadius?: number;
41
+ /** Blob deformation speed multiplier (default 1). 0 = frozen, 2 = double speed. */
42
+ blobSpeed?: number;
41
43
  }
42
- /** Particle type entry for mixing circle, asset, liquidGlass. */
43
- type ParticleTypeEntry = {
44
+ interface CircleTypeEntry {
44
45
  type: "circle";
46
+ percentage?: number;
45
47
  count?: number;
48
+ color?: string;
49
+ opacity?: number;
50
+ minRadius?: number;
51
+ maxRadius?: number;
52
+ pulse?: boolean;
53
+ pulseSpeed?: number;
54
+ }
55
+ interface LiquidGlassTypeEntry {
56
+ type: "liquidGlass";
46
57
  percentage?: number;
47
- } | {
58
+ count?: number;
59
+ liquidGlass?: Partial<LiquidGlassConfig>;
60
+ pulse?: boolean;
61
+ pulseSpeed?: number;
62
+ }
63
+ interface AssetTypeEntry {
48
64
  type: "asset";
49
65
  asset: string;
50
- count?: number;
51
66
  percentage?: number;
52
- liquidGlass?: boolean;
53
- } | {
54
- type: "liquidGlass";
55
67
  count?: number;
56
- percentage?: number;
57
- };
68
+ liquidGlass?: boolean | Partial<LiquidGlassConfig>;
69
+ opacity?: number;
70
+ pulse?: boolean;
71
+ pulseSpeed?: number;
72
+ }
73
+ /** Particle type entry for mixing circle, asset, liquidGlass with optional per-type overrides. */
74
+ type ParticleTypeEntry = CircleTypeEntry | LiquidGlassTypeEntry | AssetTypeEntry;
58
75
  /** Configuration for a child particle that holds a UI component. */
59
76
  interface ChildParticleConfig {
60
77
  /** Unique identifier linking this particle to a React/DOM child. */
@@ -79,6 +96,12 @@ interface ChildParticleConfig {
79
96
  mouseInfluence?: number;
80
97
  /** Render as liquid glass particle. Default false. */
81
98
  liquidGlass?: boolean;
99
+ /** Opacity (0–1) for liquid glass child particles. Overrides global liquidGlass.opacity. */
100
+ glassOpacity?: number;
101
+ /** Color (hex) for liquid glass child particles. Overrides global liquidGlass.color. */
102
+ glassColor?: string;
103
+ /** Full liquid glass config override for child particles. Merged over global liquidGlass. */
104
+ liquidGlassConfig?: Partial<LiquidGlassConfig>;
82
105
  }
83
106
  /** Emitted position data for a child particle each frame. */
84
107
  interface ChildParticlePosition {
@@ -191,6 +214,10 @@ interface Particle {
191
214
  borderRadius?: number;
192
215
  /** CSS overflow for the child content overlay. */
193
216
  overflow?: string;
217
+ /** Per-type config entry (shallow overrides root config). */
218
+ typeConfig?: ParticleTypeEntry;
219
+ /** Random phase offset (0–2π) for pulse animation. Makes particles pulse out of sync. */
220
+ pulsePhase?: number;
194
221
  /** Smoothed x for overlay positioning. */
195
222
  smoothX?: number;
196
223
  /** Smoothed y for overlay positioning. */
@@ -276,4 +303,4 @@ declare class ParticleNetwork {
276
303
  reset(defaults: Partial<ParticleNetworkConfig>): void;
277
304
  }
278
305
 
279
- export { type ChildParticleConfig, type ChildParticlePosition, type ConnectionRules, type GradientType, type LiquidGlassConfig, type LiquidGlassHighlightPosition, type Particle, type ParticleAssetConfig, ParticleNetwork, type ParticleNetworkConfig, type ParticleTypeEntry };
306
+ export { type AssetTypeEntry, type ChildParticleConfig, type ChildParticlePosition, type CircleTypeEntry, type ConnectionRules, type GradientType, type LiquidGlassConfig, type LiquidGlassHighlightPosition, type LiquidGlassTypeEntry, type Particle, type ParticleAssetConfig, ParticleNetwork, type ParticleNetworkConfig, type ParticleTypeEntry };
package/dist/index.js CHANGED
@@ -67,6 +67,7 @@ var DEFAULT_LIQUID_GLASS = {
67
67
  shadowStrength: 0.4,
68
68
  secondaryReflection: 0.25,
69
69
  secondaryHighlightPosition: "bottom-right",
70
+ blobSpeed: 1,
70
71
  minRadius: 20,
71
72
  maxRadius: 40
72
73
  };
@@ -182,7 +183,7 @@ var ParticleNetwork = class {
182
183
  const h = isRect ? particle.height : (particle.currentRadius ?? particle.radius) * 2;
183
184
  el.style.width = w + "px";
184
185
  el.style.height = h + "px";
185
- el.style.transform = `translate(${px - w / 2}px, ${py - h / 2}px)`;
186
+ el.style.transform = `translate3d(${Math.round(px - w / 2)}px, ${Math.round(py - h / 2)}px, 0)`;
186
187
  if (isRect) {
187
188
  const br = particle.borderRadius ?? Math.min(w, h) / 2;
188
189
  el.style.borderRadius = br + "px";
@@ -338,7 +339,14 @@ var ParticleNetwork = class {
338
339
  if (!Array.isArray(config.particleTypes)) {
339
340
  throw new Error("particleTypes must be an array");
340
341
  }
341
- if (!config.assets || typeof config.assets !== "object") {
342
+ let hasAssetEntries = false;
343
+ for (const entry of config.particleTypes) {
344
+ if (entry?.type === "asset") {
345
+ hasAssetEntries = true;
346
+ break;
347
+ }
348
+ }
349
+ if (hasAssetEntries && (!config.assets || typeof config.assets !== "object")) {
342
350
  throw new Error("assets map is required when using particleTypes with asset entries");
343
351
  }
344
352
  for (let i = 0; i < config.particleTypes.length; i++) {
@@ -358,8 +366,9 @@ var ParticleNetwork = class {
358
366
  if (typeof pt.asset !== "string" || !pt.asset) {
359
367
  throw new Error(`particleTypes[${i}].asset must be a non-empty string`);
360
368
  }
361
- if (!config.assets[pt.asset]) {
362
- throw new Error(`particleTypes[${i}]: asset "${pt.asset}" not found in assets`);
369
+ const assetKey = pt.asset;
370
+ if (!config.assets?.[assetKey]) {
371
+ throw new Error(`particleTypes[${i}]: asset "${assetKey}" not found in assets`);
363
372
  }
364
373
  }
365
374
  }
@@ -456,6 +465,8 @@ var ParticleNetwork = class {
456
465
  this.particles.forEach((p) => {
457
466
  delete p.assetId;
458
467
  delete p.liquidGlass;
468
+ delete p.typeConfig;
469
+ delete p.pulsePhase;
459
470
  });
460
471
  if (particleTypes?.length) {
461
472
  const counts = [];
@@ -468,15 +479,16 @@ var ParticleNetwork = class {
468
479
  }
469
480
  if (count > 0) {
470
481
  if (pt.type === "circle") {
471
- counts.push({ liquidGlass: false, count });
482
+ counts.push({ entry: pt, liquidGlass: false, count });
472
483
  } else if (pt.type === "asset") {
473
484
  counts.push({
485
+ entry: pt,
474
486
  assetId: pt.asset,
475
- liquidGlass: pt.liquidGlass ?? false,
487
+ liquidGlass: !!pt.liquidGlass,
476
488
  count
477
489
  });
478
490
  } else {
479
- counts.push({ liquidGlass: true, count });
491
+ counts.push({ entry: pt, liquidGlass: true, count });
480
492
  }
481
493
  }
482
494
  }
@@ -487,9 +499,10 @@ var ParticleNetwork = class {
487
499
  [indices[i], indices[j]] = [indices[j], indices[i]];
488
500
  }
489
501
  let idx = 0;
490
- for (const { assetId, liquidGlass, count } of counts) {
502
+ for (const { entry, assetId, liquidGlass, count } of counts) {
491
503
  for (let c = 0; c < count && idx < indices.length; c++, idx++) {
492
504
  const p = this.particles[indices[idx]];
505
+ p.typeConfig = entry;
493
506
  if (assetId) p.assetId = assetId;
494
507
  p.liquidGlass = liquidGlass;
495
508
  if (liquidGlass) this.resizeAsLiquidGlass(p);
@@ -522,6 +535,14 @@ var ParticleNetwork = class {
522
535
  if (!particleTypes?.length && (this.config.liquidGlassPercentage != null || this.config.liquidGlassCount != null)) {
523
536
  this.assignLiquidGlass();
524
537
  }
538
+ this.particles.forEach((p) => {
539
+ if (p.isChild) return;
540
+ const tc = p.typeConfig;
541
+ const doPulse = tc?.pulse ?? this.config.pulseEnabled;
542
+ if (doPulse) {
543
+ p.pulsePhase = Math.random() * Math.PI * 2;
544
+ }
545
+ });
525
546
  this.assignMouseBehavior();
526
547
  }
527
548
  assignLiquidGlass() {
@@ -548,8 +569,10 @@ var ParticleNetwork = class {
548
569
  }
549
570
  resizeAsLiquidGlass(p) {
550
571
  const lg = this.getLiquidGlassConfig();
551
- const min = lg.minRadius ?? DEFAULT_LIQUID_GLASS.minRadius;
552
- const max = lg.maxRadius ?? DEFAULT_LIQUID_GLASS.maxRadius;
572
+ const tc = p.typeConfig;
573
+ const perTypeLg = tc && "liquidGlass" in tc && typeof tc.liquidGlass === "object" ? tc.liquidGlass : void 0;
574
+ const min = perTypeLg?.minRadius ?? lg.minRadius ?? DEFAULT_LIQUID_GLASS.minRadius;
575
+ const max = perTypeLg?.maxRadius ?? lg.maxRadius ?? DEFAULT_LIQUID_GLASS.maxRadius;
553
576
  p.radius = Math.random() * (max - min) + min;
554
577
  delete p.currentRadius;
555
578
  this.initBlob(p);
@@ -620,9 +643,13 @@ var ParticleNetwork = class {
620
643
  if (particle.isChild) {
621
644
  particle.currentRadius = particle.radius;
622
645
  } else {
623
- if (this.config.pulseEnabled) {
624
- this.pulseAngle += this.config.pulseSpeed;
625
- const pulseScale = Math.sin(this.pulseAngle) * 0.5 + 1;
646
+ const tc = particle.typeConfig;
647
+ const doPulse = tc?.pulse ?? this.config.pulseEnabled;
648
+ if (doPulse) {
649
+ const speed = tc?.pulseSpeed ?? this.config.pulseSpeed;
650
+ this.pulseAngle += speed;
651
+ const phase = particle.pulsePhase ?? 0;
652
+ const pulseScale = Math.sin(this.pulseAngle + phase) * 0.5 + 1;
626
653
  particle.currentRadius = particle.radius * pulseScale;
627
654
  } else {
628
655
  particle.currentRadius = particle.radius;
@@ -822,7 +849,22 @@ var ParticleNetwork = class {
822
849
  blob.mouseStretchY *= 1 - smoothing;
823
850
  }
824
851
  draw3DFluidSphere(particle) {
825
- const lg = this.getLiquidGlassConfig();
852
+ const tc = particle.typeConfig;
853
+ let perTypeLg = tc && "liquidGlass" in tc && typeof tc.liquidGlass === "object" ? tc.liquidGlass : void 0;
854
+ if (particle.isChild && particle.childId) {
855
+ const childConfig = this.childParticleConfigs.get(particle.childId);
856
+ if (childConfig) {
857
+ const overrides = {
858
+ ...childConfig.liquidGlassConfig ?? {},
859
+ ...childConfig.glassOpacity != null && { opacity: childConfig.glassOpacity },
860
+ ...childConfig.glassColor != null && { color: childConfig.glassColor }
861
+ };
862
+ if (Object.keys(overrides).length > 0) {
863
+ perTypeLg = { ...perTypeLg ?? {}, ...overrides };
864
+ }
865
+ }
866
+ }
867
+ const lg = perTypeLg ? { ...this.getLiquidGlassConfig(), ...perTypeLg } : this.getLiquidGlassConfig();
826
868
  const r = particle.currentRadius ?? particle.radius;
827
869
  if (r <= 0) return;
828
870
  if (!particle.blob) this.initBlob(particle);
@@ -830,46 +872,60 @@ var ParticleNetwork = class {
830
872
  const cx = particle.x;
831
873
  const cy = particle.y;
832
874
  const isRect = particle.width != null && particle.height != null;
833
- let opacity = (lg.opacity ?? DEFAULT_LIQUID_GLASS.opacity) * this.config.particleOpacity;
834
- if (this.config.depthEffectEnabled) {
835
- opacity *= 0.6 + 0.4 * particle.z;
875
+ const hasExplicitChildOpacity = particle.isChild && particle.childId && (() => {
876
+ const cfg = this.childParticleConfigs.get(particle.childId);
877
+ return cfg?.glassOpacity != null || cfg?.liquidGlassConfig?.opacity != null;
878
+ })();
879
+ let opacity = lg.opacity ?? DEFAULT_LIQUID_GLASS.opacity;
880
+ if (!hasExplicitChildOpacity) {
881
+ opacity *= this.config.particleOpacity;
882
+ if (this.config.depthEffectEnabled) {
883
+ opacity *= 0.6 + 0.4 * particle.z;
884
+ }
836
885
  }
837
886
  if (opacity <= 0) return;
838
- blob.rotation += blob.rotSpeed;
887
+ const blobSpeed = lg.blobSpeed ?? 1;
888
+ blob.rotation += blob.rotSpeed * blobSpeed;
839
889
  for (let m = 0; m < blob.phases.length; m++) {
840
- blob.phases[m] += blob.phaseSpeeds[m];
890
+ blob.phases[m] += blob.phaseSpeeds[m] * blobSpeed;
841
891
  }
842
- blob.hlAngle += blob.hlAngleSpeed;
892
+ blob.hlAngle += blob.hlAngleSpeed * blobSpeed;
843
893
  this.updateBlobMouse(particle);
844
894
  const color = lg.color ?? DEFAULT_LIQUID_GLASS.color;
845
895
  const baseR = parseInt(color.slice(1, 3), 16);
846
896
  const baseG = parseInt(color.slice(3, 5), 16);
847
897
  const baseB = parseInt(color.slice(5, 7), 16);
848
898
  const shadowStr = lg.shadowStrength ?? DEFAULT_LIQUID_GLASS.shadowStrength;
899
+ const refl = (lg.reflectionStrength ?? DEFAULT_LIQUID_GLASS.reflectionStrength) / DEFAULT_LIQUID_GLASS.reflectionStrength;
849
900
  const gradR = isRect ? Math.max(particle.width, particle.height) / 2 : r;
850
901
  const hlDist = gradR * 0.35;
851
902
  const hlX = cx + Math.cos(blob.hlAngle) * hlDist;
852
903
  const hlY = cy + Math.sin(blob.hlAngle) * hlDist;
853
- const lr = Math.min(255, baseR + Math.round((255 - baseR) * 0.55));
854
- const lgr = Math.min(255, baseG + Math.round((255 - baseG) * 0.55));
855
- const lb = Math.min(255, baseB + Math.round((255 - baseB) * 0.55));
904
+ const lr = Math.min(255, baseR + Math.round((255 - baseR) * 0.55 * refl));
905
+ const lgr = Math.min(255, baseG + Math.round((255 - baseG) * 0.55 * refl));
906
+ const lb = Math.min(255, baseB + Math.round((255 - baseB) * 0.55 * refl));
856
907
  const dr = Math.max(0, Math.round(baseR * (1 - shadowStr * 0.35)));
857
908
  const dg = Math.max(0, Math.round(baseG * (1 - shadowStr * 0.35)));
858
909
  const db = Math.max(0, Math.round(baseB * (1 - shadowStr * 0.35)));
859
910
  this.ctx.save();
860
- const grad = this.ctx.createRadialGradient(
861
- hlX,
862
- hlY,
863
- gradR * 0.05,
864
- cx,
865
- cy,
866
- gradR * 1.05
867
- );
868
- grad.addColorStop(0, `rgba(${lr},${lgr},${lb}, ${opacity * 0.95})`);
869
- grad.addColorStop(0.4, `rgba(${baseR},${baseG},${baseB}, ${opacity * 0.8})`);
870
- grad.addColorStop(0.8, `rgba(${dr},${dg},${db}, ${opacity * 0.6})`);
871
- grad.addColorStop(1, `rgba(${dr},${dg},${db}, ${opacity * 0.25})`);
872
- this.ctx.fillStyle = grad;
911
+ this.ctx.globalAlpha = 1;
912
+ if (hasExplicitChildOpacity && opacity >= 0.99) {
913
+ this.ctx.fillStyle = `rgba(${baseR},${baseG},${baseB}, ${opacity})`;
914
+ } else {
915
+ const grad = this.ctx.createRadialGradient(
916
+ hlX,
917
+ hlY,
918
+ gradR * 0.05,
919
+ cx,
920
+ cy,
921
+ gradR * 1.05
922
+ );
923
+ grad.addColorStop(0, `rgba(${lr},${lgr},${lb}, ${opacity * 0.95})`);
924
+ grad.addColorStop(0.4, `rgba(${baseR},${baseG},${baseB}, ${opacity * 0.8})`);
925
+ grad.addColorStop(0.8, `rgba(${dr},${dg},${db}, ${opacity * 0.6})`);
926
+ grad.addColorStop(1, `rgba(${dr},${dg},${db}, ${opacity * 0.25})`);
927
+ this.ctx.fillStyle = grad;
928
+ }
873
929
  if (isRect) {
874
930
  const halfW = particle.width / 2;
875
931
  const halfH = particle.height / 2;
@@ -907,7 +963,9 @@ var ParticleNetwork = class {
907
963
  this.draw3DFluidSphere(particle);
908
964
  return;
909
965
  }
910
- let opacity = this.config.particleOpacity;
966
+ const tc = particle.typeConfig;
967
+ const tcOpacity = tc && "opacity" in tc ? tc.opacity : void 0;
968
+ let opacity = tcOpacity ?? this.config.particleOpacity;
911
969
  if (this.config.depthEffectEnabled) {
912
970
  opacity *= 0.6 + 0.4 * particle.z;
913
971
  }
@@ -924,8 +982,9 @@ var ParticleNetwork = class {
924
982
  this.ctx.drawImage(img, x, y, size, size);
925
983
  }
926
984
  } else if (particle.isChild && particle.width != null && particle.height != null) {
985
+ const color = (tc && "color" in tc ? tc.color : void 0) ?? defaultColor;
927
986
  this.ctx.globalAlpha = opacity;
928
- this.ctx.fillStyle = defaultColor;
987
+ this.ctx.fillStyle = color;
929
988
  const w = particle.width;
930
989
  const h = particle.height;
931
990
  const br = particle.borderRadius ?? Math.min(w, h) / 2;
@@ -935,8 +994,9 @@ var ParticleNetwork = class {
935
994
  this.ctx.roundRect(x, y, w, h, br);
936
995
  this.ctx.fill();
937
996
  } else {
997
+ const color = (tc && "color" in tc ? tc.color : void 0) ?? defaultColor;
938
998
  this.ctx.globalAlpha = opacity;
939
- this.ctx.fillStyle = defaultColor;
999
+ this.ctx.fillStyle = color;
940
1000
  this.ctx.beginPath();
941
1001
  this.ctx.arc(particle.x, particle.y, r, 0, Math.PI * 2);
942
1002
  this.ctx.fill();
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  ParticleNetwork
3
- } from "./chunk-VMVSPMPB.mjs";
3
+ } from "./chunk-JZTYQ6XI.mjs";
4
4
  export {
5
5
  ParticleNetwork
6
6
  };
package/dist/react.d.mts CHANGED
@@ -1,7 +1,9 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as react from 'react';
2
3
  import { ReactNode, CSSProperties, RefObject } from 'react';
3
- import { ParticleNetworkConfig } from './index.mjs';
4
+ import { LiquidGlassConfig, ParticleNetworkConfig, ParticleNetwork } from './index.mjs';
4
5
 
6
+ declare const ParticleNetworkContext: react.Context<ParticleNetwork | null>;
5
7
  declare function useParticleNetwork(config?: Partial<ParticleNetworkConfig>): RefObject<HTMLCanvasElement | null>;
6
8
  interface ParticleNetworkBgProps {
7
9
  config?: Partial<ParticleNetworkConfig>;
@@ -27,6 +29,12 @@ interface ChildParticleProps {
27
29
  anchorForce?: number;
28
30
  /** Mouse influence multiplier (0-1). Default 0.1. */
29
31
  mouseInfluence?: number;
32
+ /** Opacity (0-1) for liquid glass. Only applies when liquidGlass is true. */
33
+ glassOpacity?: number;
34
+ /** Color (hex) for liquid glass. Only applies when liquidGlass is true. */
35
+ glassColor?: string;
36
+ /** Full liquid glass config override. Merged over global liquidGlass. */
37
+ liquidGlassConfig?: Partial<LiquidGlassConfig>;
30
38
  children?: ReactNode;
31
39
  style?: CSSProperties;
32
40
  className?: string;
@@ -49,10 +57,16 @@ interface GlassChildParticleProps {
49
57
  anchorForce?: number;
50
58
  /** Mouse influence multiplier (0-1). Default 0.1. */
51
59
  mouseInfluence?: number;
60
+ /** Opacity (0-1) for the glass background. Default uses global liquidGlass.opacity. */
61
+ glassOpacity?: number;
62
+ /** Color (hex) for the glass background. Default uses global liquidGlass.color. */
63
+ glassColor?: string;
64
+ /** Full liquid glass config override. Merged over global liquidGlass. */
65
+ liquidGlassConfig?: Partial<LiquidGlassConfig>;
52
66
  children?: ReactNode;
53
67
  style?: CSSProperties;
54
68
  className?: string;
55
69
  }
56
70
  declare function GlassChildParticle(props: GlassChildParticleProps): react_jsx_runtime.JSX.Element;
57
71
 
58
- export { ChildParticle, type ChildParticleProps, GlassChildParticle, type GlassChildParticleProps, ParticleNetworkBg, type ParticleNetworkBgProps, useParticleNetwork };
72
+ export { ChildParticle, type ChildParticleProps, GlassChildParticle, type GlassChildParticleProps, ParticleNetworkBg, type ParticleNetworkBgProps, ParticleNetworkContext, useParticleNetwork };
package/dist/react.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as react from 'react';
2
3
  import { ReactNode, CSSProperties, RefObject } from 'react';
3
- import { ParticleNetworkConfig } from './index.js';
4
+ import { LiquidGlassConfig, ParticleNetworkConfig, ParticleNetwork } from './index.js';
4
5
 
6
+ declare const ParticleNetworkContext: react.Context<ParticleNetwork | null>;
5
7
  declare function useParticleNetwork(config?: Partial<ParticleNetworkConfig>): RefObject<HTMLCanvasElement | null>;
6
8
  interface ParticleNetworkBgProps {
7
9
  config?: Partial<ParticleNetworkConfig>;
@@ -27,6 +29,12 @@ interface ChildParticleProps {
27
29
  anchorForce?: number;
28
30
  /** Mouse influence multiplier (0-1). Default 0.1. */
29
31
  mouseInfluence?: number;
32
+ /** Opacity (0-1) for liquid glass. Only applies when liquidGlass is true. */
33
+ glassOpacity?: number;
34
+ /** Color (hex) for liquid glass. Only applies when liquidGlass is true. */
35
+ glassColor?: string;
36
+ /** Full liquid glass config override. Merged over global liquidGlass. */
37
+ liquidGlassConfig?: Partial<LiquidGlassConfig>;
30
38
  children?: ReactNode;
31
39
  style?: CSSProperties;
32
40
  className?: string;
@@ -49,10 +57,16 @@ interface GlassChildParticleProps {
49
57
  anchorForce?: number;
50
58
  /** Mouse influence multiplier (0-1). Default 0.1. */
51
59
  mouseInfluence?: number;
60
+ /** Opacity (0-1) for the glass background. Default uses global liquidGlass.opacity. */
61
+ glassOpacity?: number;
62
+ /** Color (hex) for the glass background. Default uses global liquidGlass.color. */
63
+ glassColor?: string;
64
+ /** Full liquid glass config override. Merged over global liquidGlass. */
65
+ liquidGlassConfig?: Partial<LiquidGlassConfig>;
52
66
  children?: ReactNode;
53
67
  style?: CSSProperties;
54
68
  className?: string;
55
69
  }
56
70
  declare function GlassChildParticle(props: GlassChildParticleProps): react_jsx_runtime.JSX.Element;
57
71
 
58
- export { ChildParticle, type ChildParticleProps, GlassChildParticle, type GlassChildParticleProps, ParticleNetworkBg, type ParticleNetworkBgProps, useParticleNetwork };
72
+ export { ChildParticle, type ChildParticleProps, GlassChildParticle, type GlassChildParticleProps, ParticleNetworkBg, type ParticleNetworkBgProps, ParticleNetworkContext, useParticleNetwork };
package/dist/react.js CHANGED
@@ -23,6 +23,7 @@ __export(react_exports, {
23
23
  ChildParticle: () => ChildParticle,
24
24
  GlassChildParticle: () => GlassChildParticle,
25
25
  ParticleNetworkBg: () => ParticleNetworkBg,
26
+ ParticleNetworkContext: () => ParticleNetworkContext,
26
27
  useParticleNetwork: () => useParticleNetwork
27
28
  });
28
29
  module.exports = __toCommonJS(react_exports);
@@ -74,6 +75,7 @@ var DEFAULT_LIQUID_GLASS = {
74
75
  shadowStrength: 0.4,
75
76
  secondaryReflection: 0.25,
76
77
  secondaryHighlightPosition: "bottom-right",
78
+ blobSpeed: 1,
77
79
  minRadius: 20,
78
80
  maxRadius: 40
79
81
  };
@@ -189,7 +191,7 @@ var ParticleNetwork = class {
189
191
  const h = isRect ? particle.height : (particle.currentRadius ?? particle.radius) * 2;
190
192
  el.style.width = w + "px";
191
193
  el.style.height = h + "px";
192
- el.style.transform = `translate(${px - w / 2}px, ${py - h / 2}px)`;
194
+ el.style.transform = `translate3d(${Math.round(px - w / 2)}px, ${Math.round(py - h / 2)}px, 0)`;
193
195
  if (isRect) {
194
196
  const br = particle.borderRadius ?? Math.min(w, h) / 2;
195
197
  el.style.borderRadius = br + "px";
@@ -345,7 +347,14 @@ var ParticleNetwork = class {
345
347
  if (!Array.isArray(config.particleTypes)) {
346
348
  throw new Error("particleTypes must be an array");
347
349
  }
348
- if (!config.assets || typeof config.assets !== "object") {
350
+ let hasAssetEntries = false;
351
+ for (const entry of config.particleTypes) {
352
+ if (entry?.type === "asset") {
353
+ hasAssetEntries = true;
354
+ break;
355
+ }
356
+ }
357
+ if (hasAssetEntries && (!config.assets || typeof config.assets !== "object")) {
349
358
  throw new Error("assets map is required when using particleTypes with asset entries");
350
359
  }
351
360
  for (let i = 0; i < config.particleTypes.length; i++) {
@@ -365,8 +374,9 @@ var ParticleNetwork = class {
365
374
  if (typeof pt.asset !== "string" || !pt.asset) {
366
375
  throw new Error(`particleTypes[${i}].asset must be a non-empty string`);
367
376
  }
368
- if (!config.assets[pt.asset]) {
369
- throw new Error(`particleTypes[${i}]: asset "${pt.asset}" not found in assets`);
377
+ const assetKey = pt.asset;
378
+ if (!config.assets?.[assetKey]) {
379
+ throw new Error(`particleTypes[${i}]: asset "${assetKey}" not found in assets`);
370
380
  }
371
381
  }
372
382
  }
@@ -463,6 +473,8 @@ var ParticleNetwork = class {
463
473
  this.particles.forEach((p) => {
464
474
  delete p.assetId;
465
475
  delete p.liquidGlass;
476
+ delete p.typeConfig;
477
+ delete p.pulsePhase;
466
478
  });
467
479
  if (particleTypes?.length) {
468
480
  const counts = [];
@@ -475,15 +487,16 @@ var ParticleNetwork = class {
475
487
  }
476
488
  if (count > 0) {
477
489
  if (pt.type === "circle") {
478
- counts.push({ liquidGlass: false, count });
490
+ counts.push({ entry: pt, liquidGlass: false, count });
479
491
  } else if (pt.type === "asset") {
480
492
  counts.push({
493
+ entry: pt,
481
494
  assetId: pt.asset,
482
- liquidGlass: pt.liquidGlass ?? false,
495
+ liquidGlass: !!pt.liquidGlass,
483
496
  count
484
497
  });
485
498
  } else {
486
- counts.push({ liquidGlass: true, count });
499
+ counts.push({ entry: pt, liquidGlass: true, count });
487
500
  }
488
501
  }
489
502
  }
@@ -494,9 +507,10 @@ var ParticleNetwork = class {
494
507
  [indices[i], indices[j]] = [indices[j], indices[i]];
495
508
  }
496
509
  let idx = 0;
497
- for (const { assetId, liquidGlass, count } of counts) {
510
+ for (const { entry, assetId, liquidGlass, count } of counts) {
498
511
  for (let c = 0; c < count && idx < indices.length; c++, idx++) {
499
512
  const p = this.particles[indices[idx]];
513
+ p.typeConfig = entry;
500
514
  if (assetId) p.assetId = assetId;
501
515
  p.liquidGlass = liquidGlass;
502
516
  if (liquidGlass) this.resizeAsLiquidGlass(p);
@@ -529,6 +543,14 @@ var ParticleNetwork = class {
529
543
  if (!particleTypes?.length && (this.config.liquidGlassPercentage != null || this.config.liquidGlassCount != null)) {
530
544
  this.assignLiquidGlass();
531
545
  }
546
+ this.particles.forEach((p) => {
547
+ if (p.isChild) return;
548
+ const tc = p.typeConfig;
549
+ const doPulse = tc?.pulse ?? this.config.pulseEnabled;
550
+ if (doPulse) {
551
+ p.pulsePhase = Math.random() * Math.PI * 2;
552
+ }
553
+ });
532
554
  this.assignMouseBehavior();
533
555
  }
534
556
  assignLiquidGlass() {
@@ -555,8 +577,10 @@ var ParticleNetwork = class {
555
577
  }
556
578
  resizeAsLiquidGlass(p) {
557
579
  const lg = this.getLiquidGlassConfig();
558
- const min = lg.minRadius ?? DEFAULT_LIQUID_GLASS.minRadius;
559
- const max = lg.maxRadius ?? DEFAULT_LIQUID_GLASS.maxRadius;
580
+ const tc = p.typeConfig;
581
+ const perTypeLg = tc && "liquidGlass" in tc && typeof tc.liquidGlass === "object" ? tc.liquidGlass : void 0;
582
+ const min = perTypeLg?.minRadius ?? lg.minRadius ?? DEFAULT_LIQUID_GLASS.minRadius;
583
+ const max = perTypeLg?.maxRadius ?? lg.maxRadius ?? DEFAULT_LIQUID_GLASS.maxRadius;
560
584
  p.radius = Math.random() * (max - min) + min;
561
585
  delete p.currentRadius;
562
586
  this.initBlob(p);
@@ -627,9 +651,13 @@ var ParticleNetwork = class {
627
651
  if (particle.isChild) {
628
652
  particle.currentRadius = particle.radius;
629
653
  } else {
630
- if (this.config.pulseEnabled) {
631
- this.pulseAngle += this.config.pulseSpeed;
632
- const pulseScale = Math.sin(this.pulseAngle) * 0.5 + 1;
654
+ const tc = particle.typeConfig;
655
+ const doPulse = tc?.pulse ?? this.config.pulseEnabled;
656
+ if (doPulse) {
657
+ const speed = tc?.pulseSpeed ?? this.config.pulseSpeed;
658
+ this.pulseAngle += speed;
659
+ const phase = particle.pulsePhase ?? 0;
660
+ const pulseScale = Math.sin(this.pulseAngle + phase) * 0.5 + 1;
633
661
  particle.currentRadius = particle.radius * pulseScale;
634
662
  } else {
635
663
  particle.currentRadius = particle.radius;
@@ -829,7 +857,22 @@ var ParticleNetwork = class {
829
857
  blob.mouseStretchY *= 1 - smoothing;
830
858
  }
831
859
  draw3DFluidSphere(particle) {
832
- const lg = this.getLiquidGlassConfig();
860
+ const tc = particle.typeConfig;
861
+ let perTypeLg = tc && "liquidGlass" in tc && typeof tc.liquidGlass === "object" ? tc.liquidGlass : void 0;
862
+ if (particle.isChild && particle.childId) {
863
+ const childConfig = this.childParticleConfigs.get(particle.childId);
864
+ if (childConfig) {
865
+ const overrides = {
866
+ ...childConfig.liquidGlassConfig ?? {},
867
+ ...childConfig.glassOpacity != null && { opacity: childConfig.glassOpacity },
868
+ ...childConfig.glassColor != null && { color: childConfig.glassColor }
869
+ };
870
+ if (Object.keys(overrides).length > 0) {
871
+ perTypeLg = { ...perTypeLg ?? {}, ...overrides };
872
+ }
873
+ }
874
+ }
875
+ const lg = perTypeLg ? { ...this.getLiquidGlassConfig(), ...perTypeLg } : this.getLiquidGlassConfig();
833
876
  const r = particle.currentRadius ?? particle.radius;
834
877
  if (r <= 0) return;
835
878
  if (!particle.blob) this.initBlob(particle);
@@ -837,46 +880,60 @@ var ParticleNetwork = class {
837
880
  const cx = particle.x;
838
881
  const cy = particle.y;
839
882
  const isRect = particle.width != null && particle.height != null;
840
- let opacity = (lg.opacity ?? DEFAULT_LIQUID_GLASS.opacity) * this.config.particleOpacity;
841
- if (this.config.depthEffectEnabled) {
842
- opacity *= 0.6 + 0.4 * particle.z;
883
+ const hasExplicitChildOpacity = particle.isChild && particle.childId && (() => {
884
+ const cfg = this.childParticleConfigs.get(particle.childId);
885
+ return cfg?.glassOpacity != null || cfg?.liquidGlassConfig?.opacity != null;
886
+ })();
887
+ let opacity = lg.opacity ?? DEFAULT_LIQUID_GLASS.opacity;
888
+ if (!hasExplicitChildOpacity) {
889
+ opacity *= this.config.particleOpacity;
890
+ if (this.config.depthEffectEnabled) {
891
+ opacity *= 0.6 + 0.4 * particle.z;
892
+ }
843
893
  }
844
894
  if (opacity <= 0) return;
845
- blob.rotation += blob.rotSpeed;
895
+ const blobSpeed = lg.blobSpeed ?? 1;
896
+ blob.rotation += blob.rotSpeed * blobSpeed;
846
897
  for (let m = 0; m < blob.phases.length; m++) {
847
- blob.phases[m] += blob.phaseSpeeds[m];
898
+ blob.phases[m] += blob.phaseSpeeds[m] * blobSpeed;
848
899
  }
849
- blob.hlAngle += blob.hlAngleSpeed;
900
+ blob.hlAngle += blob.hlAngleSpeed * blobSpeed;
850
901
  this.updateBlobMouse(particle);
851
902
  const color = lg.color ?? DEFAULT_LIQUID_GLASS.color;
852
903
  const baseR = parseInt(color.slice(1, 3), 16);
853
904
  const baseG = parseInt(color.slice(3, 5), 16);
854
905
  const baseB = parseInt(color.slice(5, 7), 16);
855
906
  const shadowStr = lg.shadowStrength ?? DEFAULT_LIQUID_GLASS.shadowStrength;
907
+ const refl = (lg.reflectionStrength ?? DEFAULT_LIQUID_GLASS.reflectionStrength) / DEFAULT_LIQUID_GLASS.reflectionStrength;
856
908
  const gradR = isRect ? Math.max(particle.width, particle.height) / 2 : r;
857
909
  const hlDist = gradR * 0.35;
858
910
  const hlX = cx + Math.cos(blob.hlAngle) * hlDist;
859
911
  const hlY = cy + Math.sin(blob.hlAngle) * hlDist;
860
- const lr = Math.min(255, baseR + Math.round((255 - baseR) * 0.55));
861
- const lgr = Math.min(255, baseG + Math.round((255 - baseG) * 0.55));
862
- const lb = Math.min(255, baseB + Math.round((255 - baseB) * 0.55));
912
+ const lr = Math.min(255, baseR + Math.round((255 - baseR) * 0.55 * refl));
913
+ const lgr = Math.min(255, baseG + Math.round((255 - baseG) * 0.55 * refl));
914
+ const lb = Math.min(255, baseB + Math.round((255 - baseB) * 0.55 * refl));
863
915
  const dr = Math.max(0, Math.round(baseR * (1 - shadowStr * 0.35)));
864
916
  const dg = Math.max(0, Math.round(baseG * (1 - shadowStr * 0.35)));
865
917
  const db = Math.max(0, Math.round(baseB * (1 - shadowStr * 0.35)));
866
918
  this.ctx.save();
867
- const grad = this.ctx.createRadialGradient(
868
- hlX,
869
- hlY,
870
- gradR * 0.05,
871
- cx,
872
- cy,
873
- gradR * 1.05
874
- );
875
- grad.addColorStop(0, `rgba(${lr},${lgr},${lb}, ${opacity * 0.95})`);
876
- grad.addColorStop(0.4, `rgba(${baseR},${baseG},${baseB}, ${opacity * 0.8})`);
877
- grad.addColorStop(0.8, `rgba(${dr},${dg},${db}, ${opacity * 0.6})`);
878
- grad.addColorStop(1, `rgba(${dr},${dg},${db}, ${opacity * 0.25})`);
879
- this.ctx.fillStyle = grad;
919
+ this.ctx.globalAlpha = 1;
920
+ if (hasExplicitChildOpacity && opacity >= 0.99) {
921
+ this.ctx.fillStyle = `rgba(${baseR},${baseG},${baseB}, ${opacity})`;
922
+ } else {
923
+ const grad = this.ctx.createRadialGradient(
924
+ hlX,
925
+ hlY,
926
+ gradR * 0.05,
927
+ cx,
928
+ cy,
929
+ gradR * 1.05
930
+ );
931
+ grad.addColorStop(0, `rgba(${lr},${lgr},${lb}, ${opacity * 0.95})`);
932
+ grad.addColorStop(0.4, `rgba(${baseR},${baseG},${baseB}, ${opacity * 0.8})`);
933
+ grad.addColorStop(0.8, `rgba(${dr},${dg},${db}, ${opacity * 0.6})`);
934
+ grad.addColorStop(1, `rgba(${dr},${dg},${db}, ${opacity * 0.25})`);
935
+ this.ctx.fillStyle = grad;
936
+ }
880
937
  if (isRect) {
881
938
  const halfW = particle.width / 2;
882
939
  const halfH = particle.height / 2;
@@ -914,7 +971,9 @@ var ParticleNetwork = class {
914
971
  this.draw3DFluidSphere(particle);
915
972
  return;
916
973
  }
917
- let opacity = this.config.particleOpacity;
974
+ const tc = particle.typeConfig;
975
+ const tcOpacity = tc && "opacity" in tc ? tc.opacity : void 0;
976
+ let opacity = tcOpacity ?? this.config.particleOpacity;
918
977
  if (this.config.depthEffectEnabled) {
919
978
  opacity *= 0.6 + 0.4 * particle.z;
920
979
  }
@@ -931,8 +990,9 @@ var ParticleNetwork = class {
931
990
  this.ctx.drawImage(img, x, y, size, size);
932
991
  }
933
992
  } else if (particle.isChild && particle.width != null && particle.height != null) {
993
+ const color = (tc && "color" in tc ? tc.color : void 0) ?? defaultColor;
934
994
  this.ctx.globalAlpha = opacity;
935
- this.ctx.fillStyle = defaultColor;
995
+ this.ctx.fillStyle = color;
936
996
  const w = particle.width;
937
997
  const h = particle.height;
938
998
  const br = particle.borderRadius ?? Math.min(w, h) / 2;
@@ -942,8 +1002,9 @@ var ParticleNetwork = class {
942
1002
  this.ctx.roundRect(x, y, w, h, br);
943
1003
  this.ctx.fill();
944
1004
  } else {
1005
+ const color = (tc && "color" in tc ? tc.color : void 0) ?? defaultColor;
945
1006
  this.ctx.globalAlpha = opacity;
946
- this.ctx.fillStyle = defaultColor;
1007
+ this.ctx.fillStyle = color;
947
1008
  this.ctx.beginPath();
948
1009
  this.ctx.arc(particle.x, particle.y, r, 0, Math.PI * 2);
949
1010
  this.ctx.fill();
@@ -1294,6 +1355,9 @@ function BaseChildParticle({
1294
1355
  anchorForce,
1295
1356
  mouseInfluence,
1296
1357
  liquidGlass,
1358
+ glassOpacity,
1359
+ glassColor,
1360
+ liquidGlassConfig,
1297
1361
  children,
1298
1362
  style,
1299
1363
  className
@@ -1313,7 +1377,10 @@ function BaseChildParticle({
1313
1377
  overflow,
1314
1378
  anchorForce,
1315
1379
  mouseInfluence,
1316
- liquidGlass
1380
+ liquidGlass,
1381
+ glassOpacity,
1382
+ glassColor,
1383
+ liquidGlassConfig
1317
1384
  });
1318
1385
  setOverlayEl(instance.getChildOverlayElement(id));
1319
1386
  return () => {
@@ -1333,9 +1400,12 @@ function BaseChildParticle({
1333
1400
  overflow,
1334
1401
  anchorForce,
1335
1402
  mouseInfluence,
1336
- liquidGlass
1403
+ liquidGlass,
1404
+ glassOpacity,
1405
+ glassColor,
1406
+ liquidGlassConfig
1337
1407
  });
1338
- }, [instance, id, x, y, radius, width, height, borderRadius, overflow, anchorForce, mouseInfluence, liquidGlass]);
1408
+ }, [instance, id, x, y, radius, width, height, borderRadius, overflow, anchorForce, mouseInfluence, liquidGlass, glassOpacity, glassColor, liquidGlassConfig]);
1339
1409
  if (!overlayEl) return null;
1340
1410
  const isRect = width != null && height != null;
1341
1411
  const portalBorderRadius = isRect ? (borderRadius ?? Math.min(width, height) / 2) + "px" : "50%";
@@ -1365,5 +1435,6 @@ function BaseChildParticle({
1365
1435
  ChildParticle,
1366
1436
  GlassChildParticle,
1367
1437
  ParticleNetworkBg,
1438
+ ParticleNetworkContext,
1368
1439
  useParticleNetwork
1369
1440
  });
package/dist/react.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  ParticleNetwork
3
- } from "./chunk-VMVSPMPB.mjs";
3
+ } from "./chunk-JZTYQ6XI.mjs";
4
4
 
5
5
  // src/react.tsx
6
6
  import {
@@ -78,6 +78,9 @@ function BaseChildParticle({
78
78
  anchorForce,
79
79
  mouseInfluence,
80
80
  liquidGlass,
81
+ glassOpacity,
82
+ glassColor,
83
+ liquidGlassConfig,
81
84
  children,
82
85
  style,
83
86
  className
@@ -97,7 +100,10 @@ function BaseChildParticle({
97
100
  overflow,
98
101
  anchorForce,
99
102
  mouseInfluence,
100
- liquidGlass
103
+ liquidGlass,
104
+ glassOpacity,
105
+ glassColor,
106
+ liquidGlassConfig
101
107
  });
102
108
  setOverlayEl(instance.getChildOverlayElement(id));
103
109
  return () => {
@@ -117,9 +123,12 @@ function BaseChildParticle({
117
123
  overflow,
118
124
  anchorForce,
119
125
  mouseInfluence,
120
- liquidGlass
126
+ liquidGlass,
127
+ glassOpacity,
128
+ glassColor,
129
+ liquidGlassConfig
121
130
  });
122
- }, [instance, id, x, y, radius, width, height, borderRadius, overflow, anchorForce, mouseInfluence, liquidGlass]);
131
+ }, [instance, id, x, y, radius, width, height, borderRadius, overflow, anchorForce, mouseInfluence, liquidGlass, glassOpacity, glassColor, liquidGlassConfig]);
123
132
  if (!overlayEl) return null;
124
133
  const isRect = width != null && height != null;
125
134
  const portalBorderRadius = isRect ? (borderRadius ?? Math.min(width, height) / 2) + "px" : "50%";
@@ -148,5 +157,6 @@ export {
148
157
  ChildParticle,
149
158
  GlassChildParticle,
150
159
  ParticleNetworkBg,
160
+ ParticleNetworkContext,
151
161
  useParticleNetwork
152
162
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "particle-network-bg",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Interactive particle network animation for backgrounds",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",