~james/core

core/web/src/lib/components/ui/NumberFlip.svelte -rw-r--r-- 2.2 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
<script lang="ts">
	import { untrack } from "svelte";

	interface Props {
		value: number;
		format?: (value: number) => string;
	}

	let { value, format = String }: Props = $props();

	// a rise brings the new digits up from below, a drop brings them down from above
	let direction = $state(1);
	let flipped = $state(false);
	let leaving = $state<string | null>(null);
	let previous = untrack(() => value);
	$effect.pre(() => {
		if (value !== previous) {
			direction = value > previous ? 1 : -1;
			leaving = format(previous);
			previous = value;
			flipped = true;
		}
	});

	const text = $derived(format(value));

	const settle = (event: AnimationEvent & { currentTarget: HTMLElement }) => {
		if (event.target === event.currentTarget.lastElementChild) leaving = null;
	};
</script>

<span class="number" style="--flip: {direction}">
	<span class="sr-only">{text}</span>
	{#key text}
		<span class="digits" class:enter={untrack(() => flipped)} aria-hidden="true">
			{#each [...text] as char, index (index)}
				<span class="digit" style="--stagger: {Math.min(index, 2)}">{char}</span>
			{/each}
		</span>
	{/key}
	{#if leaving !== null}
		{#key leaving}
			<span class="digits leave" aria-hidden="true" onanimationend={settle}>
				{#each [...leaving] as char, index (index)}
					<span class="digit" style="--stagger: {Math.min(index, 2)}">{char}</span>
				{/each}
			</span>
		{/key}
	{/if}
</span>

<style>
	.number {
		position: relative;
		display: inline-flex;
		font-variant-numeric: tabular-nums;
	}

	.digits {
		display: inline-flex;
		align-items: baseline;
	}

	.digit {
		display: inline-block;
	}

	.leave {
		position: absolute;
		inset: 0;
		pointer-events: none;
	}

	.enter .digit,
	.leave .digit {
		animation: 500ms cubic-bezier(0.34, 1.45, 0.64, 1) calc(var(--stagger) * 70ms) both;
	}

	.enter .digit {
		animation-name: pop-in;
	}

	.leave .digit {
		animation-name: pop-out;
	}

	@keyframes pop-in {
		from {
			opacity: 0;
			translate: 0 calc(var(--flip) * 8px);
			filter: blur(2px);
		}
	}

	@keyframes pop-out {
		to {
			opacity: 0;
			translate: 0 calc(var(--flip) * -8px);
			filter: blur(2px);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.enter .digit {
			animation: none;
		}

		.leave {
			display: none;
		}
	}
</style>