fancy-ui-svelte 0.7.0 → 0.9.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 (58) hide show
  1. package/README.md +107 -81
  2. package/dist/fancy-ui/animated-testimonials/README.md +44 -0
  3. package/dist/fancy-ui/animated-tooltip/README.md +40 -0
  4. package/dist/fancy-ui/apple-card-carousel/README.md +46 -0
  5. package/dist/fancy-ui/bento-grid/BentoGrid.test.d.ts +1 -0
  6. package/dist/fancy-ui/bento-grid/BentoGrid.test.js +109 -0
  7. package/dist/fancy-ui/border-beam/README.md +37 -0
  8. package/dist/fancy-ui/card-3d/CardContainer.test.d.ts +1 -0
  9. package/dist/fancy-ui/card-3d/CardContainer.test.js +118 -0
  10. package/dist/fancy-ui/card-spotlight/CardSpotlight.test.d.ts +1 -0
  11. package/dist/fancy-ui/card-spotlight/CardSpotlight.test.js +58 -0
  12. package/dist/fancy-ui/displacement-text/README.md +39 -0
  13. package/dist/fancy-ui/dock/README.md +1 -1
  14. package/dist/fancy-ui/editorial-engine/EditorialEngine.test.d.ts +1 -0
  15. package/dist/fancy-ui/editorial-engine/EditorialEngine.test.js +54 -0
  16. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +1250 -1108
  17. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +11 -5
  18. package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +48 -0
  19. package/dist/fancy-ui/fluid-cursor/README.md +57 -6
  20. package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +72 -0
  21. package/dist/fancy-ui/fluid-cursor/fluid-shared.js +125 -0
  22. package/dist/fancy-ui/fluid-cursor/fluid-shared.test.d.ts +1 -0
  23. package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +39 -0
  24. package/dist/fancy-ui/fluid-cursor/index.d.ts +1 -0
  25. package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +36 -0
  26. package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +926 -0
  27. package/dist/fancy-ui/frosted-glass/FrostedGlass.svelte +5 -3
  28. package/dist/fancy-ui/frosted-glass/README.md +4 -2
  29. package/dist/fancy-ui/index.d.ts +1 -0
  30. package/dist/fancy-ui/index.js +1 -0
  31. package/dist/fancy-ui/line-hover-link/README.md +37 -0
  32. package/dist/fancy-ui/line-reveal/LineReveal.test.d.ts +1 -0
  33. package/dist/fancy-ui/line-reveal/LineReveal.test.js +77 -0
  34. package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte +19 -2
  35. package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte.d.ts +4 -0
  36. package/dist/fancy-ui/liquid-glass/LiquidGlass.test.js +14 -0
  37. package/dist/fancy-ui/liquid-glass/README.md +7 -1
  38. package/dist/fancy-ui/liquid-text/LiquidText.svelte +1151 -0
  39. package/dist/fancy-ui/liquid-text/LiquidText.svelte.d.ts +37 -0
  40. package/dist/fancy-ui/liquid-text/LiquidText.test.d.ts +1 -0
  41. package/dist/fancy-ui/liquid-text/LiquidText.test.js +219 -0
  42. package/dist/fancy-ui/liquid-text/README.md +139 -0
  43. package/dist/fancy-ui/liquid-text/index.d.ts +2 -0
  44. package/dist/fancy-ui/liquid-text/index.js +1 -0
  45. package/dist/fancy-ui/marquee/README.md +57 -0
  46. package/dist/fancy-ui/matrix-rain/MatrixRain.test.d.ts +1 -0
  47. package/dist/fancy-ui/matrix-rain/MatrixRain.test.js +30 -0
  48. package/dist/fancy-ui/matrix-rain/README.md +42 -0
  49. package/dist/fancy-ui/noise-reveal/README.md +52 -0
  50. package/dist/fancy-ui/rainbow-button/README.md +36 -0
  51. package/dist/fancy-ui/registry.js +144 -20
  52. package/dist/fancy-ui/ripple-button/README.md +36 -0
  53. package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.d.ts +1 -0
  54. package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.js +45 -0
  55. package/dist/fancy-ui/terminal-text/README.md +48 -0
  56. package/dist/fancy-ui/terminal-text/TerminalText.test.d.ts +1 -0
  57. package/dist/fancy-ui/terminal-text/TerminalText.test.js +88 -0
  58. package/package.json +18 -4
