fancy-ui-svelte 0.12.0 → 0.14.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 (75) hide show
  1. package/dist/fancy-ui/button/Button.svelte +196 -9
  2. package/dist/fancy-ui/button/Button.test.js +117 -3
  3. package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte +32 -0
  4. package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte.d.ts +3 -0
  5. package/dist/fancy-ui/button/README.md +13 -2
  6. package/dist/fancy-ui/compare/Compare.svelte +404 -47
  7. package/dist/fancy-ui/compare/Compare.svelte.d.ts +26 -0
  8. package/dist/fancy-ui/compare/Compare.test.js +82 -2
  9. package/dist/fancy-ui/compare/README.md +9 -2
  10. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.d.ts +1 -0
  11. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.js +24 -0
  12. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte +479 -0
  13. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte.d.ts +69 -0
  14. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.d.ts +1 -0
  15. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.js +250 -0
  16. package/dist/fancy-ui/datamosh-transition/README.md +87 -0
  17. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.d.ts +180 -0
  18. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.js +462 -0
  19. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.d.ts +1 -0
  20. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.js +309 -0
  21. package/dist/fancy-ui/datamosh-transition/index.d.ts +3 -0
  22. package/dist/fancy-ui/datamosh-transition/index.js +3 -0
  23. package/dist/fancy-ui/flip-card/FlipCard.svelte +373 -32
  24. package/dist/fancy-ui/flip-card/FlipCard.svelte.d.ts +39 -2
  25. package/dist/fancy-ui/flip-card/FlipCard.test.js +125 -25
  26. package/dist/fancy-ui/flip-card/README.md +41 -14
  27. package/dist/fancy-ui/flip-card/index.d.ts +1 -1
  28. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +97 -11
  29. package/dist/fancy-ui/fluid-cursor/README.md +3 -1
  30. package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +18 -0
  31. package/dist/fancy-ui/fluid-cursor/fluid-shared.js +22 -0
  32. package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +17 -1
  33. package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +6 -0
  34. package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +32 -9
  35. package/dist/fancy-ui/glow-border/GlowBorder.svelte +256 -33
  36. package/dist/fancy-ui/glow-border/GlowBorder.svelte.d.ts +41 -2
  37. package/dist/fancy-ui/glow-border/GlowBorder.test.js +45 -1
  38. package/dist/fancy-ui/glow-border/README.md +25 -33
  39. package/dist/fancy-ui/glow-border/index.d.ts +1 -1
  40. package/dist/fancy-ui/glow-border/index.js +1 -1
  41. package/dist/fancy-ui/gradient-button/GradientButton.svelte +123 -42
  42. package/dist/fancy-ui/gradient-button/GradientButton.svelte.d.ts +10 -5
  43. package/dist/fancy-ui/gradient-button/GradientButton.test.js +22 -5
  44. package/dist/fancy-ui/gradient-button/README.md +14 -6
  45. package/dist/fancy-ui/index.d.ts +1 -0
  46. package/dist/fancy-ui/index.js +1 -0
  47. package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.svelte +166 -23
  48. package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.test.js +18 -68
  49. package/dist/fancy-ui/interactive-hover-button/README.md +21 -15
  50. package/dist/fancy-ui/meteors/Meteors.svelte +179 -18
  51. package/dist/fancy-ui/meteors/Meteors.svelte.d.ts +32 -2
  52. package/dist/fancy-ui/meteors/Meteors.test.js +70 -22
  53. package/dist/fancy-ui/meteors/README.md +21 -6
  54. package/dist/fancy-ui/neon-border/NeonBorder.svelte +187 -58
  55. package/dist/fancy-ui/neon-border/NeonBorder.svelte.d.ts +24 -5
  56. package/dist/fancy-ui/neon-border/NeonBorder.test.js +51 -19
  57. package/dist/fancy-ui/neon-border/README.md +21 -11
  58. package/dist/fancy-ui/pagination/Pagination.svelte +144 -31
  59. package/dist/fancy-ui/pagination/Pagination.test.js +64 -8
  60. package/dist/fancy-ui/pagination/README.md +32 -22
  61. package/dist/fancy-ui/rainbow-button/README.md +3 -2
  62. package/dist/fancy-ui/rainbow-button/RainbowButton.svelte +108 -0
  63. package/dist/fancy-ui/rainbow-button/RainbowButton.test.js +12 -0
  64. package/dist/fancy-ui/registry.js +256 -41
  65. package/dist/fancy-ui/ripple-button/README.md +10 -7
  66. package/dist/fancy-ui/ripple-button/RippleButton.svelte +172 -19
  67. package/dist/fancy-ui/ripple-button/RippleButton.svelte.d.ts +20 -1
  68. package/dist/fancy-ui/ripple-button/RippleButton.test.js +47 -1
  69. package/dist/fancy-ui/shimmer-button/README.md +14 -6
  70. package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +137 -54
  71. package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte.d.ts +3 -3
  72. package/dist/fancy-ui/shimmer-button/ShimmerButton.test.js +21 -7
  73. package/package.json +1 -1
  74. package/dist/fancy-ui/themes.d.ts +0 -38
  75. package/dist/fancy-ui/themes.js +0 -356
