fancy-ui-svelte 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,21 +119,35 @@ Beautiful animation and UI components for **Svelte 5**.
119
119
  **Install via npm:**
120
120
 
121
121
  ```bash
122
- npm install fancy-ui
122
+ npm install fancy-ui-svelte
123
123
  ```
124
124
 
125
125
  Then import any component:
126
126
 
127
127
  ```ts
128
- import { Marquee, BorderBeam, Confetti } from 'fancy-ui';
128
+ import { Marquee, BorderBeam, Confetti } from 'fancy-ui-svelte';
129
129
  ```
130
130
 
131
- To include Tailwind classes, add this to your app's CSS:
131
+ Add the Tailwind integration to your app's CSS (e.g. `src/app.css`):
132
132
 
133
133
  ```css
134
- @import "fancy-ui/tailwind.css";
134
+ @import "tailwindcss";
135
+ @import "fancy-ui-svelte/tailwind.css";
135
136
  ```
136
137
 
138
+ This single import tells Tailwind v4 to scan the library's components so all utility classes are generated automatically. No manual `@source` path needed.
139
+
140
+ <details>
141
+ <summary>Alternative: manual @source (without the CSS import)</summary>
142
+
143
+ ```css
144
+ @import "tailwindcss";
145
+ @source "../node_modules/fancy-ui-svelte/dist";
146
+ ```
147
+
148
+ The path is relative to your CSS file location.
149
+ </details>
150
+
137
151
  **Or browse and copy a component:**
138
152
  1. Find the component you need in the [live demo](https://fancy-ui.rama.app)
139
153
  2. Copy the source from `src/lib/fancy-ui/[component-name]/`
@@ -12,27 +12,6 @@ Creates animated SVG beams that connect two elements with smooth gradients and c
12
12
  - Reversible animation direction
13
13
  - Position offsets for fine-tuning
14
14
 
15
- ## Implementation Notes
16
-
17
- ### Vue → Svelte Conversions
18
- - `ref()` → `$state()`
19
- - `computed()` → `$derived()`
20
- - `watchEffect()` → `$effect()` (via `onMount`)
21
- - Template refs → `bind:this`
22
- - Props handling with destructuring defaults
23
-
24
- ### Key Differences
25
- 1. **Element References**: Svelte uses `bind:this` instead of Vue's `ref` template attribute
26
- 2. **Lifecycle**: Setup logic moved to `onMount` with cleanup return
27
- 3. **Derived Values**: Functions returning derived values (x1, x2, y1, y2) instead of computed refs
28
- 4. **Conditional Rendering**: Used `{#if}` blocks to ensure refs are defined before rendering beams
29
-
30
- ### Implementation Details
31
- - ResizeObserver for responsive path updates
32
- - SVG path calculation using quadratic Bezier curves (Q command)
33
- - Direction detection for vertical/horizontal beams
34
- - Unique gradient IDs to prevent conflicts with multiple instances
35
-
36
15
  ## Demo
37
16
  See `/demo/animated-beam` for usage examples.
38
17
 
@@ -11,7 +11,7 @@
11
11
  interface Props {
12
12
  options?: ConfettiOptions;
13
13
  globalOptions?: ConfettiGlobalOptions;
14
- manualstart?: boolean;
14
+ manualStart?: boolean;
15
15
  class?: string;
16
16
  children?: Snippet;
17
17
  }
@@ -19,7 +19,7 @@
19
19
  let {
20
20
  options = {},
21
21
  globalOptions = {},
22
- manualstart = false,
22
+ manualStart = false,
23
23
  class: className = "",
24
24
  children,
25
25
  }: Props = $props();
@@ -39,7 +39,7 @@
39
39
  resize: true,
40
40
  });
41
41
 