@@ -0,0 +1,37 @@
1
+ export interface LiquidTextProps {
2
+ /** Text rendered into the fluid texture (or as static fallback text). */
3
+ text?: string;
4
+ /** CSS font-family. Empty string resolves to getComputedStyle(host).fontFamily. */
5
+ font?: string;
6
+ /** Font size in px. 0 auto-fits the text to the container's width. */
7
+ fontSize?: number;
8
+ /** CSS font-weight for the rasterized/fallback text. */
9
+ fontWeight?: number | string;
10
+ /** Text color used in light mode. */
11
+ lightColor?: string;
12
+ /** Text color used in dark mode. */
13
+ darkColor?: string;
14
+ /** Additional CSS classes applied to the root element. */
15
+ class?: string;
16
+ /** Geometric UV warp gain — how far the fluid velocity displaces the text's UVs. */
17
+ strength?: number;
18
+ /** Splat radius in screen pixels around the pointer. */
19
+ radius?: number;
20
+ /** Multiplier from mouse-delta-per-frame to splat force. */
21
+ forceGain?: number;
22
+ /** Per-frame velocity decay factor (relax-back rate for the smear). */
23
+ dissipation?: number;
24
+ /** Viscous diffusion strength (Jacobi iteration, 8 iters). */
25
+ viscosity?: number;
26
+ /** Chromatic offset = warp amount x this ratio. */
27
+ chromaticRatio?: number;
28
+ /** If window.innerWidth <= staticBelow at mount, render static DOM text instead. */
29
+ staticBelow?: number;
30
+ /** Whether the fluid sim reacts to pointer movement. */
31
+ interactive?: boolean;
32
+ /** Pause the render loop via visibilitychange when the tab/page is hidden. */
33
+ pauseWhenHidden?: boolean;
34
+ }
35
+ declare const LiquidText: import("svelte").Component<LiquidTextProps, {}, "">;
36
+ type LiquidText = ReturnType<typeof LiquidText>;
37
+ export default LiquidText;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,219 @@
1
+ import { render, screen, cleanup } from "@testing-library/svelte";
2
+ import { afterEach, describe, it, expect, vi } from "vitest";
3
+ import LiquidText from "./LiquidText.svelte";
4
+ /**
5
+ * Ambient test environment (see src/test-setup.ts) already stubs:
6
+ * - HTMLCanvasElement.prototype.getContext -> null (WebGL "unavailable")
7
+ * - window.matchMedia -> { matches: false, ... }
8
+ * - ResizeObserver / IntersectionObserver -> no-op classes
9
+ *
10
+ * Per the frozen LiquidText contract, "no WebGL" and "no reduced-motion
11
+ * preference" both drive the component into its DOM-fallback path. Tests
12
+ * below that specifically target the fallback/no-WebGL/reduced-motion
13
+ * behavior re-assert these overrides explicitly (mirroring the ambient
14
+ * default) so intent is self-documenting and independent of test-setup.ts.
15
+ */
16
+ describe("LiquidText", () => {
17
+ afterEach(cleanup);
18
+ describe("accessible text node", () => {
19
+ it("renders the text prop as a real text node in the DOM", () => {
20
+ render(LiquidText, { props: { text: "Hello Liquid" } });
21
+ const textNode = screen.getByText("Hello Liquid");
22
+ expect(textNode).toBeInTheDocument();
23
+ // The contract only requires the *canvas* to be aria-hidden; the
24
+ // real text node itself must stay in the accessibility tree,
25
+ // whether it is the sr-only span (canvas active) or the visible
26
+ // fallback text.
27
+ expect(textNode.getAttribute("aria-hidden")).not.toBe("true");
28
+ });
29
+ it('falls back to the default text "Liquid" when no text prop is passed', () => {
30
+ render(LiquidText);
31
+ expect(screen.getByText("Liquid")).toBeInTheDocument();
32
+ });
33
+ it("marks the canvas aria-hidden when a canvas is rendered", () => {
34
+ const { container } = render(LiquidText, { props: { text: "Canvas Check" } });
35
+ const canvas = container.querySelector("canvas");
36
+ // The contract does not pin down whether <canvas> is always present
37
+ // in the markup or only mounted on the interactive/WebGL path, so
38
+ // this only asserts when one is actually found.
39
+ if (canvas) {
40
+ expect(canvas.getAttribute("aria-hidden")).toBe("true");
41
+ }
42
+ });
43
+ });
44
+ describe("WebGL unavailable fallback", () => {
45
+ it("renders visible styled fallback text, starts no rAF render loop, and logs no console errors", () => {
46
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
47
+ HTMLCanvasElement.prototype.getContext = (() => null);
48
+ const rafSpy = vi.spyOn(window, "requestAnimationFrame");
49
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
50
+ render(LiquidText, {
51
+ props: {
52
+ text: "No WebGL",
53
+ font: "Georgia, serif",
54
+ fontSize: 48,
55
+ fontWeight: 600,
56
+ lightColor: "#123456",
57
+ },
58
+ });
59
+ const textNode = screen.getByText("No WebGL");
60
+ expect(textNode).toBeInTheDocument();
61
+ // Per contract: "visible styled text in fallback modes", as opposed
62
+ // to the sr-only span used when the canvas is actively driven. It
63
+ // must actually carry the prop-driven font/size/color styling, not
64
+ // just be present and unstyled.
65
+ expect(textNode.className).not.toContain("sr-only");
66
+ expect(textNode.style.fontFamily).toBe("Georgia, serif");
67
+ expect(textNode.style.fontSize).toBe("48px");
68
+ expect(textNode.style.fontWeight).toBe("600");
69
+ expect(textNode.style.color).toBe("rgb(18, 52, 86)");
70
+ expect(rafSpy).not.toHaveBeenCalled();
71
+ expect(errorSpy).not.toHaveBeenCalled();
72
+ rafSpy.mockRestore();
73
+ errorSpy.mockRestore();
74
+ HTMLCanvasElement.prototype.getContext = originalGetContext;
75
+ });
76
+ });
77
+ describe("prefers-reduced-motion fallback", () => {
78
+ it("renders static fallback text and starts no rAF render loop when reduced motion is preferred", () => {
79
+ const originalMatchMedia = window.matchMedia;
80
+ Object.defineProperty(window, "matchMedia", {
81
+ writable: true,
82
+ configurable: true,
83
+ value: (query) => ({
84
+ matches: query.includes("prefers-reduced-motion"),
85
+ media: query,
86
+ onchange: null,
87
+ addEventListener: () => { },
88
+ removeEventListener: () => { },
89
+ dispatchEvent: () => false,
90
+ addListener: () => { },
91
+ removeListener: () => { },
92
+ }),
93
+ });
94
+ const rafSpy = vi.spyOn(window, "requestAnimationFrame");
95
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
96
+ render(LiquidText, {
97
+ props: {
98
+ text: "Reduced Motion",
99
+ font: "Georgia, serif",
100
+ fontSize: 32,
101
+ fontWeight: 500,
102
+ lightColor: "#654321",
103
+ },
104
+ });
105
+ const textNode = screen.getByText("Reduced Motion");
106
+ expect(textNode).toBeInTheDocument();
107
+ expect(textNode.className).not.toContain("sr-only");
108
+ // Same requirement as the no-WebGL fallback: styled per the props,
109
+ // not just present.
110
+ expect(textNode.style.fontFamily).toBe("Georgia, serif");
111
+ expect(textNode.style.fontSize).toBe("32px");
112
+ expect(textNode.style.fontWeight).toBe("500");
113
+ expect(textNode.style.color).toBe("rgb(101, 67, 33)");
114
+ expect(rafSpy).not.toHaveBeenCalled();
115
+ expect(errorSpy).not.toHaveBeenCalled();
116
+ rafSpy.mockRestore();
117
+ errorSpy.mockRestore();
118
+ Object.defineProperty(window, "matchMedia", {
119
+ writable: true,
120
+ configurable: true,
121
+ value: originalMatchMedia,
122
+ });
123
+ });
124
+ });
125
+ describe("cleanup on repeated mount/unmount", () => {
126
+ it("leaves window/document/body listener counts balanced and no dangling rAF after 5 mount/unmount cycles", () => {
127
+ const targets = [window, document, document.body];
128
+ const addSpies = targets.map((t) => vi.spyOn(t, "addEventListener"));
129
+ const removeSpies = targets.map((t) => vi.spyOn(t, "removeEventListener"));
130
+ let nextRafId = 1;
131
+ const pendingRafIds = new Set();
132
+ const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => {
133
+ const id = nextRafId++;
134
+ pendingRafIds.add(id);
135
+ return id;
136
+ });
137
+ const cafSpy = vi
138
+ .spyOn(window, "cancelAnimationFrame")
139
+ .mockImplementation((id) => {
140
+ pendingRafIds.delete(id);
141
+ });
142
+ for (let i = 0; i < 5; i++) {
143
+ const { unmount } = render(LiquidText, { props: { text: `pass-${i}` } });
144
+ unmount();
145
+ }
146
+ // Per-event-name balance on each target: every addEventListener("x", …)
147
+ // must be matched by a removeEventListener("x", …) across the 5
148
+ // mount/unmount cycles. Checking per event name (not just raw totals)
149
+ // catches leaks that would otherwise cancel out coincidentally.
150
+ targets.forEach((_, i) => {
151
+ const counts = new Map();
152
+ for (const call of addSpies[i].mock.calls) {
153
+ const name = call[0];
154
+ counts.set(name, (counts.get(name) ?? 0) + 1);
155
+ }
156
+ for (const call of removeSpies[i].mock.calls) {
157
+ const name = call[0];
158
+ counts.set(name, (counts.get(name) ?? 0) - 1);
159
+ }
160
+ for (const [eventName, balance] of counts) {
161
+ expect(balance, `"${eventName}" listener leak on target #${i}`).toBe(0);
162
+ }
163
+ });
164
+ expect(pendingRafIds.size).toBe(0);
165
+ addSpies.forEach((s) => s.mockRestore());
166
+ removeSpies.forEach((s) => s.mockRestore());
167
+ rafSpy.mockRestore();
168
+ cafSpy.mockRestore();
169
+ });
170
+ });
171
+ describe("class prop", () => {
172
+ it("applies the class prop to the root element", () => {
173
+ const { container } = render(LiquidText, { props: { class: "my-liquid-class" } });
174
+ const root = container.firstElementChild;
175
+ expect(root.className).toContain("my-liquid-class");
176
+ });
177
+ it("still renders a root element with the default empty class", () => {
178
+ const { container } = render(LiquidText);
179
+ expect(container.firstElementChild).toBeInTheDocument();
180
+ });
181
+ });
182
+ describe("prop defaults and full prop surface (smoke)", () => {
183
+ it("mounts without error using only defaults", () => {
184
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
185
+ const { container } = render(LiquidText);
186
+ expect(container.firstElementChild).toBeInTheDocument();
187
+ expect(screen.getByText("Liquid")).toBeInTheDocument();
188
+ expect(errorSpy).not.toHaveBeenCalled();
189
+ errorSpy.mockRestore();
190
+ });
191
+ it("mounts without error with every documented prop explicitly set", () => {
192
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
193
+ const { container } = render(LiquidText, {
194
+ props: {
195
+ text: "Smoke Test",
196
+ font: "Arial, sans-serif",
197
+ fontSize: 96,
198
+ fontWeight: 800,
199
+ lightColor: "#111111",
200
+ darkColor: "#eeeeee",
201
+ class: "smoke-root",
202
+ strength: 0.75,
203
+ radius: 220,
204
+ forceGain: 24,
205
+ dissipation: 0.95,
206
+ viscosity: 6,
207
+ chromaticRatio: 0.35,
208
+ staticBelow: 768,
209
+ interactive: false,
210
+ pauseWhenHidden: false,
211
+ },
212
+ });
213
+ expect(container.firstElementChild).toBeInTheDocument();
214
+ expect(screen.getByText("Smoke Test")).toBeInTheDocument();
215
+ expect(errorSpy).not.toHaveBeenCalled();
216
+ errorSpy.mockRestore();
217
+ });
218
+ });
219
+ });
@@ -0,0 +1,139 @@
1
+ # LiquidText
2
+
3
+ Large display text that liquefies under the cursor: a raw-WebGL fluid velocity
4
+ field displaces the text's UVs along the pointer's trail, with RGB chromatic
5
+ fringing on the warped edges, then relaxes back to rest as the fluid decays.
6
+
7
+ ## Features
8
+
9
+ - Raw WebGL2 (WebGL1 fallback) fluid simulation — no Three.js dependency
10
+ - Any string rendered through the sim: text is rasterized to a texture via
11
+ Canvas2D (`fillText`), not a pre-baked image
12
+ - Mouse-velocity-driven splats: faster cursor movement = stronger smear
13
+ - Chromatic aberration on displaced edges (3-tap R/G/B sampling)
14
+ - Self-relaxing: velocity field decays every frame, so the smear resolves on
15
+ its own roughly a second after the cursor moves away
16
+ - Auto-fit font size to the container width when `fontSize` is unset
17
+ - Graceful fallbacks: `prefers-reduced-motion`, no WebGL, and small viewports
18
+ (`staticBelow`) all render plain styled static text instead of the sim
19
+ - Accessible by construction: a real text node is always present in the DOM;
20
+ only the `<canvas>` is `aria-hidden`
21
+ - Single `requestAnimationFrame` loop, paused via `visibilitychange` when the
22
+ tab is hidden (`pauseWhenHidden`)
23
+ - Exhaustive cleanup on unmount: cancels the rAF loop, removes every
24
+ listener, disconnects the `ResizeObserver`, and calls `WEBGL_lose_context`
25
+
26
+ ## Usage
27
+
28
+ ```svelte
29
+ <script>
30
+ import { LiquidText } from "fancy-ui-svelte";
31
+ </script>
32
+
33
+ <LiquidText text="Liquid" class="text-7xl font-bold" />
34
+ ```
35
+
36
+ ```svelte
37
+ <!-- Tuned for a subtler, slower-decaying smear -->
38
+ <LiquidText
39
+ text="Rama Herbin"
40
+ font="Bricolage Grotesque, sans-serif"
41
+ fontWeight={700}
42
+ strength={0.35}
43
+ radius={120}
44
+ dissipation={0.985}
45
+ lightColor="#0a0a0a"
46
+ darkColor="#fafafa"
47
+ />
48
+ ```
49
+
50
+ ```svelte
51
+ <!-- Force the static DOM fallback (e.g. inside a `prefers-reduced-motion`
52
+ conscious layout, or below a custom breakpoint) -->
53
+ <LiquidText text="Liquid" interactive={false} staticBelow={1280} />
54
+ ```
55
+
56
+ ## Props
57
+
58
+ | Prop | Type | Default | Description |
59
+ | ---------------- | -------------------- | ----------- | -------------------------------------------------------------------------------------- |
60
+ | `text` | `string` | `"Liquid"` | The text rendered into the fluid texture (or as static fallback text). |
61
+ | `font` | `string` | `""` | CSS font-family for the text. Empty string resolves to `getComputedStyle(host).fontFamily`. |
62
+ | `fontSize` | `number` | `0` | Font size in px. `0` auto-fits the text to the container's width. |
63
+ | `fontWeight` | `number \| string` | `700` | CSS font-weight for the rasterized/fallback text. |
64
+ | `lightColor` | `string` | `"#000000"` | Text color used in light mode. |
65
+ | `darkColor` | `string` | `"#ffffff"` | Text color used in dark mode. |
66
+ | `class` | `string` | `""` | Additional class names applied to the root element. |
67
+ | `strength` | `number` | `0.5` | Geometric UV warp gain — how far the fluid velocity displaces the text's UVs. |
68
+ | `radius` | `number` | `160` | Splat radius in screen pixels around the pointer. |
69
+ | `forceGain` | `number` | `17` | Multiplier from mouse-delta-per-frame to splat force. |
70
+ | `dissipation` | `number` | `0.98` | Per-frame velocity decay factor (relax-back rate for the smear). |
71
+ | `viscosity` | `number` | `4` | Viscous diffusion strength (Jacobi iteration, 8 iters). |
72
+ | `chromaticRatio` | `number` | `0.2` | Chromatic offset = warp amount × this ratio. |
73
+ | `staticBelow` | `number` | `1024` | If `window.innerWidth <= staticBelow` at mount, render static DOM text instead. |
74
+ | `interactive` | `boolean` | `true` | Whether the fluid sim reacts to pointer movement. |
75
+ | `pauseWhenHidden`| `boolean` | `true` | Pause the render loop via `visibilitychange` when the tab/page is hidden. |
76
+
77
+ ## Behavior contract
78
+
79
+ - A real text node is **always** in the DOM: a `sr-only` span while the
80
+ canvas is actively driven, or visible styled text in any fallback mode.
81
+ - The `<canvas>` is always `aria-hidden="true"` — it is a visual effect
82
+ layer, not content.
83
+ - The component falls back to plain styled DOM text (no WebGL sim, no rAF
84
+ loop) when any of these are true at mount: `prefers-reduced-motion` is
85
+ preferred, WebGL is unavailable, or `window.innerWidth <= staticBelow`.
86
+ - Root markup shape: `<div class={...}>` wrapping the canvas and the text
87
+ node.
88
+
89
+ ## Implementation notes
90
+
91
+ ### Fluid simulation
92
+
93
+ - Runs the velocity solve at **0.25× the canvas resolution** to keep the
94
+ per-frame cost low for a text-sized effect (this doesn't need dye-buffer
95
+ resolution the way a full-screen cursor effect does).
96
+ - Pipeline per frame: self-advection with `dissipation` decay → pointer-velocity
97
+ splat (screen-px falloff `(1 - dist/radius)² × 1.5`, force ∝ mouse delta ×
98
+ `forceGain`) → viscous diffusion (Jacobi, `viscosity`, 8 iterations) →
99
+ divergence → pressure solve (Jacobi, 16 iterations) → gradient
100
+ subtraction/projection. Vorticity confinement is intentionally left out —
101
+ it isn't part of the reference behavior being reproduced and would add
102
+ cost without visible benefit at this scale. `dt` is fixed rather than
103
+ measured from real frame time, so the decay rate stays predictable
104
+ regardless of frame rate.
105
+ - Text is rasterized once per (text, font, fontSize, fontWeight) combination
106
+ via Canvas2D `fillText` into a texture, and re-rasterized on `ResizeObserver`
107
+ changes that affect layout (e.g. auto-fit font size).
108
+
109
+ ### Chromatic sampling
110
+
111
+ - The fragment shader reads the velocity field at the screen UV, derives a
112
+ geometric warp (`velocity * strength`) and a chromatic offset
113
+ (`warp * chromaticRatio`), then does 3 independent texture taps: red at
114
+ `uv - offset`, green at `uv`, blue at `uv + offset`. The result is a
115
+ visible RGB fringe exactly where the text is being smeared, which
116
+ disappears as the velocity field dissipates.
117
+
118
+ ### Fallbacks
119
+
120
+ - `prefers-reduced-motion: reduce` and "no WebGL context available" both
121
+ short-circuit straight to the static DOM text path — no canvas sim is
122
+ initialized, no rAF loop starts.
123
+ - `staticBelow` is checked once against `window.innerWidth` at mount (not
124
+ reactively re-checked on resize) — crossing the breakpoint after mount
125
+ does not retroactively swap modes.
126
+ - All three fallback conditions render the same static markup: visible,
127
+ normally-styled text using `lightColor`/`darkColor` per color scheme.
128
+
129
+ ### Cleanup
130
+
131
+ - A single `requestAnimationFrame` loop drives the sim; it is paused (not
132
+ cancelled) on `document.visibilitychange` when `pauseWhenHidden` is true,
133
+ and resumed when the tab becomes visible again.
134
+ - On unmount: cancels the pending rAF, removes every `window`/`document`
135
+ listener the component registered, disconnects the `ResizeObserver`, and
136
+ explicitly calls the `WEBGL_lose_context` extension's `loseContext()` to
137
+ release GPU resources deterministically rather than waiting on GC.
138
+ - Verified safe under repeated mount/unmount (no leaked listeners, no
139
+ dangling animation frames) — see `LiquidText.test.ts`.
@@ -0,0 +1,2 @@
1
+ export { default as LiquidText } from "./LiquidText.svelte";
2
+ export type { LiquidTextProps } from "./LiquidText.svelte";
@@ -0,0 +1 @@
1
+ export { default as LiquidText } from "./LiquidText.svelte";
@@ -0,0 +1,57 @@
1
+ # Marquee
2
+
3
+ An infinite scrolling row (or column) of content, built by rendering multiple copies of its children side by side and animating the whole track by `-100% - gap` on a seamless loop — no JS-measured widths, no `requestAnimationFrame`.
4
+
5
+ ## Usage
6
+
7
+ ```svelte
8
+ <script lang="ts">
9
+ import { Marquee, ReviewCard } from "fancy-ui-svelte";
10
+
11
+ const reviews = [
12
+ { name: "Jack", username: "@jack", body: "Amazing.", img: "https://avatar.vercel.sh/jack" },
13
+ ];
14
+ </script>
15
+
16
+ <Marquee pauseOnHover class="[--duration:20s]">
17
+ {#each reviews as review}
18
+ <ReviewCard {...review} />
19
+ {/each}
20
+ </Marquee>
21
+ ```
22
+
23
+ Two rows scrolling in opposite directions (a common pattern) are just two `<Marquee>`s, one with `reverse`:
24
+
25
+ ```svelte
26
+ <Marquee pauseOnHover>
27
+ {#each firstRow as r}
28
+ <ReviewCard {...r} />
29
+ {/each}
30
+ </Marquee>
31
+ <Marquee reverse pauseOnHover>
32
+ {#each secondRow as r}
33
+ <ReviewCard {...r} />
34
+ {/each}
35
+ </Marquee>
36
+ ```
37
+
38
+ ## Props
39
+
40
+ | Prop | Type | Default | Description |
41
+ | -------------- | --------- | ------- | -------------------------------------------- |
42
+ | `reverse` | `boolean` | `false` | Reverse the scroll direction |
43
+ | `pauseOnHover` | `boolean` | `false` | Pause the animation on hover |
44
+ | `vertical` | `boolean` | `false` | Scroll vertically instead of horizontally |
45
+ | `repeat` | `number` | `4` | Number of times to repeat the children track |
46
+ | `class` | `string` | — | Additional CSS classes |
47
+
48
+ Children are the content to repeat and scroll.
49
+
50
+ `ReviewCard` is also exported as a ready-made card for testimonial-style marquees, taking `img`, `name`, `username`, and `body` props — its own prop type isn't re-exported as a named type, so consumers writing their own card don't need to match its shape exactly.
51
+
52
+ ## Implementation notes
53
+
54
+ - Speed and gap are CSS custom properties (`--duration: 40s`, `--gap: 1rem` by default) set on the root and overridable per-instance via `class="[--duration:20s]"` — there's no `speed`/`gap` prop.
55
+ - The seamless loop works because `repeat` renders that many identical copies of `children` in a row and animates the whole flex track by exactly `-100% - var(--gap)` (one child-set width) — too low a `repeat` on a narrow set of children can leave a visible gap on wide viewports.
56
+ - `pauseOnHover` pauses via `group-hover:[animation-play-state:paused]` on the whole marquee group, not per-item.
57
+ - No `prefers-reduced-motion` handling — the scroll animation runs continuously regardless of user preference.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { render, cleanup } from "@testing-library/svelte";
2
+ import { afterEach, describe, it, expect } from "vitest";
3
+ import MatrixRain from "./MatrixRain.svelte";
4
+ describe("MatrixRain", () => {
5
+ afterEach(cleanup);
6
+ it("renders a canvas element", () => {
7
+ const { container } = render(MatrixRain);
8
+ expect(container.querySelector("canvas")).toBeTruthy();
9
+ });
10
+ it("applies the base fill/background classes", () => {
11
+ const { container } = render(MatrixRain);
12
+ const canvas = container.querySelector("canvas");
13
+ expect(canvas.className).toContain("block");
14
+ expect(canvas.className).toContain("h-full");
15
+ expect(canvas.className).toContain("w-full");
16
+ expect(canvas.className).toContain("bg-black");
17
+ });
18
+ it("applies custom class names alongside the base classes", () => {
19
+ const { container } = render(MatrixRain, { props: { class: "my-rain" } });
20
+ const canvas = container.querySelector("canvas");
21
+ expect(canvas.className).toContain("my-rain");
22
+ expect(canvas.className).toContain("block");
23
+ });
24
+ it("mounts and unmounts without throwing when getContext('2d') is unavailable (jsdom has no canvas backend)", () => {
25
+ const { unmount } = render(MatrixRain, {
26
+ props: { color: "#ff00ff", speed: 2, density: 0.5, glyphSize: 20, fadeOpacity: 0.1 },
27
+ });
28
+ expect(() => unmount()).not.toThrow();
29
+ });
30
+ });
@@ -0,0 +1,42 @@
1
+ # MatrixRain
2
+
3
+ A Canvas2D "digital rain" background: columns of falling glyphs with a bright leading character and a dimmer trailing one, faded by repeatedly painting a low-opacity black rectangle over the previous frame instead of clearing it.
4
+
5
+ ## Usage
6
+
7
+ ```svelte
8
+ <script lang="ts">
9
+ import { MatrixRain } from "fancy-ui-svelte";
10
+ </script>
11
+
12
+ <div class="relative h-[400px] w-full">
13
+ <MatrixRain />
14
+ </div>
15
+ ```
16
+
17
+ ```svelte
18
+ <!-- Custom color, denser and faster -->
19
+ <MatrixRain color="#39ff14" density={1.5} speed={1.8} />
20
+ ```
21
+
22
+ The canvas fills its parent (`h-full w-full`) and paints its own black background, so size it via a wrapping element.
23
+
24
+ ## Props
25
+
26
+ | Prop | Type | Default | Description |
27
+ | ------------- | -------- | ----------- | ----------------------------------------------------------------------- |
28
+ | `color` | `string` | `"#00ff41"` | Glyph color (the classic Matrix green) |
29
+ | `speed` | `number` | `1.0` | Fall speed multiplier (`< 1` slower, `> 1` faster) |
30
+ | `density` | `number` | `1.0` | Column density multiplier (higher = more, narrower columns) |
31
+ | `glyphSize` | `number` | `16` | Font size of each glyph in pixels |
32
+ | `fadeOpacity` | `number` | `0.05` | Opacity of the black overlay painted each frame — controls trail length |
33
+ | `class` | `string` | — | Additional CSS classes on the canvas |
34
+
35
+ ## Implementation notes
36
+
37
+ - Glyphs are drawn from a fixed katakana + Latin + digit + symbol string (`GLYPHS`); each visible position gets a freshly randomized glyph every frame it's redrawn, so no character "stays" in place — the trail effect comes entirely from `fadeOpacity`, not glyph persistence.
38
+ - Column count is `floor(width / (glyphSize * density))`, and both `glyphSize` and `density` are floored to a safe minimum (`glyphSize >= 1`, `density >= 0.1`) so extreme prop values can't divide by zero or blow up the column count.
39
+ - `speed >= 1` advances every column by `speed` rows per frame; `speed < 1` instead skips frames (`frameCount % round(1/speed) === 0`), which is why speed below 1 steps down in discrete slow-motion increments rather than scaling smoothly.
40
+ - The canvas is sized in device pixels via `window.devicePixelRatio` and a matching `ctx.setTransform`, so glyphs stay crisp on HiDPI screens; a `ResizeObserver` on the canvas re-runs the resize/init logic (which also resets column positions) whenever its box size changes.
41
+ - The render loop is a single `requestAnimationFrame` chain started in a `$effect` and torn down (canceled, observer disconnected) in its cleanup — there's no visibility-based pausing, so it keeps painting while off-screen or the tab is backgrounded.
42
+ - No `prefers-reduced-motion` handling — the rain animates continuously regardless of user preference.
@@ -0,0 +1,52 @@
1
+ # NoiseReveal
2
+
3
+ A WebGL image reveal driven by a Perlin-noise dissolve mask, a radial gradient that contracts as the reveal progresses, and a subtle wave displacement on the mesh itself. Reveals either once on scroll-into-view or under full manual control via a `revealed` prop.
4
+
5
+ ## Usage
6
+
7
+ ```svelte
8
+ <script lang="ts">
9
+ import { NoiseReveal } from "fancy-ui-svelte";
10
+ </script>
11
+
12
+ <!-- Reveals once when it scrolls into view -->
13
+ <NoiseReveal
14
+ src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800"
15
+ alt="Mountain peak above the clouds"
16
+ class="h-[300px] max-w-md"
17
+ />
18
+ ```
19
+
20
+ ```svelte
21
+ <!-- Manual control — can also be hidden again -->
22
+ <script lang="ts">
23
+ import { NoiseReveal } from "fancy-ui-svelte";
24
+ let revealed = $state(true);
25
+ </script>
26
+
27
+ <NoiseReveal src="/photo.jpg" trigger="manual" {revealed} />
28
+ <button onclick={() => (revealed = !revealed)}>{revealed ? "Hide" : "Reveal"}</button>
29
+ ```
30
+
31
+ ## Props
32
+
33
+ | Prop | Type | Default | Description |
34
+ | ---------- | -------------------- | -------- | ---------------------------------------------------------------------- |
35
+ | `src` | `string` | — | Image URL (required) |
36
+ | `alt` | `string` | — | Accessible label for the image |
37
+ | `trigger` | `"view" \| "manual"` | `"view"` | `"view"` reveals once on viewport entry; `"manual"` follows `revealed` |
38
+ | `revealed` | `boolean` | `false` | Manual reveal state — only used with `trigger="manual"`; can re-hide |
39
+ | `duration` | `number` | `1.5` | Reveal animation duration in seconds |
40
+ | `delay` | `number` | `0` | Delay before reveal in seconds — only applies to `trigger="view"` |
41
+ | `class` | `string` | `""` | Additional CSS classes on the root |
42
+
43
+ ## Implementation notes
44
+
45
+ - The dissolve mask is computed entirely in the fragment shader: a domain-warped Perlin noise (`cnoise`, classic 3D Perlin noise) combined with a radial gradient anchored at the image center that contracts as `uProgress` goes from 0 to 1, clamped and inverted into an alpha value — there's no CPU-side mask texture.
46
+ - Progress is driven by a hand-rolled tween (`setTarget`, cubic ease-in-out) rather than a library, advanced each animation frame against `performance.now()`; under `prefers-reduced-motion: reduce` the tween is skipped entirely and the value jumps straight to its target.
47
+ - `trigger="view"` uses an `IntersectionObserver` (`threshold: 0.1`) that disconnects itself after the first intersection — the reveal only ever fires once per mount, `delay` is applied via `setTimeout` after that first intersection, not before.
48
+ - `trigger="manual"` re-runs `setTarget(revealed ? 1 : 0)` on every change to `revealed`, so unlike `"view"` it supports revealing and re-hiding repeatedly.
49
+ - The image texture loads asynchronously (`THREE.TextureLoader`); until it resolves, `uTexture` is `null` and the plane renders whatever the shader produces with no texture bound — there is no separate loading placeholder.
50
+ - The plane is a unit `PlaneGeometry` scaled at render time to exactly fill the camera frustum at `z = 0` for the current aspect ratio (`fitPlane`), and `CoverUV` in the fragment shader applies `object-fit: cover`-style UV cropping so images with a different aspect ratio than the container aren't stretched.
51
+ - Cleanup (on effect teardown) removes the `resize` listener, cancels the render loop, removes the canvas from `container`, and disposes the renderer, geometry, material, and any loaded texture.
52
+ - When `alt` is provided, the root gets `role="img"` and `aria-label={alt}` so the canvas-rendered image is announced to assistive tech.
@@ -0,0 +1,36 @@
1
+ # RainbowButton
2
+
3
+ A button (or anchor, when `href` is set) with an animated rainbow gradient border and a matching blurred glow beneath it, built entirely from CSS gradients, background layering, and a single `background-position` keyframe animation.
4
+
5
+ ## Usage
6
+
7
+ ```svelte
8
+ <script lang="ts">
9
+ import { RainbowButton } from "fancy-ui-svelte";
10
+ </script>
11
+
12
+ <RainbowButton onclick={() => console.log("clicked")}>Get started</RainbowButton>
13
+
14
+ <!-- Renders an <a> instead of a <button> -->
15
+ <RainbowButton href="/pricing" speed={3}>See pricing</RainbowButton>
16
+ ```
17
+
18
+ ## Props
19
+
20
+ | Prop | Type | Default | Description |
21
+ | ---------- | ----------------------------------------------------------- | ---------- | --------------------------------------------------------------- |
22
+ | `speed` | `number` | `2` | Animation speed in seconds (lower = faster) |
23
+ | `href` | `string` | — | Renders an `<a>` instead of a `<button>` when set |
24
+ | `type` | `"button" \| "submit" \| "reset"` | `"button"` | Native `<button>` `type` attribute (ignored when `href` is set) |
25
+ | `disabled` | `boolean` | — | Disables the button / marks the link `aria-disabled` |
26
+ | `ref` | `HTMLButtonElement \| HTMLAnchorElement \| null` (bindable) | `null` | Bound reference to the rendered element |
27
+ | `class` | `string` | — | Additional CSS classes |
28
+ | `children` | `Snippet` | — | Button label content |
29
+
30
+ ## Implementation notes
31
+
32
+ - `RainbowButtonProps` is typed as `BaseProps & Omit<HTMLButtonAttributes, ...> & Omit<HTMLAnchorAttributes, ...>` for editor completion, but the component only actually reads `class`, `speed`, `href`, `type`, `disabled`, `ref`, and `children` off `$props()` — there is no `...restProps` spread, so other native attributes typed as valid (e.g. `onclick` via `HTMLButtonAttributes`) are **not** forwarded to the rendered element. Attach event listeners with `onclick` on a wrapping element, or extend the component, if pass-through is needed beyond what's listed above.
33
+ - When `href` is set, the rendered `<a>` gets `aria-disabled` and `tabindex="-1"` (plus `role="link"`) instead of a native `disabled` attribute, since anchors don't support `disabled`.
34
+ - The rainbow gradient colors (`--rainbow-1`…`--rainbow-5`) are hard-coded HSL custom properties scoped to `.rainbow-button` in the component's own `<style>` block — they aren't exposed as props; only the shared `speed` (as `--rainbow-speed`) is.
35
+ - Light/dark mode swap the button's own fill and text color (dark button + white text in light mode, light button + black text in `dark:`) while reusing the same rainbow gradient for both the border and the glow.
36
+ - No `prefers-reduced-motion` handling — the border and glow animation run continuously regardless of user preference.