@@ -1,7 +1,68 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * Compare - a before/after slider split by a blade of light
4
+ *
5
+ * The seam is a beam: a white-hot core with a chromatic fringe, a glow
6
+ * that spills onto both sides, and pulses of light running along it. Moving
7
+ * the seam leaves a trail behind it that stretches with the speed of the
8
+ * gesture and settles when it stops.
9
+ */
10
+ export const COMPARE_BEAM_COLORS = ["#22d3ee", "#818cf8", "#f472b6"];
11
+
12
+ /** Three colours for the beam (left fringe, centre, right fringe), from one to many given. */
13
+ export function beamPalette(colors: string[] | undefined): [string, string, string] {
14
+ const list = (colors ?? []).filter(Boolean);
15
+ if (list.length === 0) return COMPARE_BEAM_COLORS as [string, string, string];
16
+ if (list.length === 1) return [list[0], list[0], list[0]];
17
+ if (list.length === 2) return [list[0], `color-mix(in srgb, ${list[0]}, ${list[1]})`, list[1]];
18
+ return [list[0], list[Math.floor((list.length - 1) / 2)], list[list.length - 1]];
19
+ }
20
+
21
+ /**
22
+ * One frame of the beam's motion: the trail follows the seam's velocity
23
+ * (px per frame, signed) and decays; the energy eases toward its target
24
+ * and is kicked up by speed.
25
+ */
26
+ export function beamStep(
27
+ trail: number,
28
+ energy: number,
29
+ deltaPx: number,
30
+ target: number
31
+ ): { trail: number; energy: number } {
32
+ const nextTrail = Math.max(-140, Math.min(140, trail * 0.86 + deltaPx * 1.6));
33
+ const kick = Math.min(1, Math.abs(deltaPx) / 12);
34
+ const nextEnergy = Math.min(1, energy + (Math.max(target, kick) - energy) * 0.12);
35
+ return { trail: Math.abs(nextTrail) < 0.25 ? 0 : nextTrail, energy: nextEnergy };
36
+ }
37
+
38
+ /** Keyboard step for the slider, in %: arrows move 2, Shift+arrows 10, Home/End jump to the ends. */
39
+ export function keyStep(key: string, shift: boolean, current: number): number | null {
40
+ const step = shift ? 10 : 2;
41
+ switch (key) {
42
+ case "ArrowLeft":
43
+ case "ArrowDown":
44
+ return Math.max(0, current - step);
45
+ case "ArrowRight":
46
+ case "ArrowUp":
47
+ return Math.min(100, current + step);
48
+ case "PageDown":
49
+ return Math.max(0, current - 10);
50
+ case "PageUp":
51
+ return Math.min(100, current + 10);
52
+ case "Home":
53
+ return 0;
54
+ case "End":
55
+ return 100;
56
+ default:
57
+ return null;
58
+ }
59
+ }
60
+ </script>
61
+
1
62
  <script lang="ts">
2
- import { onMount } from "svelte";
63
+ import { onMount, untrack } from "svelte";
3
64
  import { cn } from "../../utils";
4
- import StarField from "./StarField.svelte";
65
+ import { createReducedMotion } from "../_internals/motion/media-query.svelte.js";
5
66
 
6
67
  interface Props {
7
68
  firstImage?: string;
@@ -16,6 +77,10 @@
16
77
  showHandlebar?: boolean;
17
78
  autoplay?: boolean;
18
79
  autoplayDuration?: number;
80
+ /** Beam colours: left fringe, centre, right fringe (one or more colours) */
81
+ beamColors?: string[];
82
+ /** Accessible name of the slider */
83
+ label?: string;
19
84
  onpercentagechange?: (percentage: number) => void;
20
85
  ondragstart?: () => void;
21
86
  ondragend?: () => void;
@@ -39,6 +104,8 @@
39
104
  showHandlebar = true,
40
105
  autoplay = false,
41
106
  autoplayDuration = 5000,
107
+ beamColors,
108
+ label = "Comparison slider",
42
109
  onpercentagechange,
43
110
  ondragstart,
44
111
  ondragend,
@@ -49,12 +116,30 @@
49
116
  handle,
50
117
  }: Props = $props();
