fancy-ui-svelte 0.4.1 → 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]/`
@@ -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;
@@ -27,6 +30,13 @@
27
30
  fluidColors?: string[];
28
31
  colorIntensity?: number;
29
32
  class?: string;
33
+ autoSplat?: boolean;
34
+ autoSplatInterval?: number;
35
+ interactive?: boolean;
36
+ pauseWhenHidden?: boolean;
37
+ splatOnMount?: boolean;
38
+ allowMultiple?: boolean;
39
+ contained?: boolean;
30
40
  }
31
41
 
32
42
  let {
@@ -48,6 +58,13 @@
48
58
  fluidColors,
49
59
  colorIntensity = 0.15,
50
60
  class: className = "",
61
+ autoSplat = false,
62
+ autoSplatInterval = 1500,
63
+ interactive = true,
64
+ pauseWhenHidden = true,
65
+ splatOnMount = false,
66
+ allowMultiple = false,
67
+ contained = true,
51
68
  }: Props = $props();
52
69
 
53
70
  function hexToRgb(hex: string): ColorRGB {
@@ -956,6 +973,28 @@
956
973
  return Math.floor(input * pixelRatio);
957
974
  }
958
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
+
959
998
  // Simulation Setup
960
999
  updateKeywords();
961
1000
  initFramebuffers();
@@ -996,7 +1035,10 @@
996
1035
  return cachedFluidColorsScaled;
997
1036
  }
998
1037
 
1038
+ let isVisible = true;
1039
+
999
1040
  function updateFrame() {
1041
+ if (!isVisible) return;
1000
1042
  const dt = calcDeltaTime();
1001
1043
  if (resizeCanvas()) initFramebuffers();
1002
1044
  updateColors(dt);
@@ -1247,6 +1289,36 @@
1247
1289
  dye.swap();
1248
1290
  }
1249
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
+
1250
1322
  function correctRadius(radius: number) {
1251
1323
  const aspectRatio = canvas.width / canvas.height;
1252
1324
  if (aspectRatio > 1) radius *= aspectRatio;
@@ -1360,37 +1432,30 @@
1360
1432
  // Event Listeners
1361
1433
  function handleMouseDown(e: MouseEvent) {
1362
1434
  const pointer = pointers[0];
1363
- const posX = scaleByPixelRatio(e.clientX);
1364
- const posY = scaleByPixelRatio(e.clientY);
1435
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
1365
1436
  updatePointerDownData(pointer, -1, posX, posY);
1366
1437
  clickSplat(pointer);
1367
1438
  }
1368
1439
 
1369
1440
  function handleFirstMouseMove(e: MouseEvent) {
1370
1441
  const pointer = pointers[0];
1371
- const posX = scaleByPixelRatio(e.clientX);
1372
- const posY = scaleByPixelRatio(e.clientY);
1442
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
1373
1443
  const color = generateColor();
1374
- updateFrame();
1375
1444
  updatePointerMoveData(pointer, posX, posY, color);
1376
1445
  document.body.removeEventListener("mousemove", handleFirstMouseMove);
1377
1446
  }
1378
1447
 
1379
1448
  function handleMouseMove(e: MouseEvent) {
1380
1449
  const pointer = pointers[0];
1381
- const posX = scaleByPixelRatio(e.clientX);
1382
- const posY = scaleByPixelRatio(e.clientY);
1383
- const color = pointer.color;
1384
- updatePointerMoveData(pointer, posX, posY, color);
1450
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
1451
+ updatePointerMoveData(pointer, posX, posY, pointer.color);
1385
1452
  }
1386
1453
 
1387
1454
  function handleFirstTouchStart(e: TouchEvent) {
1388
1455
  const touches = e.targetTouches;
1389
1456
  const pointer = pointers[0];
1390
1457
  for (let i = 0; i < touches.length; i++) {
1391
- const posX = scaleByPixelRatio(touches[i].clientX);
1392
- const posY = scaleByPixelRatio(touches[i].clientY);
1393
- updateFrame();
1458
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
1394
1459
  updatePointerDownData(pointer, touches[i].identifier, posX, posY);
1395
1460
  }
1396
1461
  document.body.removeEventListener("touchstart", handleFirstTouchStart);
@@ -1400,8 +1465,7 @@
1400
1465
  const touches = e.targetTouches;
1401
1466
  const pointer = pointers[0];
1402
1467
  for (let i = 0; i < touches.length; i++) {
1403
- const posX = scaleByPixelRatio(touches[i].clientX);
1404
- const posY = scaleByPixelRatio(touches[i].clientY);
1468
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
1405
1469
  updatePointerDownData(pointer, touches[i].identifier, posX, posY);
1406
1470
  }
1407
1471
  }
@@ -1410,36 +1474,108 @@
1410
1474
  const touches = e.targetTouches;
1411
1475
  const pointer = pointers[0];
1412
1476
  for (let i = 0; i < touches.length; i++) {
1413
- const posX = scaleByPixelRatio(touches[i].clientX);
1414
- const posY = scaleByPixelRatio(touches[i].clientY);
1477
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
1415
1478
  updatePointerMoveData(pointer, posX, posY, pointer.color);
1416
1479
  }
1417
1480
  }
1418
1481
 
1419
1482
  // Add event listeners
1420
- window.addEventListener("mousedown", handleMouseDown);
1421
- document.body.addEventListener("mousemove", handleFirstMouseMove);
1422
- window.addEventListener("mousemove", handleMouseMove);
1423
- document.body.addEventListener("touchstart", handleFirstTouchStart);
1424
- window.addEventListener("touchstart", handleTouchStart, false);
1425
- 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
+ }
1426
1508
 
1427
1509
  // Start animation
1428
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
+ }
1429
1527
 
1430
1528
  // Cleanup
1431
- return () => {
1529
+ function cleanup() {
1432
1530
  cancelAnimationFrame(animationFrameId);
1433
- window.removeEventListener("mousedown", handleMouseDown);
1434
- document.body.removeEventListener("mousemove", handleFirstMouseMove);
1435
- window.removeEventListener("mousemove", handleMouseMove);
1436
- document.body.removeEventListener("touchstart", handleFirstTouchStart);
1437
- window.removeEventListener("touchstart", handleTouchStart);
1438
- window.removeEventListener("touchmove", handleTouchMove);
1439
- };
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;
1440
1566
  });
1441
1567
  </script>
1442
1568
 
1443
- <div class={cn("pointer-events-none fixed top-0 left-0 z-50 size-full", className)}>
1444
- <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>
1445
1581
  </div>
@@ -22,6 +22,13 @@ interface Props {
22
22
  fluidColors?: string[];
23
23
  colorIntensity?: number;
24
24
  class?: string;
25
+ autoSplat?: boolean;
26
+ autoSplatInterval?: number;
27
+ interactive?: boolean;
28
+ pauseWhenHidden?: boolean;
29
+ splatOnMount?: boolean;
30
+ allowMultiple?: boolean;
31
+ contained?: boolean;
25
32
  }
26
33
  declare const FluidCursor: import("svelte").Component<Props, {}, "">;
27
34
  type FluidCursor = ReturnType<typeof FluidCursor>;
@@ -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,6 +41,31 @@ 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
+ });
41
69
  describe("color props", () => {
42
70
  it("renders without error when fluidColor is provided", () => {
43
71
  const { container } = render(FluidCursor, { props: { fluidColor: "#00ffcc" } });
@@ -50,7 +78,6 @@ describe("FluidCursor", () => {
50
78
  expect(container.querySelector("canvas")).toBeInTheDocument();
51
79
  });
52
80
  it("renders without error when fluidColor and fluidColors are both provided (fluidColor takes priority)", () => {
53
- // Both props accepted without throw — fluidColor priority is exercised at runtime
54
81
  const { container } = render(FluidCursor, {
55
82
  props: { fluidColor: "#ff0000", fluidColors: ["#00ffcc", "#7700ff"] },
56
83
  });
@@ -1,2 +1,5 @@
1
1
  export { default as FluidCursor } from "./FluidCursor.svelte";
2
- export { default as FluidCursorAdvanced } from "./FluidCursorAdvanced.svelte";
2
+ /**
3
+ * @deprecated Use `FluidCursor` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.
4
+ */
5
+ export { default as FluidCursorAdvanced } from "./FluidCursor.svelte";
@@ -1,2 +1,5 @@
1
1
  export { default as FluidCursor } from "./FluidCursor.svelte";
2
- export { default as FluidCursorAdvanced } from "./FluidCursorAdvanced.svelte";
2
+ /**
3
+ * @deprecated Use `FluidCursor` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.
4
+ */
5
+ export { default as FluidCursorAdvanced } from "./FluidCursor.svelte";