훅(Hook)

useScrollProgress

ResizeObserver와 RAF로 안정화된 스크롤 진행도(0~1) 훅

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

제작 과정

왜 만들었나

애플 키노트 같은 sticky 안무 스크롤 진행에 따라 텍스트가 scale/blur/opacity로 변화 를 framer-motion 없이 만들고 싶었습니다. 핵심은 "요소 기준 스크롤 진행도(0~1)" 한 값이고, 그것만 안정적으로 노출하면 나머지는 inline style로 풀린다는 직관에서 출발했습니다.

두 가지 모드

  • enter-exit: 요소가 viewport 하단에 닿을 때 0, 상단을 벗어날 때 1. 일반적인 등장 효과용.

  • sticky: ref가 sticky 자식이고 부모가 wrapper일 때, 부모 기준 진행도. 풀스크린 sticky 시퀀스용.

안정화 처리

Next.js의 client navigation으로 페이지를 떠났다 돌아왔을 때 sticky가 멈춰버리는 문제를 겪었습니다. 원인은 mount 시 한 번만 측정하는데 그 시점에 layout이 안정화되지 않은 경우였습니다. 해결로 다음을 모두 적용했습니다.

  • mount 직후 즉시 측정 + double RAF로 한 번 더 (폰트이미지 로드 후 보정)

  • ResizeObserver로 element와 부모 크기 변화 시 재측정

  • visibilitychange / pageshow 이벤트로 bfcache 복원 시 재측정

mapRange 헬퍼

0~1 progress를 임의의 출력 범위로 매핑. mapRange(progress, 0.05, 0.45, 0, 1) 같이 쓰면 "5%에서 시작해 45%에서 끝나는 구간 동안 01로 변화"가 한 줄로 표현됩니다.

언제 좋은가

  • framer-motion보다 훨씬 가벼움 번들 크기 0 추가

  • RAF throttle + ResizeObserver로 client navigation 케이스에 강함

  • mapRange와 짝지어 inline style만으로 어떤 안무도 표현 가능

언제 별로인가

  • 여러 keyframe물리 기반 같은 복잡한 시퀀스는 framer-motion이 적합

  • sticky 모드는 ref를 반드시 sticky 자식에 붙여야 함 wrapper에 붙이면 진행도가 페이지 전체 기준이 되어 의도와 달라짐

소스 코드

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

interface Options {
  mode?: 'enter-exit' | 'sticky';
}

export function useScrollProgress<T extends HTMLElement = HTMLDivElement>(
  options: Options = {},
): { ref: RefObject<T | null>; progress: number } {
  const { mode = 'enter-exit' } = options;
  const ref = useRef<T | null>(null);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let rafId: number | null = null;

    const compute = () => {
      const rect = el.getBoundingClientRect();
      const vh = window.innerHeight;
 if (mode === 'sticky') {
        const parent = el.parentElement;
 if (!parent) return 0;
        const pRect = parent.getBoundingClientRect();
        const total = pRect.height - vh;
        if (total <= 0) return 0;
        return Math.min(Math.max(-pRect.top, 0), total) / total;
      }
      const total = rect.height + vh;
      const scrolled = vh - rect.top;
 return Math.min(Math.max(scrolled / total, 0), 1);
    };

 const measure = () => {
      if (rafId !== null) return;
      rafId = requestAnimationFrame(() => {
        rafId = null;
 setProgress(compute());
      });
    };

    measure();
 requestAnimationFrame(() => requestAnimationFrame(measure));
 window.addEventListener('scroll', measure, { passive: true });
 window.addEventListener('resize', measure, { passive: true });

 const ro = new ResizeObserver(measure);
    ro.observe(el);
    if (el.parentElement) ro.observe(el.parentElement);

    return () => {
      window.removeEventListener('scroll', measure);
 window.removeEventListener('resize', measure);
 ro.disconnect();
      if (rafId !== null) cancelAnimationFrame(rafId);
    };
  }, [mode]);

  return { ref, progress };
}

export function mapRange(p: number, inMin: number, inMax: number, outMin: number, outMax: number) {
  if (inMax === inMin) return outMin;
  const t = Math.min(Math.max((p - inMin) / (inMax - inMin), 0), 1);
  return outMin + (outMax - outMin) * t;
}
40조회수

댓글