fancy-ui-svelte 0.13.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 (63) hide show
  1. package/dist/fancy-ui/button/Button.svelte +24 -2
  2. package/dist/fancy-ui/button/README.md +2 -2
  3. package/dist/fancy-ui/compare/Compare.svelte +404 -47
  4. package/dist/fancy-ui/compare/Compare.svelte.d.ts +26 -0
  5. package/dist/fancy-ui/compare/Compare.test.js +82 -2
  6. package/dist/fancy-ui/compare/README.md +9 -2
  7. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.d.ts +1 -0
  8. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.js +24 -0
  9. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte +479 -0
  10. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte.d.ts +69 -0
  11. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.d.ts +1 -0
  12. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.js +250 -0
  13. package/dist/fancy-ui/datamosh-transition/README.md +87 -0
  14. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.d.ts +180 -0
  15. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.js +462 -0
  16. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.d.ts +1 -0
  17. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.js +309 -0
  18. package/dist/fancy-ui/datamosh-transition/index.d.ts +3 -0
  19. package/dist/fancy-ui/datamosh-transition/index.js +3 -0
  20. package/dist/fancy-ui/flip-card/FlipCard.svelte +373 -32
  21. package/dist/fancy-ui/flip-card/FlipCard.svelte.d.ts +39 -2
  22. package/dist/fancy-ui/flip-card/FlipCard.test.js +125 -25
  23. package/dist/fancy-ui/flip-card/README.md +41 -14
  24. package/dist/fancy-ui/flip-card/index.d.ts +1 -1
  25. package/dist/fancy-ui/glow-border/GlowBorder.svelte +256 -33
  26. package/dist/fancy-ui/glow-border/GlowBorder.svelte.d.ts +41 -2
  27. package/dist/fancy-ui/glow-border/GlowBorder.test.js +45 -1
  28. package/dist/fancy-ui/glow-border/README.md +25 -33
  29. package/dist/fancy-ui/glow-border/index.d.ts +1 -1
  30. package/dist/fancy-ui/glow-border/index.js +1 -1
  31. package/dist/fancy-ui/gradient-button/GradientButton.svelte +123 -42
  32. package/dist/fancy-ui/gradient-button/GradientButton.svelte.d.ts +10 -5
  33. package/dist/fancy-ui/gradient-button/GradientButton.test.js +22 -5
  34. package/dist/fancy-ui/gradient-button/README.md +14 -6
  35. package/dist/fancy-ui/index.d.ts +1 -0
  36. package/dist/fancy-ui/index.js +1 -0
  37. package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.svelte +166 -23
  38. package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.test.js +18 -68
  39. package/dist/fancy-ui/interactive-hover-button/README.md +21 -15
  40. package/dist/fancy-ui/meteors/Meteors.svelte +179 -18
  41. package/dist/fancy-ui/meteors/Meteors.svelte.d.ts +32 -2
  42. package/dist/fancy-ui/meteors/Meteors.test.js +70 -22
  43. package/dist/fancy-ui/meteors/README.md +21 -6
  44. package/dist/fancy-ui/neon-border/NeonBorder.svelte +187 -58
  45. package/dist/fancy-ui/neon-border/NeonBorder.svelte.d.ts +24 -5
  46. package/dist/fancy-ui/neon-border/NeonBorder.test.js +51 -19
  47. package/dist/fancy-ui/neon-border/README.md +21 -11
  48. package/dist/fancy-ui/pagination/Pagination.svelte +144 -31
  49. package/dist/fancy-ui/pagination/Pagination.test.js +64 -8
  50. package/dist/fancy-ui/pagination/README.md +32 -22
  51. package/dist/fancy-ui/rainbow-button/README.md +3 -2
  52. package/dist/fancy-ui/rainbow-button/RainbowButton.svelte +108 -0
  53. package/dist/fancy-ui/rainbow-button/RainbowButton.test.js +12 -0
  54. package/dist/fancy-ui/registry.js +256 -41
  55. package/dist/fancy-ui/ripple-button/README.md +10 -7
  56. package/dist/fancy-ui/ripple-button/RippleButton.svelte +172 -19
  57. package/dist/fancy-ui/ripple-button/RippleButton.svelte.d.ts +20 -1
  58. package/dist/fancy-ui/ripple-button/RippleButton.test.js +47 -1
  59. package/dist/fancy-ui/shimmer-button/README.md +14 -6
  60. package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +137 -54
  61. package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte.d.ts +3 -3
  62. package/dist/fancy-ui/shimmer-button/ShimmerButton.test.js +21 -7
  63. package/package.json +1 -1
@@ -279,10 +279,18 @@
279
279
  light-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))
280
280
  );
281
281
  /* 150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) =
282
- tokens.EASINGS.inout — the reversible-state pair, because a press
283
- resolves either way (released, or the interaction carries on). */
282
+ tokens.EASINGS.inout — the reversible-state pair. It runs the colour
283
+ channel, and the release half of the press: letting go settles, it
284
+ does not snap back. */
284
285
  --ft-btn-motion: var(--ft-duration-fast, 150ms)
285
286
  var(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));