51
118
 
119
+ const reducedMotion = createReducedMotion();
120
+ $effect(() => reducedMotion.start());
121
+
52
122
  let sliderRef: HTMLDivElement;
53
- let sliderXPercent = $state(initialSliderPercentage);
123
+ let sliderXPercent = $state(untrack(() => initialSliderPercentage));
54
124
  let isDragging = $state(false);
55
125
  let isMouseOver = $state(false);
56
126
  let isInteracting = $state(false);
57
127
  let autoplayRAF: number | null = null;
128
+ let returnRAF: number | null = null;
129
+
130
+ // the beam's motion: trail (px, signed) and energy (0 idle … 1 lit)
131
+ let trail = $state(0);
132
+ let energy = $state(0);
133
+ let beamRAF: number | null = null;
134
+ let lastPx: number | null = null;
135
+
136
+ const palette = $derived(beamPalette(beamColors));
137
+
138
+ function setPercent(p: number): void {
139
+ const next = Math.max(0, Math.min(100, p));
140
+ sliderXPercent = next;
141
+ onpercentagechange?.(next);
142
+ }
58
143
 
59
144
  function startAutoplay(): void {
60
145
  if (!autoplay || isMouseOver || isDragging) return;
@@ -68,10 +153,11 @@
68
153
 
69
154
  const elapsedTime = Date.now() - startTime;
70
155
  const progress = (elapsedTime % (autoplayDuration * 2)) / autoplayDuration;
71
- const percentage = progress <= 1 ? progress * 100 : (2 - progress) * 100;
156
+ // eased back-and-forth: slow at the ends, quick through the middle
157
+ const linear = progress <= 1 ? progress : 2 - progress;
158
+ const eased = linear < 0.5 ? 4 * linear ** 3 : 1 - (-2 * linear + 2) ** 3 / 2;
72
159
 
73
- sliderXPercent = percentage;
74
- onpercentagechange?.(percentage);
160
+ setPercent(eased * 100);
75
161
  autoplayRAF = requestAnimationFrame(animate);
76
162
  }
77
163
 
@@ -85,8 +171,35 @@
85
171
  }
86
172
  }
87
173
 
174
+ function stopReturn(): void {
175
+ if (returnRAF) {
176
+ cancelAnimationFrame(returnRAF);
177
+ returnRAF = null;
178
+ }
179
+ }
180
+
181
+ /** Glide the seam back to its resting place instead of snapping. */
182
+ function glideTo(target: number): void {
183
+ stopReturn();
184
+ if (reducedMotion.current) {
185
+ setPercent(target);
186
+ return;
187
+ }
188
+ const from = sliderXPercent;
189
+ const start = performance.now();
190
+ const duration = 520;
191
+ const step = (now: number) => {
192
+ const k = Math.min(1, (now - start) / duration);
193
+ const e = 1 - (1 - k) ** 3;
194
+ setPercent(from + (target - from) * e);
195
+ returnRAF = k < 1 ? requestAnimationFrame(step) : null;
196
+ };
197
+ returnRAF = requestAnimationFrame(step);
198
+ }
199
+
88
200
  function mouseEnterHandler(): void {
89
201
  isMouseOver = true;
202
+ stopReturn();
90
203
  onhoverenter?.();
91
204
  if (autoplay) {
92
205
  stopAutoplay();
@@ -99,14 +212,14 @@
99
212
  onhoverleave?.();
100
213
 
101
214
  if (slideMode === "hover") {
102
- sliderXPercent = initialSliderPercentage;
103
- onpercentagechange?.(initialSliderPercentage);
215
+ glideTo(initialSliderPercentage);
104
216
  }
105
217
  if (slideMode === "drag") {
106
218
  isDragging = false;
107
219
  }
108
220
 
109
221
  if (autoplay) {
222
+ stopReturn();
110
223
  startAutoplay();
111
224
  }
112
225
  }
@@ -137,16 +250,13 @@
137
250
  if (slideMode === "hover" || (slideMode === "drag" && isDragging)) {
138
251
  isInteracting = true;
139
252
  stopAutoplay();
253
+ stopReturn();
140
254
 
141
255
  const rect = sliderRef.getBoundingClientRect();
142
256
  const x = clientX - rect.left;
143
257
  const percent = (x / rect.width) * 100;
144
258
 
145
- requestAnimationFrame(() => {
146
- const newPercent = Math.max(0, Math.min(100, percent));
147
- sliderXPercent = newPercent;
148
- onpercentagechange?.(newPercent);
149
- });
259
+ requestAnimationFrame(() => setPercent(percent));
150
260
  }
151
261
  }
