훅(Hook)

useScrollReveal / useStaggerReveal

JS는 트리거만, 모션은 CSS transition이 그리는 가벼운 등장 효과 훅

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

제작 과정

왜 두 갈래로 나눴나

섹션 헤더 한 덩어리를 등장시키는 단일 reveal과, 카드 그리드를 시차로 이어 등장시키는 stagger reveal은 책임이 달라서 한 훅 안에 옵션으로 묶기보다 작은 훅 둘로 분리하는 편이 호출부가 깔끔했습니다.

설계 원칙

  • JS는 트리거만 IntersectionObserver가 .revealed 클래스를 부여하면 끝. 실제 모션 표현은 CSS가 담당.

  • stagger는 setTimeout 한 번씩 별도 RAF 루프 없이 가벼움.

  • repeat 옵션으로 재진입 시 다시 fade되는 형태도 지원.

함께 쓰는 CSS

초기 hidden 상태를 위한 .scroll-reveal 또는 .scroll-reveal-blur 같은 클래스가 필요합니다. 별도 스니펫(.scroll-reveal-blur)을 참고하세요.

언제 좋은가

  • 코드가 짧고 GPU 가속이 잘 들어가 모바일에서도 부드러움

  • 의존성 0 어떤 프로젝트에든 즉시 붙일 수 있음

언제 별로인가

  • 물리 기반 시뮬레이션이나 복잡한 keyframe 시퀀스에는 framer-motion이 더 표현이 풍부함

  • 초기 hidden CSS 클래스가 별도로 필요해 두 군데를 함께 관리해야 함

소스 코드

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

interface Options {
  threshold?: number;
  rootMargin?: string;
  repeat?:boolean;
}

export function useScrollReveal<T extends HTMLElement = HTMLDivElement>(
  options: Options = {},
): RefObject<T | null> {
 const ref = useRef<T | null>(null);
  const { threshold = 0.15, rootMargin = '0px 0px -60px 0px', repeat = false } = options;

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

    const observer = new IntersectionObserver(
 ([entry]) => {
        if (entry.isIntersecting) {
 el.classList.add('revealed');
          if (!repeat) observer.unobserve(el);
        } else if (repeat) {
 el.classList.remove('revealed');
        }
      },
      { threshold, rootMargin },
    );

    observer.observe(el);
 return () => observer.disconnect();
  }, [threshold, rootMargin, repeat]);

  return ref;
}

export function useStaggerReveal<Textends HTMLElement = HTMLDivElement>(
  options: Options & { staggerMs?: number } = {},
): RefObject<T | null> {
  const ref = useRef<T | null>(null);
  const { threshold = 0.1, rootMargin = '0px 0px -40px 0px', staggerMs = 120 } = options;

  useEffect(() => {
 const container = ref.current;
    if (!container) return;
 const children = container.querySelectorAll<HTMLElement>('[data-reveal]');

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        children.forEach((child, i) => {
          setTimeout(() => child.classList.add('revealed'), i * staggerMs);
        });
        observer.unobserve(container);
 },
      { threshold, rootMargin },
    );

 observer.observe(container);
    return () => observer.disconnect();
 }, [threshold, rootMargin, staggerMs]);

  return ref;
}
34조회수

댓글