스크롤 애니메이션: '언제(Event)'가 아니라 '얼마나(Progress)'의 문제

스크롤 애니메이션: '언제(Event)'가 아니라 '얼마나(Progress)'의 문제

2026년 2월 20일

스크롤하면 섹션이 화면에 고정되고, 좌우 텍스트와 중앙 프리뷰가 차례로 등장하는 쇼케이스를 만들려 했습니다.

“IntersectionObserver로 진입 감지하면 되겠지”라고 생각했지만, 실제로 필요한 건 이벤트가 아니라 진행률이었습니다.


1. 문제 상황

japdongsani 메인 페이지에는 컴포넌트를 하나씩 소개하는 쇼케이스 섹션이 있다. 스크롤하면 섹션이 화면에 고정되면서, 세 영역이 순서대로 등장하는 구조였다.

  • 좌측: 컴포넌트의 탄생 배경 텍스트 (왼쪽에서 슬라이드인)
  • 중앙: 컴포넌트 실물 프리뷰 (scale + opacity)
  • 우측: 컴포넌트 이름과 문서 링크 (오른쪽에서 슬라이드인)

그리고 충분히 지나가면 반대 방향으로 퇴장하며 다음 섹션으로 넘어간다.

개념은 단순했다. 문제는 이걸 어떻게 코드로 표현하느냐였다.

2. 내가 처음 한 생각

처음엔 IntersectionObserver가 머리에 떠올랐다.

  • 섹션이 뷰포트에 들어오면 isVisible = true
  • CSS 클래스를 붙여서 애니메이션 재생
// 처음 머릿속에 그린 구조
const { ref, inView } = useInView({ threshold: 0.3 });

<div
  ref={ref}
  className={`transition-all duration-700 ${
    inView ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-10"
  }`}
>

Framer Motion의 whileInView도 비슷하게 생각했다. 진입하면 애니메이션이 트리거되는 방식.

그런데 조금 생각해보니 둘 다 “들어왔냐 / 나갔냐”만 안다. 내가 필요한 건 달랐다.

“스크롤이 이 섹션의 30% 지점에 왔을 때 텍스트가 완전히 자리를 잡아야 하고, 75% 지점에서 퇴장을 시작해야 한다.”

이건 enter/exit 이벤트가 아니라 스크롤 진행률에 연속적으로 반응하는 값이 필요한 문제였다.

3. 실제로 해 본 선택

사고방식을 바꿨다.

“이건 이벤트 기반이 아니라 진행률 기반이다.”

Framer Motion의 useScroll과 useTransform을 택했다. 스크롤 진행률(0~1)을 모션 값으로 직접 매핑하는 방식이다.

핵심 패턴: sticky + h-[200vh]

가장 먼저 풀어야 할 문제가 있었다. 시각적으로는 화면에 고정되어야 하는데, 스크롤할 “거리”가 없으면 진행률이 생기지 않는다.

해결책은 바깥 컨테이너를 h-[200vh]로 키우는 것이었다. 안쪽은 sticky top-0 h-screen으로 고정하면, 사용자가 200vh를 지나는 동안 화면은 멈춰있고 스크롤 거리가 쌓인다.

// 바깥: 스크롤 "시간"을 확보
<div ref={ref} className="relative h-[200vh]">
  {/* 안: 화면에 핀 */}
  <div className="sticky top-0 flex h-screen items-center justify-center">
    {/* 실제 컨텐츠 */}
  </div>
</div>

진행률 추출

const { scrollYProgress } = useScroll({
  target: ref,
  offset: ["start end", "end start"],
});

offset: [“start end”, “end start”]는 요소의 하단이 뷰포트에 닿는 순간을 0, 요소의 상단이 뷰포트를 벗어나는 순간을 1로 잡는다. 즉 요소가 화면에 등장하는 전 구간이 0~1로 매핑된다.

요소별 keyframe 설정

각 요소에 서로 다른 keyframe을 줬다. CSS @keyframes처럼 중간 정지 구간을 표현할 수 있다.

// 좌측 텍스트: 왼쪽에서 슬라이드인, 머물다가 왼쪽으로 퇴장
const leftX = useTransform(
  scrollYProgress,
  [0, 0.3, 0.5, 0.8],
  [-60, 0, 0, -60],
);
const leftOpacity = useTransform(
  scrollYProgress,
  [0, 0.25, 0.5, 0.75],
  [0, 1, 1, 0],
);

// 중앙 프리뷰: 작게 시작해 커지며 등장, 반대로 퇴장
const centerScale = useTransform(
  scrollYProgress,
  [0, 0.25, 0.5, 0.75],
  [0.88, 1, 1, 0.88],
);

progress 0.3~0.5 구간에서 값이 유지되면 “들어와서 잠시 머물다 퇴장하는” 느낌이 난다. 각 요소의 keyframe 구간을 살짝씩 다르게 주면 자연스러운 순차 등장 효과를 얻을 수 있다.

4. 결과

의도한 흐름대로 동작했다.

스크롤하면 좌우 텍스트가 각자의 방향에서 밀려오고, 중앙 프리뷰는 scale되며 나타난다. 충분히 지나가면 같은 방향으로 빠져나가고 다음 섹션이 올라온다.

배운 것들

  • sticky + h-[Nvh] 패턴은 “스크롤 시간”을 임의로 늘릴 수 있는 강력한 기법이다
  • offset: [“start end”, “end start”]는 요소가 뷰포트에 처음 닿는 시점부터 완전히 나가는 시점까지를 0~1로 매핑한다
  • keyframe 배열의 중간 정지 구간으로 “등장 → 유지 → 퇴장”을 표현할 수 있다

다만 keyframe 값을 맞추는 과정이 생각보다 손이 많이 갔다. progress 0.3이 화면에서 어느 시점인지 처음엔 감이 잘 안 왔고, 값을 넣고 새로고침을 반복했다.

5. 지금 다시 해본다면?

progress 값을 먼저 화면에 띄워놨을 것이다.

// 개발 중에만 켜두는 디버그 뷰
<p className="fixed top-4 left-4 z-50 font-mono text-xs text-white">
  {scrollYProgress.get().toFixed(2)}
</p>

스크롤하면서 숫자가 변하는 걸 보면 “텍스트가 들어오는 이 타이밍이 progress 0.27이구나”를 바로 알 수 있다.

그 다음에 keyframe을 짰으면 훨씬 빠르게 완성됐을 것이다. 감으로 0.3, 0.5를 넣고 새로고침하는 루프를 대부분 아낄 수 있었을 거라고 본다.

그리고 처음부터 “이건 이벤트가 아니라 진행률 문제다”라고 정의했다면 IntersectionObserver를 시도해보는 시간도 없었을 것이다. 문제의 성격을 먼저 규정하는 게 도구 선택보다 앞선다.