Files
2026-05-30 15:02:45 +09:00

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>