42
- if (!manualstart) {
42
+ if (!manualStart) {
43
43
  fire();
44
44
  }
45
45
 
@@ -3,7 +3,7 @@ import type { GlobalOptions as ConfettiGlobalOptions, Options as ConfettiOptions
3
3
  interface Props {
4
4
  options?: ConfettiOptions;
5
5
  globalOptions?: ConfettiGlobalOptions;
6
- manualstart?: boolean;
6
+ manualStart?: boolean;
7
7
  class?: string;
8
8
  children?: Snippet;
9
9
  }
@@ -11,7 +11,7 @@ Canvas wrapper that creates a confetti instance.
11
11
  |------|------|---------|-------------|
12
12
  | `options` | `ConfettiOptions` | `{}` | Default confetti options |
13
13
  | `globalOptions` | `ConfettiGlobalOptions` | `{}` | Canvas creation options |
14
- | `manualstart` | `boolean` | `false` | Skip auto-fire on mount |
14
+ | `manualStart` | `boolean` | `false` | Skip auto-fire on mount |
15
15
  | `class` | `string` | `''` | Canvas CSS classes |
16
16
 
17
17
  ### ConfettiButton
@@ -2,6 +2,9 @@
2
2
  import { cn } from "../../utils.js";
3
3
  import { onMount } from "svelte";
4
4
 
5
+ // Module-level singleton registry
6
+ let activeInstance: (() => void) | null = null;
7
+
5
8
  interface ColorRGB {
6
9
  r: number;
7
10
  g: number;
@@ -21,9 +24,19 @@
21
24
  splatForce?: number;
22
25
  shading?: boolean;
23
26
  colorUpdateSpeed?: number;
24
- backColor?: ColorRGB;
27
+ backColor?: ColorRGB | string;
25
28
  transparent?: boolean;
29
+ fluidColor?: string;
30
+ fluidColors?: string[];
31
+ colorIntensity?: number;
26
32
  class?: string;
33
+ autoSplat?: boolean;
34
+ autoSplatInterval?: number;
35
+ interactive?: boolean;
36
+ pauseWhenHidden?: boolean;
37
+ splatOnMount?: boolean;
38
+ allowMultiple?: boolean;
39
+ contained?: boolean;
27
40
  }
28
41
 
29
42
  let {
@@ -41,9 +54,39 @@
41
54
  colorUpdateSpeed = 10,
42
55
  backColor = { r: 0.5, g: 0, b: 0 },
43
56
  transparent = true,
57
+ fluidColor,
58
+ fluidColors,
59
+ colorIntensity = 0.15,
44
60
  class: className = "",
61
+ autoSplat = false,
62
+ autoSplatInterval = 1500,
63
+ interactive = true,
64
+ pauseWhenHidden = true,
65
+ splatOnMount = false,
66
+ allowMultiple = false,
67
+ contained = true,
45
68
  }: Props = $props();
46
69
 
70
+ function hexToRgb(hex: string): ColorRGB {
71
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
72
+ if (!result) {
73
+ console.warn(
74
+ `[FluidCursor] Invalid hex color: "${hex}". Expected format: "#rrggbb". Falling back to white.`
75
+ );
76
+ return { r: 1, g: 1, b: 1 };
77
+ }
78
+ return {
79
+ r: parseInt(result[1], 16) / 255,
80
+ g: parseInt(result[2], 16) / 255,
81
+ b: parseInt(result[3], 16) / 255,
82
+ };
83
+ }
84
+
85
+ const clampedColorIntensity = Math.max(0, Math.min(1, colorIntensity));
86
+
87
+ const resolvedBackColor: ColorRGB =
88
+ typeof backColor === "string" ? hexToRgb(backColor) : backColor;
89
+
47
90
  let canvasRef: HTMLCanvasElement;
48
91
 
49
92
  onMount(() => {
@@ -94,7 +137,7 @@
94
137
  SHADING: shading,
95
138
  COLOR_UPDATE_SPEED: colorUpdateSpeed,
96
139
  PAUSED: false,
97
- BACK_COLOR: backColor,
140
+ BACK_COLOR: resolvedBackColor,
98
141
  TRANSPARENT: transparent,
99
142
  };
100
143
 
@@ -930,15 +973,72 @@
930
973
  return Math.floor(input * pixelRatio);
931
974
  }
932
975
 
976
+ let canvasRectCache: DOMRect | null = null;
977
+ function updateCanvasRectCache() {
978
+ if (contained && canvas) canvasRectCache = canvas.getBoundingClientRect();
979
+ }
980
+ if (contained) {
981
+ window.addEventListener("resize", updateCanvasRectCache, { passive: true });
982
+ window.addEventListener("scroll", updateCanvasRectCache, { passive: true });
983
+ updateCanvasRectCache();
984
+ }
985
+
986
+ function getCanvasPos(clientX: number, clientY: number): { x: number; y: number } {
987
+ if (contained) {
988
+ if (!canvasRectCache) updateCanvasRectCache();
989
+ const rect = canvasRectCache!;
990
+ return {
991
+ x: scaleByPixelRatio(clientX - rect.left),
992
+ y: scaleByPixelRatio(clientY - rect.top),
993
+ };
994
+ }
995
+ return { x: scaleByPixelRatio(clientX), y: scaleByPixelRatio(clientY) };
996
+ }
997
+
933
998
  // Simulation Setup
934
999
  updateKeywords();
935
1000
  initFramebuffers();
936
1001
 
937
1002
  let lastUpdateTime = Date.now();
938
1003
  let colorUpdateTimer = 0.0;
1004
+ let colorIndex = 0;
939
1005
  let animationFrameId: number;
940
1006
 
1007
+ // Cached pre-scaled colors to avoid re-parsing hex on every splat
1008
+ let cachedFluidColorHex: string | undefined;
1009
+ let cachedFluidColor: ColorRGB | null = null;
1010
+ let cachedFluidColorsRef: string[] | undefined;
1011
+ let cachedFluidColorsScaled: ColorRGB[] = [];
1012
+
1013
+ function getScaledColor(hex: string): ColorRGB {
1014
+ const { r, g, b } = hexToRgb(hex);
1015
+ return {
1016
+ r: r * clampedColorIntensity,
1017
+ g: g * clampedColorIntensity,
1018
+ b: b * clampedColorIntensity,
1019
+ };
1020
+ }
1021
+
1022
+ function getCachedFluidColor(hex: string): ColorRGB {
1023
+ if (cachedFluidColor === null || cachedFluidColorHex !== hex) {
1024
+ cachedFluidColorHex = hex;
1025
+ cachedFluidColor = getScaledColor(hex);
1026
+ }
1027
+ return cachedFluidColor;
1028
+ }
1029
+
1030
+ function getCachedFluidColors(colors: string[]): ColorRGB[] {
1031
+ if (cachedFluidColorsRef !== colors) {
1032
+ cachedFluidColorsRef = colors;
1033
+ cachedFluidColorsScaled = colors.map(getScaledColor);
1034
+ }
1035
+ return cachedFluidColorsScaled;
1036
+ }
1037
+
1038
+ let isVisible = true;
1039
+
941
1040
  function updateFrame() {
1041
+ if (!isVisible) return;
942
1042
  const dt = calcDeltaTime();
943
1043
  if (resizeCanvas()) initFramebuffers();
944
1044
  updateColors(dt);
@@ -1189,6 +1289,36 @@
1189
1289
  dye.swap();
1190
1290
  }
1191
1291
 
1292
+ function multipleSplats(steps: number) {
1293
+ // Simulate a cursor sweep along a random arc — each step on its own frame
1294
+ // so the velocity field has time to evolve between injections.
1295
+ const angle = Math.random() * Math.PI * 2;
1296
+ const cx = 0.25 + Math.random() * 0.5;
1297
+ const cy = 0.25 + Math.random() * 0.5;
1298
+ const arcSpan = Math.PI * (0.8 + Math.random() * 0.8);
1299
+ let i = 0;
1300
+
1301
+ function step() {
1302
+ if (i >= steps) return;
1303
+ const t = i / Math.max(1, steps - 1);
1304
+ const a = angle + t * arcSpan;
1305
+ const r = 0.15 + 0.05 * Math.sin(t * Math.PI);
1306
+ const x = cx + Math.cos(a) * r;
1307
+ const y = cy + Math.sin(a) * r;
1308
+ const color = generateColor();
1309
+ splat(
1310
+ x,
1311
+ y,
1312
+ -Math.sin(a) * config.SPLAT_FORCE * 0.5,
1313
+ Math.cos(a) * config.SPLAT_FORCE * 0.5,
1314
+ color
1315
+ );
1316
+ i++;
1317
+ if (i < steps) requestAnimationFrame(step);
1318
+ }
1319
+ requestAnimationFrame(step);
1320
+ }
1321
+
1192
1322
  function correctRadius(radius: number) {
1193
1323
  const aspectRatio = canvas.width / canvas.height;
1194
1324
  if (aspectRatio > 1) radius *= aspectRatio;
@@ -1232,10 +1362,19 @@
1232
1362
  }
1233
1363
 
1234
1364
  function generateColor(): ColorRGB {
1365
+ if (fluidColor) {
1366
+ return getCachedFluidColor(fluidColor);
1367
+ }
1368
+ if (fluidColors && fluidColors.length > 0) {
1369
+ const colors = getCachedFluidColors(fluidColors);
1370
+ const idx = colorIndex % colors.length;
1371
+ colorIndex++;
1372
+ return colors[idx];
1373
+ }
1235
1374
  const c = HSVtoRGB(Math.random(), 1.0, 1.0);
1236
- c.r *= 0.15;
1237
- c.g *= 0.15;
1238
- c.b *= 0.15;
1375
+ c.r *= clampedColorIntensity;
1376
+ c.g *= clampedColorIntensity;
1377
+ c.b *= clampedColorIntensity;
1239
1378
  return c;
1240
1379
  }
1241
1380
 
@@ -1293,37 +1432,30 @@
1293
1432
  // Event Listeners
1294
1433
  function handleMouseDown(e: MouseEvent) {
1295
1434
  const pointer = pointers[0];
1296
- const posX = scaleByPixelRatio(e.clientX);
1297
- const posY = scaleByPixelRatio(e.clientY);
1435
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
1298
1436
  updatePointerDownData(pointer, -1, posX, posY);
1299
1437
  clickSplat(pointer);
1300
1438
  }
1301
1439
 
1302
1440
  function handleFirstMouseMove(e: MouseEvent) {
1303
1441
  const pointer = pointers[0];
1304
- const posX = scaleByPixelRatio(e.clientX);
1305
- const posY = scaleByPixelRatio(e.clientY);
1442
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
1306
1443
  const color = generateColor();
1307
- updateFrame();
1308
1444
  updatePointerMoveData(pointer, posX, posY, color);
1309
1445
  document.body.removeEventListener("mousemove", handleFirstMouseMove);
1310
1446
  }
1311
1447
 
1312
1448
  function handleMouseMove(e: MouseEvent) {
1313
1449
  const pointer = pointers[0];
1314
- const posX = scaleByPixelRatio(e.clientX);
1315
- const posY = scaleByPixelRatio(e.clientY);
1316
- const color = pointer.color;
1317
- updatePointerMoveData(pointer, posX, posY, color);
1450
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
1451
+ updatePointerMoveData(pointer, posX, posY, pointer.color);
1318
1452
  }
1319
1453
 
1320
1454
  function handleFirstTouchStart(e: TouchEvent) {
1321
1455
  const touches = e.targetTouches;
1322
1456
  const pointer = pointers[0];
1323
1457
  for (let i = 0; i < touches.length; i++) {
1324
- const posX = scaleByPixelRatio(touches[i].clientX);
1325
- const posY = scaleByPixelRatio(touches[i].clientY);
1326
- updateFrame();
1458
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
1327
1459
  updatePointerDownData(pointer, touches[i].identifier, posX, posY);
1328
1460
  }
1329
1461
  document.body.removeEventListener("touchstart", handleFirstTouchStart);
@@ -1333,8 +1465,7 @@
1333
1465
  const touches = e.targetTouches;
1334
1466
  const pointer = pointers[0];
1335
1467
  for (let i = 0; i < touches.length; i++) {
1336
- const posX = scaleByPixelRatio(touches[i].clientX);
1337
- const posY = scaleByPixelRatio(touches[i].clientY);
1468
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
1338
1469
  updatePointerDownData(pointer, touches[i].identifier, posX, posY);
1339
1470
  }
1340
1471
  }
@@ -1343,36 +1474,108 @@
1343
1474
  const touches = e.targetTouches;
1344
1475
  const pointer = pointers[0];
1345
1476
  for (let i = 0; i < touches.length; i++) {
1346
- const posX = scaleByPixelRatio(touches[i].clientX);
1347
- const posY = scaleByPixelRatio(touches[i].clientY);
1477
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
1348
1478
  updatePointerMoveData(pointer, posX, posY, pointer.color);
1349
1479
  }
1350
1480
  }
1351
1481
 
1352
1482
  // Add event listeners
1353
- window.addEventListener("mousedown", handleMouseDown);
1354
- document.body.addEventListener("mousemove", handleFirstMouseMove);
1355
- window.addEventListener("mousemove", handleMouseMove);
1356
- document.body.addEventListener("touchstart", handleFirstTouchStart);
1357
- window.addEventListener("touchstart", handleTouchStart, false);
1358
- window.addEventListener("touchmove", handleTouchMove, false);
1483
+ if (interactive) {
1484
+ window.addEventListener("mousedown", handleMouseDown);
1485
+ document.body.addEventListener("mousemove", handleFirstMouseMove);
1486
+ window.addEventListener("mousemove", handleMouseMove);
1487
+ document.body.addEventListener("touchstart", handleFirstTouchStart);
1488
+ window.addEventListener("touchstart", handleTouchStart, false);
1489
+ window.addEventListener("touchmove", handleTouchMove, false);
1490
+ }
1491
+
1492
+ // Pause animation when scrolled out of view
1493
+ let observer: IntersectionObserver | null = null;
1494
+ if (pauseWhenHidden) {
1495
+ observer = new IntersectionObserver(
1496
+ ([entry]) => {
1497
+ const wasVisible = isVisible;
1498
+ isVisible = entry.isIntersecting;
1499
+ if (isVisible && !wasVisible) {
1500
+ lastUpdateTime = Date.now();
1501
+ animationFrameId = requestAnimationFrame(updateFrame);
1502
+ }
1503
+ },
1504
+ { threshold: 0 }
1505
+ );
1506
+ observer.observe(canvas);
1507
+ }
1359
1508
 
1360
1509
  // Start animation
1361
1510
  updateFrame();
1511
+ if (splatOnMount) multipleSplats(Math.floor(Math.random() * 6) + 10);
1512
+
1513
+ // Auto-splat timer
1514
+ let autoSplatTimer: ReturnType<typeof setInterval> | null = null;
1515
+ if (autoSplat) {
1516
+ autoSplatTimer = setInterval(() => {
1517
+ const color = generateColor();
1518
+ splat(
1519
+ Math.random(),
1520
+ Math.random(),
1521
+ (Math.random() - 0.5) * 200,
1522
+ (Math.random() - 0.5) * 200,
1523
+ color
1524
+ );
1525
+ }, autoSplatInterval);
1526
+ }
1362
1527
 
1363
1528
  // Cleanup
1364
- return () => {
1529
+ function cleanup() {
1365
1530
  cancelAnimationFrame(animationFrameId);
1366
- window.removeEventListener("mousedown", handleMouseDown);
1367
- document.body.removeEventListener("mousemove", handleFirstMouseMove);
1368
- window.removeEventListener("mousemove", handleMouseMove);
1369
- document.body.removeEventListener("touchstart", handleFirstTouchStart);
1370
- window.removeEventListener("touchstart", handleTouchStart);
1371
- window.removeEventListener("touchmove", handleTouchMove);
1372
- };
1531
+ if (observer) observer.disconnect();
1532
+ if (autoSplatTimer) clearInterval(autoSplatTimer);
1533
+ if (interactive) {
1534
+ window.removeEventListener("mousedown", handleMouseDown);
1535
+ document.body.removeEventListener("mousemove", handleFirstMouseMove);
1536
+ window.removeEventListener("mousemove", handleMouseMove);
1537
+ document.body.removeEventListener("touchstart", handleFirstTouchStart);
1538
+ window.removeEventListener("touchstart", handleTouchStart);
1539
+ window.removeEventListener("touchmove", handleTouchMove);
1540
+ }
1541
+ if (contained) {
1542
+ window.removeEventListener("resize", updateCanvasRectCache);
1543
+ window.removeEventListener("scroll", updateCanvasRectCache);
1544
+ }
1545
+ }
1546
+
1547
+ if (!allowMultiple) {
1548
+ if (activeInstance) {
1549
+ if (import.meta.env.DEV) {
1550
+ console.warn(
1551
+ "[FluidCursor] Destroying previous instance. Only one instance is allowed by default. Use `allowMultiple={true}` to opt out of singleton behavior."
1552
+ );
1553
+ }
1554
+ activeInstance();
1555
+ }
1556
+ activeInstance = cleanup;
1557
+ return () => {
1558
+ cleanup();
1559
+ if (activeInstance === cleanup) {
1560
+ activeInstance = null;
1561
+ }
1562
+ };
1563
+ }
1564
+
1565
+ return cleanup;
1373
1566
  });