152
262
 
@@ -170,6 +280,41 @@
170
280
  if (!autoplay) handleMove(e.touches[0].clientX);
171
281
  }
172
282
 
283
+ function handleKeydown(e: KeyboardEvent): void {
284
+ const next = keyStep(e.key, e.shiftKey, sliderXPercent);
285
+ if (next === null) return;
286
+ e.preventDefault();
287
+ stopAutoplay();
288
+ stopReturn();
289
+ setPercent(next);
290
+ }
291
+
292
+ /** Run the beam's motion until it has settled. */
293
+ function wakeBeam(): void {
294
+ if (beamRAF !== null || reducedMotion.current) return;
295
+ const tick = () => {
296
+ const width = sliderRef?.clientWidth ?? 0;
297
+ const px = (sliderXPercent / 100) * width;
298
+ const delta = lastPx === null ? 0 : px - lastPx;
299
+ lastPx = px;
300
+ const target = isInteracting || isDragging ? 1 : isMouseOver ? 0.7 : 0;
301
+ const next = beamStep(trail, energy, delta, target);
302
+ trail = next.trail;
303
+ energy = next.energy;
304
+ const settled = trail === 0 && delta === 0 && Math.abs(energy - target) < 0.01;
305
+ beamRAF = settled ? null : requestAnimationFrame(tick);
306
+ };
307
+ beamRAF = requestAnimationFrame(tick);
308
+ }
309
+
310
+ // any movement of the seam (pointer, keys, autoplay, glide) wakes the beam
311
+ $effect(() => {
312
+ void sliderXPercent;
313
+ void isInteracting;
314
+ void isMouseOver;
315
+ untrack(wakeBeam);
316
+ });
317
+
173
318
  // Watch for initialSliderPercentage changes
174
319
  $effect(() => {
175
320
  sliderXPercent = initialSliderPercentage;
@@ -188,15 +333,23 @@
188
333
  startAutoplay();
189
334
  return () => {
190
335
  stopAutoplay();
336
+ stopReturn();
337
+ if (beamRAF !== null) cancelAnimationFrame(beamRAF);
191
338
  };
192
339
  });
193
340
  </script>
194
341
 
195
342
  <div
196
343
  bind:this={sliderRef}
197
- class={cn("h-[400px] w-[400px] overflow-hidden", className)}
344
+ class={cn("compare h-[400px] w-[400px] overflow-hidden", className)}
198
345
  style:position="relative"
199
- style:cursor={slideMode === "drag" ? "grab" : "col-resize"}
346
+ style:cursor={slideMode === "drag" ? (isDragging ? "grabbing" : "grab") : "col-resize"}
347
+ style:--cmp-c1={palette[0]}
348
+ style:--cmp-c2={palette[1]}
349
+ style:--cmp-c3={palette[2]}
350
+ style:--cmp-energy={energy.toFixed(3)}
351
+ style:--cmp-trail={trail.toFixed(1)}
352
+ data-active={isInteracting || isDragging ? "" : undefined}
200
353
  onmousemove={handleMouseMove}
201
354
  onmouseleave={mouseLeaveHandler}
202
355
  onmouseenter={mouseEnterHandler}
@@ -205,50 +358,42 @@
205
358
  ontouchstart={handleTouchStart}
206
359
  ontouchend={handleTouchEnd}
207
360
  ontouchmove={handleTouchMove}
361
+ onkeydown={handleKeydown}
208
362
  role="slider"
209
- aria-valuenow={sliderXPercent}
363
+ aria-label={label}
364
+ aria-valuenow={Math.round(sliderXPercent)}
365
+ aria-valuetext="{Math.round(sliderXPercent)}% {firstImageAlt}"
210
366
  aria-valuemin={0}
211
367
  aria-valuemax={100}
368
+ aria-orientation="horizontal"
212
369
  tabindex="0"
213
370
  >
214
- <!-- Slider Line -->
371
+ <!-- The seam: a blade of light, screened onto both sides -->
215
372
  <div
216
- class="pointer-events-none absolute top-0 z-40 m-auto h-full w-px bg-gradient-to-b from-transparent from-5% via-indigo-500 to-transparent to-95%"
373
+ class="compare-beam pointer-events-none absolute top-0 z-40 h-full w-px"
217
374
  style:left="{sliderXPercent}%"
