69 lines
1.7 KiB
Vue
69 lines
1.7 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|
|
|
interface Props {
|
|
end: number
|
|
duration?: number
|
|
suffix?: string
|
|
decimals?: number
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
duration: 2000,
|
|
suffix: '',
|
|
decimals: 0,
|
|
})
|
|
|
|
const count = ref(0)
|
|
const refEl = ref<HTMLDivElement | null>(null)
|
|
let hasAnimated = false
|
|
let rafId: number | null = null
|
|
let observer: IntersectionObserver | null = null
|
|
|
|
onMounted(() => {
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0].isIntersecting && !hasAnimated) {
|
|
hasAnimated = true
|
|
let startTime: number | null = null
|
|
|
|
const animate = (currentTime: number) => {
|
|
if (!startTime) startTime = currentTime
|
|
const progress = Math.min((currentTime - startTime) / props.duration, 1)
|
|
const easeProgress = 1 - Math.pow(1 - progress, 4)
|
|
count.value = props.end * easeProgress
|
|
|
|
if (progress < 1) {
|
|
rafId = requestAnimationFrame(animate)
|
|
} else {
|
|
count.value = props.end
|
|
}
|
|
}
|
|
|
|
rafId = requestAnimationFrame(animate)
|
|
}
|
|
},
|
|
{ threshold: 0.5 }
|
|
)
|
|
|
|
if (refEl.value) {
|
|
observer.observe(refEl.value)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (observer && refEl.value) observer.disconnect()
|
|
if (rafId) cancelAnimationFrame(rafId)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="refEl"
|
|
class="text-4xl md:text-5xl font-bold text-apple-dark mb-2 transition-transform group-hover:scale-110 duration-300 tabular-nums"
|
|
>
|
|
{{ count.toFixed(props.decimals) }}
|
|
<span v-if="props.suffix" class="text-2xl ml-1 font-medium text-gray-400">{{ props.suffix }}</span>
|
|
</div>
|
|
</template>
|