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.
- package/dist/fancy-ui/button/Button.svelte +24 -2
- package/dist/fancy-ui/button/README.md +2 -2
- package/dist/fancy-ui/compare/Compare.svelte +404 -47
- package/dist/fancy-ui/compare/Compare.svelte.d.ts +26 -0
- package/dist/fancy-ui/compare/Compare.test.js +82 -2
- package/dist/fancy-ui/compare/README.md +9 -2
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.js +24 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte +479 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte.d.ts +69 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.js +250 -0
- package/dist/fancy-ui/datamosh-transition/README.md +87 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.d.ts +180 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.js +462 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.d.ts +1 -0
- package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.js +309 -0
- package/dist/fancy-ui/datamosh-transition/index.d.ts +3 -0
- package/dist/fancy-ui/datamosh-transition/index.js +3 -0
- package/dist/fancy-ui/flip-card/FlipCard.svelte +373 -32
- package/dist/fancy-ui/flip-card/FlipCard.svelte.d.ts +39 -2
- package/dist/fancy-ui/flip-card/FlipCard.test.js +125 -25
- package/dist/fancy-ui/flip-card/README.md +41 -14
- package/dist/fancy-ui/flip-card/index.d.ts +1 -1
- package/dist/fancy-ui/glow-border/GlowBorder.svelte +256 -33
- package/dist/fancy-ui/glow-border/GlowBorder.svelte.d.ts +41 -2
- package/dist/fancy-ui/glow-border/GlowBorder.test.js +45 -1
- package/dist/fancy-ui/glow-border/README.md +25 -33
- package/dist/fancy-ui/glow-border/index.d.ts +1 -1
- package/dist/fancy-ui/glow-border/index.js +1 -1
- package/dist/fancy-ui/gradient-button/GradientButton.svelte +123 -42
- package/dist/fancy-ui/gradient-button/GradientButton.svelte.d.ts +10 -5
- package/dist/fancy-ui/gradient-button/GradientButton.test.js +22 -5
- package/dist/fancy-ui/gradient-button/README.md +14 -6
- package/dist/fancy-ui/index.d.ts +1 -0
- package/dist/fancy-ui/index.js +1 -0
- package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.svelte +166 -23
- package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.test.js +18 -68
- package/dist/fancy-ui/interactive-hover-button/README.md +21 -15
- package/dist/fancy-ui/meteors/Meteors.svelte +179 -18
- package/dist/fancy-ui/meteors/Meteors.svelte.d.ts +32 -2
- package/dist/fancy-ui/meteors/Meteors.test.js +70 -22
- package/dist/fancy-ui/meteors/README.md +21 -6
- package/dist/fancy-ui/neon-border/NeonBorder.svelte +187 -58
- package/dist/fancy-ui/neon-border/NeonBorder.svelte.d.ts +24 -5
- package/dist/fancy-ui/neon-border/NeonBorder.test.js +51 -19
- package/dist/fancy-ui/neon-border/README.md +21 -11
- package/dist/fancy-ui/pagination/Pagination.svelte +144 -31
- package/dist/fancy-ui/pagination/Pagination.test.js +64 -8
- package/dist/fancy-ui/pagination/README.md +32 -22
- package/dist/fancy-ui/rainbow-button/README.md +3 -2
- package/dist/fancy-ui/rainbow-button/RainbowButton.svelte +108 -0
- package/dist/fancy-ui/rainbow-button/RainbowButton.test.js +12 -0
- package/dist/fancy-ui/registry.js +256 -41
- package/dist/fancy-ui/ripple-button/README.md +10 -7
- package/dist/fancy-ui/ripple-button/RippleButton.svelte +172 -19
- package/dist/fancy-ui/ripple-button/RippleButton.svelte.d.ts +20 -1
- package/dist/fancy-ui/ripple-button/RippleButton.test.js +47 -1
- package/dist/fancy-ui/shimmer-button/README.md +14 -6
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +137 -54
- package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte.d.ts +3 -3
- package/dist/fancy-ui/shimmer-button/ShimmerButton.test.js +21 -7
- 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
|
|
283
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
<!--
|
|
371
|
+
<!-- The seam: a blade of light, screened onto both sides -->
|
|
215
372
|
<div
|
|
216
|
-
class="pointer-events-none absolute top-0 z-40
|
|
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
|
-
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
></
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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="
|
|
238
|
-
|
|
239
|
-
|
|
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;
|