1374
1567
  </script>
1375
1568
 
1376
- <div class={cn("pointer-events-none fixed top-0 left-0 z-50 size-full", className)}>
1377
- <canvas bind:this={canvasRef} id="fluid" class="block h-screen w-screen"></canvas>
1569
+ <div
1570
+ class={cn(
1571
+ contained
1572
+ ? "pointer-events-none absolute inset-0 h-full w-full"
1573
+ : "pointer-events-none fixed top-0 left-0 z-50 size-full",
1574
+ className
1575
+ )}
1576
+ >
1577
+ <canvas
1578
+ bind:this={canvasRef}
1579
+ class={contained ? "block h-full w-full" : "block h-screen w-screen"}
1580
+ ></canvas>
1378
1581
  </div>
@@ -16,9 +16,19 @@ interface Props {
16
16
  splatForce?: number;
17
17
  shading?: boolean;
18
18
  colorUpdateSpeed?: number;
19
- backColor?: ColorRGB;
19
+ backColor?: ColorRGB | string;
20
20
  transparent?: boolean;
21
+ fluidColor?: string;
22
+ fluidColors?: string[];
23
+ colorIntensity?: number;
21
24
  class?: string;
25
+ autoSplat?: boolean;
26
+ autoSplatInterval?: number;
27
+ interactive?: boolean;
28
+ pauseWhenHidden?: boolean;
29
+ splatOnMount?: boolean;
30
+ allowMultiple?: boolean;
31
+ contained?: boolean;
22
32
  }