375
+ aria-hidden="true"
218
376
  >
219
- <!-- Decorative Effects -->
220
- <div
221
- class="absolute top-1/2 left-0 z-20 h-full w-36 -translate-y-1/2 bg-gradient-to-r from-indigo-400 via-transparent to-transparent [mask-image:radial-gradient(100px_at_left,white,transparent)] opacity-50"
222
- ></div>
223
- <div
224
- class="absolute top-1/2 left-0 z-10 h-1/2 w-10 -translate-y-1/2 bg-gradient-to-r from-cyan-400 via-transparent to-transparent [mask-image:radial-gradient(50px_at_left,white,transparent)] opacity-100"
225
- ></div>
226
- <div
227
- class="absolute top-1/2 -right-10 h-3/4 w-10 -translate-y-1/2 [mask-image:radial-gradient(100px_at_left,white,transparent)]"
228
- >
229
- <StarField starsCount={120} class="size-full" />
230
- </div>
377
+ <span class="compare-beam__glow"></span>
378
+ <span class="compare-beam__trail"></span>
379
+ <span class="compare-beam__fringe"></span>
380
+ <span class="compare-beam__core"></span>
381
+ <span class="compare-beam__pulse"></span>
382
+ <span class="compare-beam__pulse compare-beam__pulse--late"></span>
383
+ </div>
231
384
 
