67 lines
1.8 KiB
Vue
67 lines
1.8 KiB
Vue
<script setup lang="ts">
|
|
// 숫자 카운트업 — 요소가 보이면 0부터 목표값까지 증가 애니메이션
|
|
// SSR/no-JS 환경에서는 최종값을 그대로 렌더(SEO·접근성), 클라이언트에서만 애니메이션
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
|
|
interface Props {
|
|
value: number
|
|
duration?: number // ms
|
|
locale?: boolean // 천 단위 구분기호(ko-KR)
|
|
}
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
duration: 1600,
|
|
locale: true,
|
|
})
|
|
|
|
const display = ref<number>(props.value) // 초기값 = 최종값 (SSR 노출)
|
|
const elRef = ref<HTMLElement | null>(null)
|
|
let io: IntersectionObserver | null = null
|
|
let rafId = 0
|
|
|
|
const formatted = computed(() =>
|
|
props.locale ? display.value.toLocaleString('ko-KR') : String(display.value),
|
|
)
|
|
|
|
function animate() {
|
|
const start = performance.now()
|
|
const from = 0
|
|
const to = props.value
|
|
const tick = (now: number) => {
|
|
const t = Math.min(1, (now - start) / props.duration)
|
|
// easeOutCubic — 빠르게 시작해 부드럽게 감속
|
|
const eased = 1 - Math.pow(1 - t, 3)
|
|
display.value = Math.round(from + (to - from) * eased)
|
|
if (t < 1) rafId = requestAnimationFrame(tick)
|
|
}
|
|
rafId = requestAnimationFrame(tick)
|
|
}
|
|
|
|
onMounted(() => {
|
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
if (reduceMotion || !elRef.value) return
|
|
|
|
display.value = 0 // 클라이언트에서 0부터 시작
|
|
io = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
animate()
|
|
io?.disconnect()
|
|
}
|
|
}
|
|
},
|
|
{ threshold: 0.4 },
|
|
)
|
|
io.observe(elRef.value)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
io?.disconnect()
|
|
if (rafId) cancelAnimationFrame(rafId)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<span ref="elRef">{{ formatted }}</span>
|
|
</template>
|