<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>