232
- <!-- Custom Handle Slot -->
385
+ <!-- Handle, above the beam and not blended with it -->
386
+ <div
387
+ class="compare-seam pointer-events-none absolute top-0 z-[41] h-full w-px"
388
+ style:left="{sliderXPercent}%"
389
+ >
233
390
  {#if handle}
234
391
  {@render handle()}
235
392
  {:else if showHandlebar}
236
- <div
237
- class="pointer-events-auto absolute top-1/2 -right-2.5 z-30 flex size-5 -translate-y-1/2 cursor-grab items-center justify-center rounded-md bg-white shadow-[0px_-1px_0px_0px_#FFFFFF40]"
238
- >
239
- <svg
240
- xmlns="http://www.w3.org/2000/svg"
241
- fill="none"
242
- viewBox="0 0 24 24"
243
- stroke-width="1.5"
244
- stroke="currentColor"
245
- class="size-4 text-black"
246
- >
247
- <path
248
- stroke-linecap="round"
249
- stroke-linejoin="round"
250
- d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z"
251
- />
393
+ <div class="compare-handle pointer-events-auto" aria-hidden="true">
394
+ <span class="compare-handle__ring"></span>
395
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="size-4">
396
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 7 4 12l5 5M15 7l5 5-5 5" />
252
397
  </svg>
253
398
  </div>
254
399
  {/if}
@@ -302,3 +447,215 @@
302
447
  {/if}
303
448
  </div>
304
449
  </div>
450
+
451
+ <style>
452
+ @property --cmp-angle {
453
+ syntax: "<angle>";
454
+ inherits: false;
455
+ initial-value: 0deg;
456
+ }
457
+
458
+ .compare:focus-visible {
459
+ outline: 2px solid var(--cmp-c2);
460
+ outline-offset: 3px;
461
+ }
462
+
463
+ /* the whole beam screens onto the pictures: light adds, never darkens */
464
+ .compare-beam {
465
+ mix-blend-mode: screen;
466
+ }
467
+
468
+ .compare-beam > span {
469
+ position: absolute;
470
+ pointer-events: none;
471
+ }
472
+
473
+ /* The light the beam spills on both sides: chromatic, brighter when active. */
474
+ .compare-beam__glow {
475
+ top: 0;
476
+ bottom: 0;
477
+ left: -70px;
478
+ width: 140px;
479
+ background: linear-gradient(
480
+ to right,
481
+ transparent,
482
+ color-mix(in srgb, var(--cmp-c1) 55%, transparent) 38%,
483
+ var(--cmp-c2) 50%,
484
+ color-mix(in srgb, var(--cmp-c3) 55%, transparent) 62%,
485
+ transparent
486
+ );
487
+ -webkit-mask: linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
488
+ mask: linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
489
+ filter: blur(18px);
490
+ opacity: calc(0.35 + var(--cmp-energy) * 0.55);
491
+ }
492
+
493
+ /* The trail: stretched out behind the seam by its speed (the sign picks the side). */
494
+ .compare-beam__trail {
495
+ top: 12%;
496
+ bottom: 12%;
497
+ left: 0;
498
+ width: 100px;
499
+ transform-origin: left center;
500
+ transform: scaleX(calc(var(--cmp-trail) / -100));
501
+ background: linear-gradient(
502
+ to right,
503
+ #fff,
504
+ var(--cmp-c2) 18%,
505
+ color-mix(in srgb, var(--cmp-c3) 50%, transparent) 55%,
506
+ transparent
507
+ );
508
+ -webkit-mask: linear-gradient(to bottom, transparent, #000 30%, #000 70%, transparent);
509
+ mask: linear-gradient(to bottom, transparent, #000 30%, #000 70%, transparent);
510
+ filter: blur(6px);
511
+ opacity: 0.75;
512
+ }
513
+
514
+ /* A chromatic fringe either side of the core. */
515
+ .compare-beam__fringe {
516
+ top: 0;
517
+ bottom: 0;
518
+ left: -4px;
519
+ width: 9px;
520
+ background: linear-gradient(
521
+ to right,
522
+ var(--cmp-c1),
523
+ transparent 45%,
524
+ transparent 55%,
525
+ var(--cmp-c3)
526
+ );
527
+ -webkit-mask: linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);
528
+ mask: linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);
529
+ filter: blur(1.5px);
530
+ opacity: calc(0.55 + var(--cmp-energy) * 0.45);
531
+ }
532
+
533
+ /* The white-hot core. */
534
+ .compare-beam__core {
535
+ top: 0;
536
+ bottom: 0;
537
+ left: -0.75px;
538
+ width: 1.5px;
539
+ background: linear-gradient(
540
+ to bottom,
541
+ transparent,
542
+ color-mix(in srgb, var(--cmp-c2) 40%, #fff) 6%,
543
+ #fff 30%,
544
+ #fff 70%,
545
+ color-mix(in srgb, var(--cmp-c2) 40%, #fff) 94%,
546
+ transparent
547
+ );
548
+ box-shadow: 0 0 6px 0.5px color-mix(in srgb, var(--cmp-c2) 80%, transparent);
549
+ }
550
+
551
+ /* Pulses of light running down the seam. */
552
+ .compare-beam__pulse {
553
+ top: 0;
554
+ left: -1.5px;
555
+ width: 3px;
556
+ height: 26%;
557
+ border-radius: 999px;
558
+ background: linear-gradient(to bottom, transparent, #fff 50%, transparent);
559
+ filter: drop-shadow(0 0 4px var(--cmp-c2)) drop-shadow(0 0 10px var(--cmp-c1));
560
+ opacity: 0;
561
+ }
562
+
563
+ @media (prefers-reduced-motion: no-preference) {
564
+ .compare-beam__pulse {
565
+ animation: cmp-pulse 3.2s cubic-bezier(0.45, 0, 0.55, 1) infinite;
566
+ }
567
+ .compare-beam__pulse--late {
568
+ animation-delay: -1.6s;
569
+ animation-duration: 3.2s;
570
+ }
571
+ .compare[data-active] .compare-beam__pulse {
572
+ animation-duration: 1.6s;
573
+ }
574
+ .compare[data-active] .compare-beam__pulse--late {
575
+ animation-delay: -0.8s;
576
+ }
577
+ .compare-handle__ring {
578
+ animation: cmp-spin 4s linear infinite;
579
+ }
580
+ .compare[data-active] .compare-handle__ring {
581
+ animation-duration: 1.4s;
582
+ }
583
+ }
584
+
585
+ @keyframes cmp-pulse {
586
+ 0% {
587
+ transform: translateY(-100%);
588
+ opacity: 0;
589
+ }
590
+ 15% {
591
+ opacity: 1;
592
+ }
593
+ 85% {
594
+ opacity: 1;
595
+ }
596
+ 100% {
597
+ transform: translateY(385%);
598
+ opacity: 0;
599
+ }
600
+ }
601
+
602
+ /* The handle: a glass bead on the beam with a ring of light round it. */
603
+ .compare-handle {
604
+ position: absolute;
605
+ top: 50%;
606
+ left: 0;
607
+ z-index: 30;
608
+ display: flex;
609
+ align-items: center;
610
+ justify-content: center;
611
+ width: 34px;
612
+ height: 34px;
613
+ border-radius: 999px;
614
+ transform: translate(-50%, -50%) scale(calc(1 + var(--cmp-energy) * 0.08));
615
+ color: #fff;
616
+ background: rgb(255 255 255 / 0.12);
617
+ backdrop-filter: blur(10px) saturate(1.4);
618
+ -webkit-backdrop-filter: blur(10px) saturate(1.4);
619
+ box-shadow:
620
+ inset 0 1px 0 rgb(255 255 255 / 0.35),
621
+ inset 0 0 0 1px rgb(255 255 255 / 0.18),
622
+ 0 4px 16px rgb(0 0 0 / 0.35),
623
+ 0 0 calc(8px + var(--cmp-energy) * 18px) color-mix(in srgb, var(--cmp-c2) 70%, transparent);
624
+ cursor: inherit;
625
+ }
626
+
627
+ .compare-handle svg {
628
+ filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.45));
629
+ }
630
+
631
+ .compare-handle__ring {
632
+ position: absolute;
633
+ inset: -1.5px;
634
+ border-radius: inherit;
635
+ padding: 1.5px;
636
+ background: conic-gradient(
637
+ from var(--cmp-angle),
638
+ transparent 0%,
639
+ var(--cmp-c1) 18%,
640
+ #fff 30%,
641
+ var(--cmp-c3) 42%,
642
+ transparent 58%,
643
+ transparent 100%
644
+ );
645
+ -webkit-mask:
646
+ linear-gradient(#000 0 0) content-box,
647
+ linear-gradient(#000 0 0);
648
+ -webkit-mask-composite: xor;
649
+ mask:
650
+ linear-gradient(#000 0 0) content-box,
651
+ linear-gradient(#000 0 0);
652
+ mask-composite: exclude;
653
+ opacity: calc(0.45 + var(--cmp-energy) * 0.55);
654
+ }
655
+
656
+ @keyframes cmp-spin {
657
+ to {
658
+ --cmp-angle: 360deg;
659
+ }
660
+ }
661
+ </style>
@@ -1,3 +1,25 @@
1
+ /**
2
+ * Compare - a before/after slider split by a blade of light
3
+ *
4
+ * The seam is a beam: a white-hot core with a chromatic fringe, a glow
5
+ * that spills onto both sides, and pulses of light running along it. Moving
6
+ * the seam leaves a trail behind it that stretches with the speed of the
7
+ * gesture and settles when it stops.
8
+ */
9
+ export declare const COMPARE_BEAM_COLORS: string[];
10
+ /** Three colours for the beam (left fringe, centre, right fringe), from one to many given. */
11
+ export declare function beamPalette(colors: string[] | undefined): [string, string, string];
12
+ /**
13
+ * One frame of the beam's motion: the trail follows the seam's velocity
14
+ * (px per frame, signed) and decays; the energy eases toward its target
15
+ * and is kicked up by speed.
16
+ */
17
+ export declare function beamStep(trail: number, energy: number, deltaPx: number, target: number): {
18
+ trail: number;
19
+ energy: number;
20
+ };
21
+ /** Keyboard step for the slider, in %: arrows move 2, Shift+arrows 10, Home/End jump to the ends. */
22
+ export declare function keyStep(key: string, shift: boolean, current: number): number | null;
1
23
  interface Props {
2
24
  firstImage?: string;
3
25
  secondImage?: string;
@@ -11,6 +33,10 @@ interface Props {
11
33
  showHandlebar?: boolean;
12
34
  autoplay?: boolean;
13
35
  autoplayDuration?: number;
36
+ /** Beam colours: left fringe, centre, right fringe (one or more colours) */
37
+ beamColors?: string[];
38
+ /** Accessible name of the slider */
39
+ label?: string;
14
40
  onpercentagechange?: (percentage: number) => void;
15
41
  ondragstart?: () => void;
16
42
  ondragend?: () => void;
@@ -1,6 +1,6 @@
1
- import { render, cleanup } from "@testing-library/svelte";
1
+ import { render, cleanup, fireEvent } from "@testing-library/svelte";
2
2
  import { afterEach, describe, it, expect } from "vitest";
3
- import Compare from "./Compare.svelte";
3
+ import Compare, { beamPalette, beamStep, keyStep, COMPARE_BEAM_COLORS } from "./Compare.svelte";
4
4
  describe("Compare", () => {
5
5
  afterEach(cleanup);
6
6
  it('renders a container with role="slider"', () => {
@@ -55,3 +55,83 @@ describe("Compare", () => {
55
55
  expect(slider).toHaveAttribute("aria-valuemax", "100");
56
56
  });
57
57
  });
58
+ describe("Compare beam", () => {
59
+ afterEach(cleanup);
60
+ it("renders the beam layers and a glass handle", () => {
61
+ const { container } = render(Compare);
62
+ for (const part of ["glow", "trail", "fringe", "core", "pulse"]) {
63
+ expect(container.querySelector(`.compare-beam__${part}`)).toBeInTheDocument();
64
+ }
65
+ expect(container.querySelector(".compare-handle")).toBeInTheDocument();
66
+ });
67
+ it("hides the handle when showHandlebar is false", () => {
68
+ const { container } = render(Compare, { props: { showHandlebar: false } });
69
+ expect(container.querySelector(".compare-handle")).toBeNull();
70
+ });
71
+ it("sets the beam colours as CSS variables", () => {
72
+ const { container } = render(Compare, { props: { beamColors: ["red", "lime", "blue"] } });
73
+ const root = container.querySelector('[role="slider"]');
74
+ expect(root.style.getPropertyValue("--cmp-c1")).toBe("red");
75
+ expect(root.style.getPropertyValue("--cmp-c2")).toBe("lime");
76
+ expect(root.style.getPropertyValue("--cmp-c3")).toBe("blue");
77
+ });
78
+ it("has an accessible name", () => {
79
+ const { container } = render(Compare, { props: { label: "Before and after" } });
80
+ expect(container.querySelector('[role="slider"]')).toHaveAttribute("aria-label", "Before and after");
81
+ });
82
+ it("moves with the arrow keys, Shift for big steps, Home/End for the ends", async () => {
83
+ const seen = [];
84
+ const { container } = render(Compare, {
85
+ props: { onpercentagechange: (p) => seen.push(p) },
86
+ });
87
+ const slider = container.querySelector('[role="slider"]');
88
+ await fireEvent.keyDown(slider, { key: "ArrowRight" });
89
+ expect(slider).toHaveAttribute("aria-valuenow", "52");
90
+ await fireEvent.keyDown(slider, { key: "ArrowLeft", shiftKey: true });
91
+ expect(slider).toHaveAttribute("aria-valuenow", "42");
92
+ await fireEvent.keyDown(slider, { key: "End" });
93
+ expect(slider).toHaveAttribute("aria-valuenow", "100");
94
+ await fireEvent.keyDown(slider, { key: "Home" });
95
+ expect(slider).toHaveAttribute("aria-valuenow", "0");
96
+ expect(seen).toEqual([52, 42, 100, 0]);
97
+ });
98
+ });
99
+ describe("beamPalette", () => {
100
+ it("falls back to the default colours", () => {
101
+ expect(beamPalette(undefined)).toEqual(COMPARE_BEAM_COLORS);
102
+ expect(beamPalette([])).toEqual(COMPARE_BEAM_COLORS);
103
+ });
104
+ it("spreads one, two or many colours over three slots", () => {
105
+ expect(beamPalette(["red"])).toEqual(["red", "red", "red"]);
106
+ expect(beamPalette(["red", "blue"])).toEqual(["red", "color-mix(in srgb, red, blue)", "blue"]);
107
+ expect(beamPalette(["a", "b", "c", "d", "e"])).toEqual(["a", "c", "e"]);
108
+ });
109
+ });
110
+ describe("beamStep", () => {
111
+ it("stretches the trail with speed, on the side of the motion", () => {
112
+ expect(beamStep(0, 0, 10, 0).trail).toBeGreaterThan(0);
113
+ expect(beamStep(0, 0, -10, 0).trail).toBeLessThan(0);
114
+ });
115
+ it("caps the trail and lets it settle to zero", () => {
116
+ expect(beamStep(0, 0, 1000, 0).trail).toBe(140);
117
+ let s = { trail: 60, energy: 0 };
118
+ for (let i = 0; i < 200; i++)
119
+ s = beamStep(s.trail, s.energy, 0, 0);
120
+ expect(s.trail).toBe(0);
121
+ });
122
+ it("eases the energy toward its target, kicked up by speed", () => {
123
+ let s = { trail: 0, energy: 0 };
124
+ for (let i = 0; i < 200; i++)
125
+ s = beamStep(s.trail, s.energy, 0, 1);
126
+ expect(s.energy).toBeCloseTo(1, 2);
127
+ expect(beamStep(0, 0, 20, 0).energy).toBeGreaterThan(0);
128
+ });
129
+ });
130
+ describe("keyStep", () => {
131
+ it("maps keys to positions and clamps", () => {
132
+ expect(keyStep("ArrowRight", false, 99)).toBe(100);
133
+ expect(keyStep("ArrowLeft", true, 5)).toBe(0);
134
+ expect(keyStep("PageUp", false, 50)).toBe(60);
135
+ expect(keyStep("Enter", false, 50)).toBeNull();
136
+ });
137
+ });