fancy-ui-svelte 0.8.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.
- package/README.md +107 -89
- package/dist/fancy-ui/animated-testimonials/README.md +44 -0
- package/dist/fancy-ui/animated-tooltip/README.md +40 -0
- package/dist/fancy-ui/apple-card-carousel/README.md +46 -0
- package/dist/fancy-ui/bento-grid/BentoGrid.test.d.ts +1 -0
- package/dist/fancy-ui/bento-grid/BentoGrid.test.js +109 -0
- package/dist/fancy-ui/border-beam/README.md +37 -0
- package/dist/fancy-ui/card-3d/CardContainer.test.d.ts +1 -0
- package/dist/fancy-ui/card-3d/CardContainer.test.js +118 -0
- package/dist/fancy-ui/card-spotlight/CardSpotlight.test.d.ts +1 -0
- package/dist/fancy-ui/card-spotlight/CardSpotlight.test.js +58 -0
- package/dist/fancy-ui/displacement-text/README.md +39 -0
- package/dist/fancy-ui/dock/README.md +1 -1
- package/dist/fancy-ui/editorial-engine/EditorialEngine.test.d.ts +1 -0
- package/dist/fancy-ui/editorial-engine/EditorialEngine.test.js +54 -0
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +130 -9
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +9 -1
- package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +19 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +32 -0
- package/dist/fancy-ui/fluid-cursor/index.d.ts +1 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +11 -4
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +113 -34
- package/dist/fancy-ui/line-hover-link/README.md +37 -0
- package/dist/fancy-ui/line-reveal/LineReveal.test.d.ts +1 -0
- package/dist/fancy-ui/line-reveal/LineReveal.test.js +77 -0
- package/dist/fancy-ui/marquee/README.md +57 -0
- package/dist/fancy-ui/matrix-rain/MatrixRain.test.d.ts +1 -0
- package/dist/fancy-ui/matrix-rain/MatrixRain.test.js +30 -0
- package/dist/fancy-ui/matrix-rain/README.md +42 -0
- package/dist/fancy-ui/noise-reveal/README.md +52 -0
- package/dist/fancy-ui/rainbow-button/README.md +36 -0
- package/dist/fancy-ui/registry.js +20 -20
- package/dist/fancy-ui/ripple-button/README.md +36 -0
- package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.d.ts +1 -0
- package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.js +45 -0
- package/dist/fancy-ui/terminal-text/README.md +48 -0
- package/dist/fancy-ui/terminal-text/TerminalText.test.d.ts +1 -0
- package/dist/fancy-ui/terminal-text/TerminalText.test.js +88 -0
- package/package.json +15 -2
|
@@ -263,19 +263,22 @@ async function createWebGpuFluid(canvas, opts) {
|
|
|
263
263
|
}
|
|
264
264
|
const context = maybeContext;
|
|
265
265
|
context.configure(configuration);
|
|
266
|
+
// Browsers that do not support toneMapping drop the member, which
|
|
267
|
+
// getConfiguration() makes observable. Surface it as data (not just a
|
|
268
|
+
// DEV log) so callers can tell an extended-tone-mapping canvas from a
|
|
269
|
+
// clamped fallback; whether the display is HDR is checked separately by
|
|
270
|
+
// startWebGpuFluid.
|
|
271
|
+
let extendedToneMapping = false;
|
|
272
|
+
try {
|
|
273
|
+
const applied = context.getConfiguration?.();
|
|
274
|
+
extendedToneMapping = applied?.toneMapping?.mode === "extended";
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// getConfiguration unsupported (e.g. Safari): assume clamped output.
|
|
278
|
+
}
|
|
266
279
|
if (import.meta.env.DEV) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
let extended = false;
|
|
270
|
-
try {
|
|
271
|
-
const applied = context.getConfiguration?.();
|
|
272
|
-
extended = applied?.toneMapping?.mode === "extended";
|
|
273
|
-
}
|
|
274
|
-
catch {
|
|
275
|
-
// getConfiguration unsupported: assume clamped output.
|
|
276
|
-
}
|
|
277
|
-
console.info(extended
|
|
278
|
-
? "[FluidCursor] HDR level: webgpu-hdr (extended tone mapping active)"
|
|
280
|
+
console.info(extendedToneMapping
|
|
281
|
+
? "[FluidCursor] extended tone mapping active (webgpu-hdr if the display reports high dynamic range)"
|
|
279
282
|
: "[FluidCursor] HDR level: webgpu-sdr (float16 + P3, tone mapping clamped by browser)");
|
|
280
283
|
}
|
|
281
284
|
let destroyed = false;
|
|
@@ -610,6 +613,7 @@ async function createWebGpuFluid(canvas, opts) {
|
|
|
610
613
|
get lost() {
|
|
611
614
|
return lost;
|
|
612
615
|
},
|
|
616
|
+
extendedToneMapping,
|
|
613
617
|
destroy() {
|
|
614
618
|
destroyed = true;
|
|
615
619
|
device.destroy();
|
|
@@ -625,8 +629,13 @@ async function createWebGpuFluid(canvas, opts) {
|
|
|
625
629
|
}
|
|
626
630
|
/**
|
|
627
631
|
* Full WebGPU setup for FluidCursor: engine + pointer wiring + rAF loop +
|
|
628
|
-
* visibility pause + auto splats. Resolves with a
|
|
629
|
-
* `null` when WebGPU is unavailable
|
|
632
|
+
* visibility pause + auto splats. Resolves with a {@link FluidCursorHandle}
|
|
633
|
+
* (augmented with `cleanup`), or with `null` when WebGPU is unavailable
|
|
634
|
+
* (caller falls back to the WebGL path).
|
|
635
|
+
*
|
|
636
|
+
* Handle coordinates are top-left origin in [0,1]; WebGPU texcoords already
|
|
637
|
+
* use a top-origin convention, so no Y flip happens here (the WebGL path owns
|
|
638
|
+
* its own flip).
|
|
630
639
|
*/
|
|
631
640
|
export async function startWebGpuFluid(canvas, opts) {
|
|
632
641
|
const maybeEngine = await createWebGpuFluid(canvas, opts);
|
|
@@ -634,6 +643,19 @@ export async function startWebGpuFluid(canvas, opts) {
|
|
|
634
643
|
return null;
|
|
635
644
|
const engine = maybeEngine;
|
|
636
645
|
const pointers = [pointerPrototype()];
|
|
646
|
+
// Dedicated synthetic pointer driven programmatically through the handle. It
|
|
647
|
+
// lives alongside the mouse pointer in the same list, so updateFrame splats
|
|
648
|
+
// both and the two inputs coexist. Created on first programmatic use, so a
|
|
649
|
+
// consumer that never drives the handle costs nothing.
|
|
650
|
+
let autopilotPointer = null;
|
|
651
|
+
let penWasUp = true;
|
|
652
|
+
function ensureAutopilotPointer() {
|
|
653
|
+
if (!autopilotPointer) {
|
|
654
|
+
autopilotPointer = pointerPrototype();
|
|
655
|
+
pointers.push(autopilotPointer);
|
|
656
|
+
}
|
|
657
|
+
return autopilotPointer;
|
|
658
|
+
}
|
|
637
659
|
let animationFrameId = 0;
|
|
638
660
|
let lastUpdateTime = Date.now();
|
|
639
661
|
let colorUpdateTimer = 0;
|
|
@@ -734,6 +756,11 @@ export async function startWebGpuFluid(canvas, opts) {
|
|
|
734
756
|
if (colorUpdateTimer >= 1) {
|
|
735
757
|
colorUpdateTimer = colorUpdateTimer % 1;
|
|
736
758
|
pointers.forEach((p) => {
|
|
759
|
+
// The synthetic pointer's color belongs to the handle: pulling from
|
|
760
|
+
// the generator here would advance the shared palette index twice per
|
|
761
|
+
// update and overwrite explicit stroke colors.
|
|
762
|
+
if (p === autopilotPointer)
|
|
763
|
+
return;
|
|
737
764
|
p.color = opts.generateColor();
|
|
738
765
|
});
|
|
739
766
|
}
|
|
@@ -823,25 +850,77 @@ export async function startWebGpuFluid(canvas, opts) {
|
|
|
823
850
|
engine.splat(Math.random(), Math.random(), (Math.random() - 0.5) * 200, (Math.random() - 0.5) * 200, opts.generateColor());
|
|
824
851
|
}, opts.autoSplatInterval);
|
|
825
852
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
853
|
+
// Extended tone mapping only proves the browser kept the configuration — an
|
|
854
|
+
// SDR display still clamps the output — so "webgpu-hdr" requires both. The
|
|
855
|
+
// display state is sampled once here and does not follow the window to
|
|
856
|
+
// another monitor.
|
|
857
|
+
const hdrDisplay = typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
858
|
+
? window.matchMedia("(dynamic-range: high)").matches
|
|
859
|
+
: false;
|
|
860
|
+
const renderLevel = engine.extendedToneMapping && hdrDisplay ? "webgpu-hdr" : "webgpu-sdr";
|
|
861
|
+
return {
|
|
862
|
+
/**
|
|
863
|
+
* x,y in [0,1], top-left origin. WebGPU texcoords are already top-origin,
|
|
864
|
+
* so the coordinates pass straight through.
|
|
865
|
+
*/
|
|
866
|
+
moveTo(x, y, color) {
|
|
867
|
+
const pointer = ensureAutopilotPointer();
|
|
868
|
+
const posX = x * canvas.width;
|
|
869
|
+
const posY = y * canvas.height;
|
|
870
|
+
if (penWasUp) {
|
|
871
|
+
// First move of a new stroke: reposition only (mirrors
|
|
872
|
+
// updatePointerDownData) so the jump from the previous stroke's
|
|
873
|
+
// end never draws a connecting streak.
|
|
874
|
+
penWasUp = false;
|
|
875
|
+
pointer.down = true;
|
|
876
|
+
pointer.moved = false;
|
|
877
|
+
pointer.texcoordX = posX / canvas.width;
|
|
878
|
+
pointer.texcoordY = posY / canvas.height;
|
|
879
|
+
pointer.prevTexcoordX = pointer.texcoordX;
|
|
880
|
+
pointer.prevTexcoordY = pointer.texcoordY;
|
|
881
|
+
pointer.deltaX = 0;
|
|
882
|
+
pointer.deltaY = 0;
|
|
883
|
+
// A stroke with no explicit color takes a fresh generated one, as a
|
|
884
|
+
// real pointer-down does: the prototype's black would inject
|
|
885
|
+
// invisible dye.
|
|
886
|
+
pointer.color = color ?? opts.generateColor();
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
updatePointerMoveData(pointer, posX, posY, color ?? pointer.color);
|
|
890
|
+
},
|
|
891
|
+
penUp() {
|
|
892
|
+
// `moved` is deliberately left alone: updateFrame consumes and clears it
|
|
893
|
+
// on the next frame, so clearing it here would swallow the final segment
|
|
894
|
+
// of a stroke finished synchronously before that frame.
|
|
895
|
+
penWasUp = true;
|
|
896
|
+
if (autopilotPointer)
|
|
897
|
+
autopilotPointer.down = false;
|
|
898
|
+
},
|
|
899
|
+
/** No Y flip on the WebGPU path. */
|
|
900
|
+
burst(x, y, dx, dy, color) {
|
|
901
|
+
engine.splat(x, y, dx, dy, color);
|
|
902
|
+
},
|
|
903
|
+
renderLevel,
|
|
904
|
+
cleanup() {
|
|
905
|
+
stopped = true;
|
|
906
|
+
cancelAnimationFrame(animationFrameId);
|
|
907
|
+
if (observer)
|
|
908
|
+
observer.disconnect();
|
|
909
|
+
if (autoSplatTimer)
|
|
910
|
+
clearInterval(autoSplatTimer);
|
|
911
|
+
if (opts.interactive) {
|
|
912
|
+
window.removeEventListener("mousedown", handleMouseDown);
|
|
913
|
+
document.body.removeEventListener("mousemove", handleFirstMouseMove);
|
|
914
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
915
|
+
document.body.removeEventListener("touchstart", handleFirstTouchStart);
|
|
916
|
+
window.removeEventListener("touchstart", handleTouchStart);
|
|
917
|
+
window.removeEventListener("touchmove", handleTouchMove);
|
|
918
|
+
}
|
|
919
|
+
if (opts.contained) {
|
|
920
|
+
window.removeEventListener("resize", updateCanvasRectCache);
|
|
921
|
+
window.removeEventListener("scroll", updateCanvasRectCache);
|
|
922
|
+
}
|
|
923
|
+
engine.destroy();
|
|
924
|
+
},
|
|
846
925
|
};
|
|
847
926
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# LineHoverLink
|
|
2
|
+
|
|
3
|
+
An anchor with a pure-CSS underline hover effect, selectable from 11 variants. No JavaScript runs on hover — every variant is `::before`/`::after` pseudo-elements or an inline SVG stroke animated with CSS `transition`/`transform`/`stroke-dashoffset`, triggered by `:hover`/`:focus-visible`.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```svelte
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { LineHoverLink } from "fancy-ui-svelte";
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<LineHoverLink href="/about" variant="slide">About</LineHoverLink>
|
|
13
|
+
<LineHoverLink href="/contact" variant="scribble" target="_blank">Contact</LineHoverLink>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Default | Description |
|
|
19
|
+
| ------------ | ------------------ | --------- | ---------------------- |
|
|
20
|
+
| `variant` | `LineHoverVariant` | `"slide"` | The animation variant |
|
|
21
|
+
| `href` | `string` | `"#"` | Link href |
|
|
22
|
+
| `target` | `string` | — | Link target |
|
|
23
|
+
| `rel` | `string` | — | Link rel |
|
|
24
|
+
| `aria-label` | `string` | — | Accessible label |
|
|
25
|
+
| `class` | `string` | `""` | Additional CSS classes |
|
|
26
|
+
|
|
27
|
+
Children (slot content) are the link's visible text/label.
|
|
28
|
+
|
|
29
|
+
`LineHoverVariant` is one of: `"slide" | "double" | "grow" | "strike" | "fade" | "pulse" | "swap" | "sweep" | "bounce" | "arc" | "scribble"`.
|
|
30
|
+
|
|
31
|
+
## Implementation notes
|
|
32
|
+
|
|
33
|
+
- The component has no `<script>`-side reactive state beyond two `$derived` values — everything visual is CSS selected by a `link-hover--<variant>` class.
|
|
34
|
+
- `arc` and `scribble` render an inline `<svg>` with a single `<path pathLength="1">` instead of a pseudo-element underline; both use the same technique (`stroke-dasharray: 1; stroke-dashoffset: 1` at rest, animated to `0` on hover/focus via `transition: stroke-dashoffset`).
|
|
35
|
+
- `strike`, `bounce`, `arc`, and `scribble` wrap the link text in an inner `<span>` (via the `needsSpan` derived flag) because those variants also transform the text itself (scale for `strike`, translate for `bounce`) independently of the underline graphic.
|
|
36
|
+
- `rel` defaults to `"noopener noreferrer"` automatically when `target="_blank"` and no explicit `rel` was passed, via the `relValue` derived value — an explicit `rel` prop is never overridden.
|
|
37
|
+
- All variants respond to both `:hover` and `:focus-visible`, so the effect is keyboard-navigable, not mouse-only.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { render, cleanup } from "@testing-library/svelte";
|
|
2
|
+
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
|
3
|
+
import LineReveal from "./LineReveal.svelte";
|
|
4
|
+
// pretext's prepareWithSegments() needs a canvas 2d context or OffscreenCanvas
|
|
5
|
+
// for text measurement — neither exists under jsdom (getContext is mocked to
|
|
6
|
+
// return null in src/test-setup.ts). Stubbing document.fonts.load() with a
|
|
7
|
+
// promise that never resolves keeps the component on its pre-measurement
|
|
8
|
+
// fallback branch instead of letting the effect's `.then()` handler call
|
|
9
|
+
// into pretext and throw "Text measurement requires OffscreenCanvas or a DOM
|
|
10
|
+
// canvas context."
|
|
11
|
+
function stubNeverLoadingFonts() {
|
|
12
|
+
Object.defineProperty(document, "fonts", {
|
|
13
|
+
configurable: true,
|
|
14
|
+
writable: true,
|
|
15
|
+
value: { load: () => new Promise(() => { }) },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
class MockIntersectionObserver {
|
|
19
|
+
callback;
|
|
20
|
+
static instances = [];
|
|
21
|
+
constructor(callback) {
|
|
22
|
+
this.callback = callback;
|
|
23
|
+
MockIntersectionObserver.instances.push(this);
|
|
24
|
+
}
|
|
25
|
+
observe = vi.fn();
|
|
26
|
+
disconnect = vi.fn();
|
|
27
|
+
unobserve = vi.fn();
|
|
28
|
+
}
|
|
29
|
+
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
|
30
|
+
describe("LineReveal", () => {
|
|
31
|
+
beforeEach(stubNeverLoadingFonts);
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
cleanup();
|
|
34
|
+
MockIntersectionObserver.instances = [];
|
|
35
|
+
});
|
|
36
|
+
it("mounts and renders the root wrapper", () => {
|
|
37
|
+
const { container } = render(LineReveal, { props: { text: "Hello there" } });
|
|
38
|
+
const root = container.firstElementChild;
|
|
39
|
+
expect(root.className).toContain("relative");
|
|
40
|
+
expect(root.className).toContain("w-full");
|
|
41
|
+
});
|
|
42
|
+
it("applies custom class names", () => {
|
|
43
|
+
const { container } = render(LineReveal, {
|
|
44
|
+
props: { text: "Hello", class: "my-reveal" },
|
|
45
|
+
});
|
|
46
|
+
const root = container.firstElementChild;
|
|
47
|
+
expect(root.className).toContain("my-reveal");
|
|
48
|
+
});
|
|
49
|
+
it("keeps the full text available to screen readers via a sr-only span", () => {
|
|
50
|
+
const { container } = render(LineReveal, { props: { text: "Accessible copy" } });
|
|
51
|
+
const srOnly = container.querySelector(".sr-only");
|
|
52
|
+
expect(srOnly?.textContent).toBe("Accessible copy");
|
|
53
|
+
});
|
|
54
|
+
it("renders the pre-measure fallback (hidden text) before pretext has measured", () => {
|
|
55
|
+
const { container } = render(LineReveal, { props: { text: "Falls back" } });
|
|
56
|
+
const fallback = container.querySelector('[aria-hidden="true"]');
|
|
57
|
+
expect(fallback).toBeTruthy();
|
|
58
|
+
expect(fallback.textContent).toBe("Falls back");
|
|
59
|
+
expect(fallback.style.visibility).toBe("hidden");
|
|
60
|
+
});
|
|
61
|
+
it("sets the font style from the font prop", () => {
|
|
62
|
+
const { container } = render(LineReveal, {
|
|
63
|
+
props: { text: "Styled", font: "700 24px Georgia, serif" },
|
|
64
|
+
});
|
|
65
|
+
const root = container.firstElementChild;
|
|
66
|
+
expect(root.getAttribute("style") ?? "").toContain("24px");
|
|
67
|
+
});
|
|
68
|
+
it("observes the container element for intersection", () => {
|
|
69
|
+
render(LineReveal, { props: { text: "Watch me" } });
|
|
70
|
+
const lastInstance = MockIntersectionObserver.instances.at(-1);
|
|
71
|
+
expect(lastInstance?.observe).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
it("unmounts cleanly", () => {
|
|
74
|
+
const { unmount } = render(LineReveal, { props: { text: "Bye" } });
|
|
75
|
+
expect(() => unmount()).not.toThrow();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -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.
|