fancy-ui-svelte 0.4.1 → 0.6.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.
Files changed (36) hide show
  1. package/README.md +40 -7
  2. package/dist/fancy-ui/dock/index.d.ts +1 -1
  3. package/dist/fancy-ui/dock/index.js +0 -1
  4. package/dist/fancy-ui/editorial-engine/EditorialEngine.svelte +181 -0
  5. package/dist/fancy-ui/editorial-engine/EditorialEngine.svelte.d.ts +26 -0
  6. package/dist/fancy-ui/editorial-engine/README.md +56 -0
  7. package/dist/fancy-ui/editorial-engine/engine.d.ts +7 -0
  8. package/dist/fancy-ui/editorial-engine/engine.js +581 -0
  9. package/dist/fancy-ui/editorial-engine/index.d.ts +2 -0
  10. package/dist/fancy-ui/editorial-engine/index.js +1 -0
  11. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +168 -32
  12. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +7 -0
  13. package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +36 -9
  14. package/dist/fancy-ui/fluid-cursor/index.d.ts +4 -1
  15. package/dist/fancy-ui/fluid-cursor/index.js +4 -1
  16. package/dist/fancy-ui/index.d.ts +4 -0
  17. package/dist/fancy-ui/index.js +4 -0
  18. package/dist/fancy-ui/line-reveal/LineReveal.svelte +168 -0
  19. package/dist/fancy-ui/line-reveal/LineReveal.svelte.d.ts +33 -0
  20. package/dist/fancy-ui/line-reveal/README.md +42 -0
  21. package/dist/fancy-ui/line-reveal/index.d.ts +2 -0
  22. package/dist/fancy-ui/line-reveal/index.js +1 -0
  23. package/dist/fancy-ui/noise-reveal/NoiseReveal.svelte +323 -0
  24. package/dist/fancy-ui/noise-reveal/NoiseReveal.svelte.d.ts +19 -0
  25. package/dist/fancy-ui/noise-reveal/NoiseReveal.test.js +123 -0
  26. package/dist/fancy-ui/noise-reveal/index.d.ts +2 -0
  27. package/dist/fancy-ui/noise-reveal/index.js +1 -0
  28. package/dist/fancy-ui/registry.js +1589 -9
  29. package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +1 -1
  30. package/dist/fancy-ui/tailwind.css +2 -2
  31. package/dist/types.d.ts +37 -0
  32. package/package.json +10 -2
  33. package/dist/fancy-ui/fluid-cursor/FluidCursorAdvanced.svelte +0 -1556
  34. package/dist/fancy-ui/fluid-cursor/FluidCursorAdvanced.svelte.d.ts +0 -33
  35. package/dist/fancy-ui/fluid-cursor/FluidCursorAdvanced.test.js +0 -86
  36. /package/dist/fancy-ui/{fluid-cursor/FluidCursorAdvanced.test.d.ts → noise-reveal/NoiseReveal.test.d.ts} +0 -0
@@ -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";
@@ -11,6 +11,7 @@ export * from "./border-beam/index.js";
11
11
  export * from "./glow-border/index.js";
12
12
  export * from "./marquee/index.js";
13
13
  export * from "./compare/index.js";
14
+ export * from "./dock/index.js";
14
15
  export * from "./image-trail-cursor/index.js";
15
16
  export * from "./interactive-grid-pattern/index.js";
16
17
  export * from "./interactive-hover-button/index.js";
@@ -53,4 +54,7 @@ export * from "./tracing-beam/index.js";
53
54
  export * from "./displacement-text/index.js";
54
55
  export * from "./matrix-rain/index.js";
55
56
  export * from "./terminal-text/index.js";
57
+ export * from "./noise-reveal/index.js";
58
+ export * from "./line-reveal/index.js";
59
+ export * from "./editorial-engine/index.js";
56
60
  export { registry, categories, categoryLabels, categoryDescriptions, getAllComponents, getComponent, getComponentsByStatus, getComponentsByCategory, getComponentsGroupedByCategory, getComponentsGroupedByStatus, searchComponents, getStats, hasComponent, getComponentCategory, } from "./registry.js";
@@ -16,6 +16,7 @@ export * from "./border-beam/index.js";
16
16
  export * from "./glow-border/index.js";
17
17
  export * from "./marquee/index.js";
18
18
  export * from "./compare/index.js";
19
+ export * from "./dock/index.js";
19
20
  export * from "./image-trail-cursor/index.js";
20
21
  export * from "./interactive-grid-pattern/index.js";
21
22
  export * from "./interactive-hover-button/index.js";
@@ -58,6 +59,9 @@ export * from "./tracing-beam/index.js";
58
59
  export * from "./displacement-text/index.js";
59
60
  export * from "./matrix-rain/index.js";
60
61
  export * from "./terminal-text/index.js";
62
+ export * from "./noise-reveal/index.js";
63
+ export * from "./line-reveal/index.js";
64
+ export * from "./editorial-engine/index.js";
61
65
  // =============================================================================