23
33
  declare const FluidCursor: import("svelte").Component<Props, {}, "">;
24
34
  type FluidCursor = ReturnType<typeof FluidCursor>;
@@ -1,5 +1,5 @@
1
1
  import { render, cleanup } from "@testing-library/svelte";
2
- import { afterEach, describe, it, expect } from "vitest";
2
+ import { afterEach, describe, it, expect, vi } from "vitest";
3
3
  import FluidCursor from "./FluidCursor.svelte";
4
4
  describe("FluidCursor", () => {
5
5
  afterEach(cleanup);
@@ -13,24 +13,27 @@ describe("FluidCursor", () => {
13
13
  const canvas = container.querySelector("canvas");
14
14
  expect(canvas).toBeInTheDocument();
15
15
  });
16
- it('canvas has id="fluid"', () => {
17
- const { container } = render(FluidCursor);
18
- const canvas = container.querySelector("canvas#fluid");
19
- expect(canvas).toBeInTheDocument();
20
- });
21
16
  it("container has pointer-events-none class", () => {
22
17
  const { container } = render(FluidCursor);
23
18
  const div = container.firstElementChild;
24
19
  expect(div?.className).toContain("pointer-events-none");
25
20
  });
26
- it("container has fixed positioning class", () => {
21
+ it("defaults to contained mode (absolute positioning)", () => {
27
22
  const { container } = render(FluidCursor);
28
23
  const div = container.firstElementChild;
29
- expect(div?.className).toContain("fixed");
24
+ expect(div?.className).toContain("absolute");
25
+ expect(div?.className).toContain("inset-0");
30
26
  });
31
- it("container has z-50 class", () => {
27
+ it("canvas fills container in contained mode", () => {
32
28
  const { container } = render(FluidCursor);
29
+ const canvas = container.querySelector("canvas");
30
+ expect(canvas?.className).toContain("h-full");
31
+ expect(canvas?.className).toContain("w-full");
32
+ });
33
+ it("uses fixed positioning when contained={false}", () => {
34
+ const { container } = render(FluidCursor, { props: { contained: false } });
33
35
  const div = container.firstElementChild;
36
+ expect(div?.className).toContain("fixed");
34
37
  expect(div?.className).toContain("z-50");
35
38
  });
36
39
  it("applies custom class names", () => {
@@ -38,4 +41,61 @@ describe("FluidCursor", () => {
38
41
  const div = container.firstElementChild;
39
42
  expect(div?.className).toContain("my-fluid");
40
43
  });
44
+ describe("interaction props", () => {
45
+ it("interactive={false} does not register mouse event listeners", () => {
46
+ const addSpy = vi.spyOn(window, "addEventListener");
47
+ render(FluidCursor, { props: { interactive: false } });
48
+ const mousedownCalls = addSpy.mock.calls.filter(([event]) => event === "mousedown");
49
+ expect(mousedownCalls).toHaveLength(0);
50
+ const mousemoveCalls = addSpy.mock.calls.filter(([event]) => event === "mousemove");
51
+ expect(mousemoveCalls).toHaveLength(0);
52
+ addSpy.mockRestore();
53
+ });
54
+ it("mounts without error with autoSplat={true} and splatOnMount={true}", () => {
55
+ const { container } = render(FluidCursor, {
56
+ props: { autoSplat: true, splatOnMount: true },
57
+ });
58
+ expect(container.querySelector("canvas")).toBeInTheDocument();
59
+ });
60
+ it("pauseWhenHidden={false} does not instantiate IntersectionObserver", () => {
61
+ const observeSpy = vi.fn();
62
+ const mockObserver = vi.fn(() => ({ observe: observeSpy, disconnect: vi.fn() }));
63
+ vi.stubGlobal("IntersectionObserver", mockObserver);
64
+ render(FluidCursor, { props: { pauseWhenHidden: false } });
65
+ expect(mockObserver).not.toHaveBeenCalled();
66
+ vi.unstubAllGlobals();
67
+ });
68
+ });
69
+ describe("color props", () => {
70
+ it("renders without error when fluidColor is provided", () => {
71
+ const { container } = render(FluidCursor, { props: { fluidColor: "#00ffcc" } });
72
+ expect(container.querySelector("canvas")).toBeInTheDocument();
73
+ });
74
+ it("renders without error when fluidColors is provided", () => {
75
+ const { container } = render(FluidCursor, {
76
+ props: { fluidColors: ["#ff0080", "#00ffcc", "#7700ff"] },
77
+ });
78
+ expect(container.querySelector("canvas")).toBeInTheDocument();
79
+ });
80
+ it("renders without error when fluidColor and fluidColors are both provided (fluidColor takes priority)", () => {
81
+ const { container } = render(FluidCursor, {
82
+ props: { fluidColor: "#ff0000", fluidColors: ["#00ffcc", "#7700ff"] },
83
+ });
84
+ expect(container.querySelector("canvas")).toBeInTheDocument();
85
+ });
86
+ it("renders without error when backColor is a hex string", () => {
87
+ const { container } = render(FluidCursor, { props: { backColor: "#1a1a2e" } });
88
+ expect(container.querySelector("canvas")).toBeInTheDocument();
89
+ });
90
+ it("warns and falls back when an invalid hex string is passed to backColor", () => {
91
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
92
+ render(FluidCursor, { props: { backColor: "not-a-color" } });
93
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("[FluidCursor] Invalid hex color"));
94
+ warn.mockRestore();
95
+ });
96
+ it("renders without error when colorIntensity is out of [0,1] range (clamped internally)", () => {
97
+ const { container } = render(FluidCursor, { props: { colorIntensity: 5 } });
98
+ expect(container.querySelector("canvas")).toBeInTheDocument();
99
+ });
100
+ });
41
101
  });
@@ -9,6 +9,7 @@ A WebGL-based fluid simulation that follows the cursor, creating beautiful flowi
9
9
  - Configurable simulation parameters
10
10
  - Transparent background support
11
11
  - Automatic canvas resizing
12
+ - Controllable fluid colors via hex strings (fixed color, palette cycling, or random)
12
13
 
13
14
  ## Implementation Notes
14
15
 
@@ -27,6 +28,30 @@ The component implements a full Navier-Stokes fluid simulation with:
27
28
  - Advection for both velocity and dye
28
29
  - Gradient subtraction for incompressible flow
29
30
 
31
+ ## Color Props
32
+
33
+ | Prop | Type | Default | Description |
34
+ |---|---|---|---|
35
+ | `fluidColor` | `string` (hex) | — | Fixed fluid color. Disables random cycling. |
36
+ | `fluidColors` | `string[]` (hex) | — | Palette to cycle through on each splat. |
37
+ | `colorIntensity` | `number` | `0.15` | Intensity multiplier applied to fluid colors (0–1). |
38
+ | `backColor` | `{ r, g, b }` or `string` (hex) | `{ r: 0.5, g: 0, b: 0 }` | Background color — accepts RGB object or hex string. |
39
+
40
+ **Priority**: `fluidColor` > `fluidColors` > random HSV
41
+
42
+ ### Examples
43
+
44
+ ```svelte
45
+ <!-- Fixed teal fluid -->
46
+ <FluidCursor fluidColor="#00ffcc" />
47
+
48
+ <!-- Cycling palette -->
49
+ <FluidCursor fluidColors={["#ff0080", "#00ffcc", "#7700ff"]} colorIntensity={0.3} />
50
+
51
+ <!-- Hex background -->
52
+ <FluidCursor backColor="#1a1a2e" />
53
+ ```
54
+
30
55
  ### Event Handling
31
56
 
32
57
  - Window-level mouse/touch events for full-screen tracking
@@ -1 +1,5 @@
1
1
  export { default as FluidCursor } from "./FluidCursor.svelte";
2
+ /**
3
+ * @deprecated Use `FluidCursor` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.
4
+ */
5
+ export { default as FluidCursorAdvanced } from "./FluidCursor.svelte";
@@ -1 +1,5 @@
1
1
  export { default as FluidCursor } from "./FluidCursor.svelte";
2
+ /**
3
+ * @deprecated Use `FluidCursor` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.
4
+ */
5
+ export { default as FluidCursorAdvanced } from "./FluidCursor.svelte";