훅(Hook)

뷰포트 진입 시 RAF 카운트업 훅

IntersectionObserver로 진입을 감지한 뒤 cubicease-out으로 0에서 목표값까지 부드럽게 카운트업하는 작은 React 훅

React 19TypeScriptIntersectionObserverrequestAnimationFrame
라이브 데모
새 탭에서 열기
데모 불러오는 중…

제작 과정

왜 만들었나

포트폴리오 메인의 통계 섹션처럼, 사용자가 직접 보는 순간에만 숫자가 자라나는 효과가 필요했습니다. 라이브러리 없이 IntersectionObserver와 requestAnimationFrame만으로 충분히 만들 수 있어서 작은 훅 하나로 정리했습니다.

핵심 동작

  1. ref가 viewport에 진입하면 IntersectionObserver가 한 번만 트리거

  2. RAF 루프에서 elapsed 비율을 cubic ease-out으로 보간 setState로 React에 반영

  3. 도달 후 unobserve로 자기 자신을 떼어 추가 비용 0

언제 좋은가

  • 사용자가 보지 않는 카운팅을 미리 진행하지 않음 절약과 의도가 모두 잡힘

  • cubic ease-out으로 마지막이 부드럽게 감속되어 자연스러움

  • 한 번 트리거 후 unobserve라 메모리CPU 낭비 없음

언제 별로인가

  • 기본은 1회 실행 반복 트리거가 필요하면 startedRef를 cleanup에서 초기화하거나 옵션을 추가해야 함

  • viewport 안에서 mount되는 hero 영역이라면 즉시 시작되어 의도와 다를 수 있음 (delay 옵션 추가 가능)

의존성

React 18+, 브라우저 IntersectionObserver. 그 외 라이브러리 없음.

소스 코드

import { useEffect, useRef, useState, type RefObject } from  'react';

interface ountUpOptions {
  end: number;
  start?: number;
  duration?: number;
  threshold?: number;
  decimals?: number;
}

export function useCountUp<T extends HTMLElement = HTMLDivElement>({
  end,
  start = 0,
  duration = 1800,
 threshold = 0.4,
  decimals = 0,
}: CountUpOptions): { ref: RefObject<T | null>; value: string } {
  const ref = useRef<T | null>(null);
  const [value, setValue] = useState(start);
  const startedRef = useRef(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting || startedRef.current) return;
 startedRef.current = true;

        const startTime = performance.now();
        const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);

        const tick = (now: number) => {
 const elapsed = now - startTime;
          const progress = Math.min(elapsed / duration, 1);
          setValue(start + (end - start) * easeOut(progress));
          if (progress < 1) requestAnimationFrame(tick);
          else setValue(end);
 };

        requestAnimationFrame(tick);
 observer.unobserve(el);
      },
      { threshold },
    );

     observer.observe(el);
    return () => observer.disconnect();
  }, [end, start, duration, threshold]);

  const formatted = decimals > 0 ? value.toFixed(decimals) : Math.round(value).toString();
  return { ref, value: formatted };
}
58조회수

댓글