62
66
  // Registry
63
67
  // =============================================================================
@@ -0,0 +1,168 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * Props for LineReveal
4
+ *
5
+ * Reveals text line by line with a staggered slide-up animation.
6
+ * Line breaks are computed with @chenglou/pretext (canvas text
7
+ * measurement), so no DOM splitting or reflow measurement is needed.
8
+ */
9
+ export interface LineRevealProps {
10
+ /** Text to reveal */
11
+ text: string;
12
+ /**
13
+ * CSS font shorthand used for both measurement and rendering
14
+ * (canvas `ctx.font` format, e.g. "600 32px Inter, sans-serif").
15
+ * Prefer named families: `system-ui` can resolve differently between
16
+ * canvas measurement and DOM rendering.
17
+ */
18
+ font?: string;
19
+ /** Line height in pixels (defaults to 1.2 × font size) */
20
+ lineHeight?: number;
21
+ /** Delay between lines in seconds */
22
+ stagger?: number;
23
+ /** Animation duration per line in seconds */
24
+ duration?: number;
25
+ /** Initial delay before the first line in seconds */
26
+ delay?: number;
27
+ /** Animate only the first time the component enters the viewport */
28
+ once?: boolean;
29
+ /** Additional CSS classes */
30
+ class?: string;
31
+ }
32
+ </script>
33
+
34
+ <script lang="ts">
35
+ import { cn } from "../../utils.js";
36
+ import { onMount } from "svelte";
37
+ import {
38
+ prepareWithSegments,
39
+ layoutWithLines,
40
+ type PreparedTextWithSegments,
41
+ } from "@chenglou/pretext";
42
+
43
+ let {
44
+ text,
45
+ // Named families only: canvas and CSS can resolve `system-ui` to
46
+ // different fonts (notably on macOS), which skews measured widths.
47
+ font = '600 32px "Helvetica Neue", Helvetica, Arial, sans-serif',
48
+ lineHeight,
49
+ stagger = 0.08,
50
+ duration = 0.7,
51
+ delay = 0,
52
+ once = true,
53
+ class: className,
54
+ }: LineRevealProps = $props();
55
+
56
+ const fontSize = $derived(Number(/(\d+(?:\.\d+)?)px/.exec(font)?.[1] ?? 32));
57
+ const resolvedLineHeight = $derived(lineHeight ?? Math.round(fontSize * 1.2));
58
+
59
+ let containerRef: HTMLDivElement;
60
+ let containerWidth = $state(0);
61
+ let revealed = $state(false);
62
+ let prepared = $state<PreparedTextWithSegments | null>(null);
63
+
64
+ // Measurement requires a canvas context, so this only runs in the browser
65
+ // ($effect never runs during SSR). Web fonts must be loaded first or the
66
+ // canvas silently measures with the fallback font.
67
+ $effect(() => {
68
+ const currentText = text;
69
+ const currentFont = font;
70
+ let cancelled = false;
71
+
72
+ document.fonts
73
+ .load(currentFont)
74
+ .catch(() => {})
75
+ .then(() => {
76
+ if (!cancelled) {
77
+ prepared = prepareWithSegments(currentText, currentFont);
78
+ }
79
+ });
80
+
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ });
85
+
86
+ // Pure arithmetic on cached measurements — cheap to re-run on every resize
87
+ // (containerWidth is tracked by Svelte's bind:clientWidth ResizeObserver).
88
+ const layoutResult = $derived(
89
+ prepared && containerWidth > 0
90
+ ? layoutWithLines(prepared, containerWidth, resolvedLineHeight)
91
+ : null
92
+ );
93
+
94
+ onMount(() => {
95
+ const observer = new IntersectionObserver(
96
+ (entries) => {
97
+ for (const entry of entries) {
98
+ if (entry.isIntersecting) {
99
+ revealed = true;
100
+ if (once) observer.disconnect();
101
+ } else if (!once) {
102
+ revealed = false;
103
+ }
104
+ }
105
+ },
106
+ { threshold: 0.1 }
107
+ );
108
+
109
+ observer.observe(containerRef);
110
+
111
+ return () => observer.disconnect();
112
+ });
113
+ </script>
114
+
115
+ <div
116
+ bind:this={containerRef}
117
+ bind:clientWidth={containerWidth}
118
+ class={cn("relative w-full", className)}
119
+ style:font
120
+ style:line-height="{resolvedLineHeight}px"
121
+ style:height={layoutResult ? `${layoutResult.height}px` : undefined}
122
+ >
123
+ <span class="sr-only">{text}</span>
124
+ {#if layoutResult}
125
+ {#each layoutResult.lines as line, i (i)}
126
+ <div class="line-mask" style:height="{resolvedLineHeight}px" aria-hidden="true">
127
+ <div
128
+ class="line"
129
+ class:revealed
130
+ style:transition-duration="{duration}s"
131
+ style:transition-delay="{delay + i * stagger}s"
132
+ >
133
+ {line.text}
134
+ </div>
135
+ </div>
136
+ {/each}
137
+ {:else}
138
+ <!-- SSR / pre-measure fallback: keeps text in the HTML and reserves space -->
139
+ <div style:visibility="hidden" aria-hidden="true">{text}</div>
140
+ {/if}
141
+ </div>
142
+
143
+ <style>
144
+ .line-mask {
145
+ overflow: hidden;
146
+ /* preserve exact spacing so rendered width matches pretext measurement */
147
+ white-space: pre;
148
+ }
149
+
150
+ .line {
151
+ opacity: 0;
152
+ transform: translateY(105%);
153
+ transition-property: transform, opacity;
154
+ transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
155
+ }
156
+
157
+ .line.revealed {
158
+ opacity: 1;
159
+ transform: translateY(0);
160
+ }
161
+
162
+ @media (prefers-reduced-motion: reduce) {
163
+ .line {
164
+ transition-duration: 0.1s !important;
165
+ transition-delay: 0s !important;
166
+ }
167
+ }
168
+ </style>
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Props for LineReveal
3
+ *
4
+ * Reveals text line by line with a staggered slide-up animation.
5
+ * Line breaks are computed with @chenglou/pretext (canvas text
6
+ * measurement), so no DOM splitting or reflow measurement is needed.
7
+ */
8
+ export interface LineRevealProps {
9
+ /** Text to reveal */
10
+ text: string;
11
+ /**
12
+ * CSS font shorthand used for both measurement and rendering
13
+ * (canvas `ctx.font` format, e.g. "600 32px Inter, sans-serif").
14
+ * Prefer named families: `system-ui` can resolve differently between
15
+ * canvas measurement and DOM rendering.
16
+ */
17
+ font?: string;
18
+ /** Line height in pixels (defaults to 1.2 × font size) */
19
+ lineHeight?: number;
20
+ /** Delay between lines in seconds */
21
+ stagger?: number;
22
+ /** Animation duration per line in seconds */
23
+ duration?: number;
24
+ /** Initial delay before the first line in seconds */
25
+ delay?: number;
26
+ /** Animate only the first time the component enters the viewport */
27
+ once?: boolean;
28
+ /** Additional CSS classes */
29
+ class?: string;
30
+ }
31
+ declare const LineReveal: import("svelte").Component<LineRevealProps, {}, "">;
32
+ type LineReveal = ReturnType<typeof LineReveal>;
33
+ export default LineReveal;
@@ -0,0 +1,42 @@
1
+ # LineReveal
2
+
3
+ Reveals text line by line with a staggered slide-up animation.
4
+
5
+ Unlike DOM-splitting approaches, line breaks are computed with
6
+ [@chenglou/pretext](https://github.com/chenglou/pretext) — canvas-accurate text
7
+ measurement in pure JS. Each computed line is rendered as its own masked
8
+ element, so the per-line stagger works without measuring or mutating the DOM,
9
+ and lines re-wrap instantly on container resize.
10
+
11
+ ## Usage
12
+
13
+ ```svelte
14
+ <script lang="ts">
15
+ import { LineReveal } from "$lib/fancy-ui/line-reveal";
16
+ </script>
17
+
18
+ <LineReveal
19
+ text="Text measurement and layout without touching the DOM."
20
+ font="700 40px Helvetica, Arial, sans-serif"
21
+ />
22
+ ```
23
+
24
+ ## Props
25
+
26
+ | Prop | Type | Default | Description |
27
+ | ------------ | --------- | -------------------------------- | ------------------------------------------------------------ |
28
+ | `text` | `string` | — (required) | Text to reveal |
29
+ | `font` | `string` | `"600 32px \"Helvetica Neue\", Helvetica, Arial, sans-serif"` | CSS font shorthand, used for both measurement and rendering |
30
+ | `lineHeight` | `number` | `1.2 × font size` | Line height in pixels |
31
+ | `stagger` | `number` | `0.08` | Delay between lines in seconds |
32
+ | `duration` | `number` | `0.7` | Animation duration per line in seconds |
33
+ | `delay` | `number` | `0` | Initial delay in seconds |
34
+ | `once` | `boolean` | `true` | Animate only on first viewport entry |
35
+ | `class` | `string` | — | Additional CSS classes |
36
+
37
+ ## Notes
38
+
39
+ - `font` must be a valid canvas `ctx.font` shorthand (weight, size in px, family).
40
+ - Measurement happens client-side only (pretext needs a canvas); during SSR the
41
+ raw text is rendered hidden so content stays in the HTML.
42
+ - Resizing the container re-wraps lines without replaying the animation.
@@ -0,0 +1,2 @@
1
+ export { default as LineReveal } from "./LineReveal.svelte";
2
+ export type { LineRevealProps } from "./LineReveal.svelte";