287
+ /* The down-stroke only. 150ms = tokens.DURATIONS.fast,
288
+ cubic-bezier(0.16, 1, 0.3, 1) = tokens.EASINGS.out — the arrival
289
+ curve, which spends most of the scale in the first frames. Same clock
290
+ as the release, but the button is already down by the time the eye
291
+ reads it, so the press bites instead of easing in. Pressing is an
292
+ arrival; releasing is what resolves either way. */
293
+ --ft-btn-press: var(--ft-duration-fast, 150ms) var(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
286
294
  /*
287
295
  * Replaces the `transition-colors` utility removed from the class string
288
296
  * above, at exactly the values that utility already resolved to. Colour is
@@ -315,6 +323,10 @@
315
323
  * channel under `no-preference` and nowhere else, so with motion reduced the
316
324
  * colours still cross and the button simply does not move. The resting state
317
325
  * (no `transform` at all) is the ungated fallback.
326
+ *
327
+ * The two strokes are timed apart. The rule below carries the release curve,
328
+ * because that is the transition the button runs on its way back to rest;
329
+ * `:active` overrides `transform` with the arrival curve for the way down.
318
330
  */
319
331
  @media (prefers-reduced-motion: no-preference) {
320
332
  .ft-btn {
@@ -332,6 +344,16 @@
332
344
  the attribute selectors are what make the anchor behave like it. */
333
345
  .ft-btn:not([data-disabled="true"]):not([aria-busy="true"]):active {
334
346
  transform: scale(0.97);
347
+ /* The whole list is re-declared rather than just
348
+ `transition-timing-function`, which has no per-property form here:
349
+ a single value would retime the colour channel too, and a
350
+ four-value one would restate the same three curves to change the
351
+ fourth. Only `transform` differs from the rule above. */
352
+ transition:
353
+ color var(--ft-btn-motion),
354
+ background-color var(--ft-btn-motion),
355
+ border-color var(--ft-btn-motion),
356
+ transform var(--ft-btn-press);
335
357
  }
336
358
  }
337
359
 
@@ -96,11 +96,11 @@ It is opt-in and silent by default: nothing plays unless both `sound` is set on
96
96
  ## Motion
97
97
 
98
98
  - **The lead slot cross-fades.** `iconStart` and the spinner share one fixed-size cell — `calc(1em + 1px)`, the same box the spinner already drew itself in, so it follows the size variant for free. Flipping `loading` fades one out while the other fades in, over the same 80 ms, in place. Both are mounted at once for the length of that fade; the cell never resizes, so the label beside it never shifts. A button that starts out `loading` renders its spinner immediately, with no fade in from nothing, and a button given neither a spinner nor an `iconStart` renders no cell at all.
99
- - **Pressing scales the button to `0.97`** for as long as it is held, over 150 ms on `--ft-ease-inout` — the reversible-state curve, because a press resolves either way. A disabled button and a `loading` one are both excluded: a press that does nothing should not pretend to.
99
+ - **Pressing scales the button to `0.97`** for as long as it is held. The two strokes are timed apart: going down runs on `--ft-ease-out`, the arrival curve, which spends most of the scale in the first frames so the press bites rather than easing in; coming back up runs on `--ft-ease-inout`, the reversible-state curve, so letting go settles instead of snapping. Same 150 ms clock either way. A disabled button and a `loading` one are both excluded: a press that does nothing should not pretend to.
100
100
  - The press transition lists `transform` and the three colour properties, and deliberately never `box-shadow`. This button's focus ring _is_ a `box-shadow` (`focus-visible:ring-*` compiles to one), and a focus ring must never animate.
101
101
  - **Reduced motion.** The press scale is declared entirely inside `@media (prefers-reduced-motion: no-preference)`; without that preference the button simply does not move, and the resting state is the ungated fallback. The lead cross-fade collapses to a duration of `0`, which makes the swap synchronous — exactly the instant cut this button did before the fade existed. The spinner's own rotation is likewise gated, and with motion reduced the ring is still drawn, just still, so `aria-busy` keeps a visible companion. Colour is the one channel that stays ungated in both places: a colour change is not motion, and gating it would only make a theme flip look broken for the people who asked for less movement.
102
102
  - **Touch and coarse pointers.** `:active` is exactly the affordance a finger gets, so the press feedback is not suppressed on touch. The button carries `touch-action: manipulation`, which removes the browser's ~300 ms tap delay without blocking scrolling — a button inside a scrollable list stays scrollable while it press-reacts.
103
- - **Timing.** 80 ms, linear, for the lead cross-fade (a fade needs no curve); 150 ms, `--ft-ease-inout` (`cubic-bezier(0.4, 0, 0.2, 1)`), for both the press scale and the colour channel beside it.
103
+ - **Timing.** 80 ms, linear, for the lead cross-fade (a fade needs no curve); 150 ms everywhere else. The colour channel and the press release run on `--ft-ease-inout` (`cubic-bezier(0.4, 0, 0.2, 1)`), the press down-stroke on `--ft-ease-out` (`cubic-bezier(0.16, 1, 0.3, 1)`). The `:active` rule re-declares the whole transition list to retime that one property, because `transition-timing-function` has no per-property form that would not restate the other three curves anyway.
104
104
 
105
105
  ## Implementation notes
106
106
 
@@ -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;