[Framer Motion] Framer Motion을 정복해보자😵‍💫-1(4)

📌실습파일 전체 다운: https://github.com/yeom-kenco/framer-motion-study-kit.git

 

저는 framer motion으로 스크롤애니메이션 구현할때마다,

특정 임계값에 스크롤이 걸치면 무한으로 애니메이션이 트리거되는 현상이 일어나더라고요...

 

저번 잡생각 프로젝트때도 이것때문에 진짜 고생고생해서 현상이 거의 안보이게 완화시키는 것 까지는 성공했지만,

100%해결하지는 못했었습니다. 다른분들은 어떻게 이 점을 해결하셨는지 궁금하네요!

 

저는 이번 학습에서 들어올 때 기준과, 나갈 때 기준을 다르게 두는 완충(버퍼) 작용을 해주는 훅을 사용해서 해결했습니다!!

바로 비교될 수 있도록 3-1, 3-2로 기존 코드와 함께 영상으로 글 마지막에 정리해두었습니다~!!

 

Week 1 — Step 4: whileInView & viewport(스크롤 진입 재생)

이번 스텝은 “스크롤로 화면에 들어올 때마다 애니메이션을 재생하게 만드는 법”을 손에 익힌다.

 

개념 핵심

  • whileInView={{ ... }}
    요소가 뷰포트에 들어오는 순간 whileInView 상태로 전환
  • viewport={{ once, amount, margin }}
    • once: boolean → 한 번만 재생할지, 들어올 때마다 재생할지
    • amount: 0~1 | "some" | "all" → 요소의 얼마나 보이면 트리거되는지(비율)
    • margin: string → 조금 일찍/늦게 트리거하고 싶을 때, IntersectionObserver의 rootMargin(예: "-100px 0px")

 

 

붙여 넣는 실습 컴포넌트 (한 줄씩 따라가기)

더보기
// Step4_InViewViewport.tsx
import { useState } from "react";
import { MotionConfig, motion, useInView } from "framer-motion";

export default function Step4_InViewViewport() {
  const [seed, setSeed] = useState(0); // remount용 (스크롤 없이도 재시작 테스트 가능)

  return (
    <MotionConfig reducedMotion="never">
      <div
        style={{
          padding: 24,
          maxWidth: 900,
          margin: "0 auto",
          borderTop: "1px solid #e5e7eb",
          marginTop: 40,
        }}
      >
        <h1>Week1: Step4</h1>
        {/* 0) Replay 버튼: 전체 섹션을 remount해서 '한 번만 재생' 케이스도 반복 확인 */}
        <div
          style={{
            display: "flex",
            gap: 12,
            alignItems: "center",
            marginBottom: 16,
          }}
        >
          <button
            onClick={() => setSeed((s) => s + 1)}
            style={{
              padding: "8px 12px",
              borderRadius: 10,
              border: "1px solid #e5e7eb",
              background: "#111",
              color: "#fff",
            }}
          >
            🔁 Replay (remount)
          </button>
          <span style={{ color: "#6b7280" }}>
            스크롤 없이도 다시 실험할 수 있어요.
          </span>
        </div>

        {/* --- 스크롤 여백을 위해 더미 컨텐츠 --- */}
        <Spacer />
        {/* key를 바꿔 remount → 각 섹션 초기화 */}
        <section key={`s1-${seed}`}>
          <DemoOnce />
        </section>
        <Spacer />
        <section key={`s2-${seed}`}>
          <DemoRepeat />
        </section>
        <Spacer />
        <section key={`s3-${seed}`}>
          <DemoMargin />
        </section>
        <Spacer />
        <section key={`s4-${seed}`}>
          <DemoHook />
        </section>
        <Spacer />
      </div>
    </MotionConfig>
  );
}

/* ------------------------------------------------------------------ */
/* 1) once: true — 한 번만 재생                                           */
/* ------------------------------------------------------------------ */
function DemoOnce() {
  return (
    <div>
      <h3 style={{ fontWeight: 600, marginBottom: 8 }}>
        1) once: true (한 번만 재생)
      </h3>
      <p style={{ color: "#6b7280", marginBottom: 12 }}>
        요소가 화면에 처음 들어올 때 한 번만 애니메이션. 다시 스크롤해도
        재생되지 않음.
      </p>

      <motion.div
        initial={{ opacity: 0, y: 40 }}
        whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: true, amount: 0.3 }} // 30% 보이면 트리거, 이후에는 다시 안 함
        transition={{ type: "spring", stiffness: 320, damping: 24 }}
        style={{ height: 90, borderRadius: 12, background: "#e5e7eb" }}
      />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* 2) once: false — 들어올 때마다 재생                                     */
/* ------------------------------------------------------------------ */
function DemoRepeat() {
  return (
    <div>
      <h3 style={{ fontWeight: 600, marginBottom: 8 }}>
        2) once: false (반복 재생)
      </h3>
      <p style={{ color: "#6b7280", marginBottom: 12 }}>
        뷰포트에서 나갔다가 다시 들어오면 매번 재생. 데모를 보려면 위/아래로
        넘나들며 스크롤.
      </p>

      <motion.div
        initial={{ opacity: 0, y: 60 }}
        whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: false, amount: 0.5 }} // 50%가 보일 때마다 트리거
        transition={{ type: "spring", stiffness: 300, damping: 26 }}
        style={{ height: 90, borderRadius: 12, background: "#ddd6fe" }}
      />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* 3) margin — 조금 일찍 트리거 (상단에 닿기도 전에 시작)                     */
/* ------------------------------------------------------------------ */
function DemoMargin() {
  return (
    <div>
      <h3 style={{ fontWeight: 600, marginBottom: 8 }}>
        3) viewport.margin (조금 일찍 시작)
      </h3>
      <p style={{ color: "#6b7280", marginBottom: 12 }}>
        margin으로 트리거 시점을 앞당겨 '스크롤 도착하기 전에' 미리 애니메이션을
        시작.
      </p>

      <motion.div
        initial={{ opacity: 0, y: 40 }}
        whileInView={{ opacity: 1, y: 0 }}
        viewport={{
          once: false,
          amount: 0.3,
          margin: "-120px 0px -120px 0px", // 위/아래로 120px 당겨서 일찍/늦게 트리거
        }}
        transition={{ type: "spring", stiffness: 320, damping: 24 }}
        style={{ height: 90, borderRadius: 12, background: "#a7f3d0" }}
      />
      <p style={{ color: "#6b7280", fontSize: 13, marginTop: 8 }}>
        스크롤 상단에 딱 닿기 전에 자연스럽게 등장하는 느낌을 만들 때 유용.
      </p>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* 4) useInView 훅 — 더 세밀하게 제어(상태/클래스 토글, 연쇄 트리거 등)          */
/* ------------------------------------------------------------------ */
function DemoHook() {
  const ref = useRefDiv(); // ref + inView 관찰 유틸(아래 정의)
  return (
    <div>
      <h3 style={{ fontWeight: 600, marginBottom: 8 }}>
        4) useInView 훅 (커스텀 제어)
      </h3>
      <p style={{ color: "#6b7280", marginBottom: 12 }}>
        훅을 쓰면 "보이는지 여부"로 상태를 바꾸거나, 다른 애니메이션을
        연쇄적으로 트리거할 수 있어.
      </p>

      <motion.div
        ref={ref.el} // 이 요소를 관찰
        initial={{ opacity: 0, scale: 0.95 }}
        animate={
          ref.inView ? { opacity: 1, scale: 1 } : { opacity: 0.6, scale: 0.98 }
        }
        transition={{ type: "spring", stiffness: 340, damping: 24 }}
        style={{
          height: 100,
          borderRadius: 12,
          background: ref.inView ? "#fee2e2" : "#f3f4f6", // inView에 따라 배경 변경
          display: "grid",
          placeItems: "center",
          fontWeight: 600,
        }}
      >
        {ref.inView ? "보이는 중 (inView=true)" : "아직 멀어요 (inView=false)"}
      </motion.div>
      <p style={{ color: "#6b7280", fontSize: 13, marginTop: 8 }}>
        이 방식은 <code>whileInView</code> 대신, <code>inView</code> 상태로 더
        많은 로직을 엮고 싶을 때 적합.
      </p>
    </div>
  );
}

/* ---- 유틸: useRef + useInView 묶음 --------------------------------- */
import { useRef } from "react";
function useRefDiv() {
  const el = useRef<HTMLDivElement | null>(null);
  const inView = useInView(el, { amount: 0.5, margin: "-80px 0px" }); // 50% 보일 때, 위쪽 80px 당겨서 트리거
  return { el, inView };
}

/* ---- 스페이서: 스크롤 여백용 -------------------------------------- */
function Spacer() {
  return <div style={{ height: 240 }} />;
}

라인별 포인트

  • whileInView={{ ... }}: 들어올 때 적용할 스타일(= animate 타겟)
  • viewport={{ once, amount, margin }}: 언제/어떻게 트리거할지 조정
  • useInView(ref, opts): “보이는지 여부(boolean)”를 직접 받아서, 다른 상태/로직과 연결 가능
  • margin: "-120px 0px": 위쪽 경계를 위로 당겨 더 일찍 시작하게 만듦
  • amount: 0.3 / 0.5: 요소의 30%/50%가 보일 때 트리거

 

손풀기 미션 (5분)

  1. 한 번만 vs 반복
    DemoRepeat의 once:false → true로 바꿔 보고, 스크롤로 재진입해도 재생 안 되는지 확인.
  2. 느낌 조정
    amount: 0.3 → 0.8로 바꾸면 거의 다 보여야 트리거됨(늦게 시작). 차이를 눈으로 비교.
  3. 일찍 시작
    margin: "-200px 0px -200px 0px"로 조정해 영역 바깥에서 미리 시작되는지 체감.
  4. 훅 커스터마이즈
    useRefDiv에서 amount: 0.2로 낮추고, inView일 때만 콘솔 로그(console.log("enter")) 찍어보기.

 

자주 막히는 포인트 (빠른 해결)

  • 아무 반응이 없다
    • 부모/조상에 overflow: hidden이 걸려 있으면 관찰 지점이 달라져 트리거가 늦거나 안 될 수 있음.
    • 요소가 이미 화면 안에 있을 경우(특히 개발 서버 핫리로드 후), 애니메이션이 바로 끝난 상태로 보일 수 있어 → 스크롤로 한 번 빼냈다가 다시 진입해보기
  • 너무 빨리/늦게 시작한다
    • amount와 margin을 함께 조절. 일찍 시작하려면 margin을 음수로(위쪽을 끌어당김).
  • 모바일에서 체감 약함
    • 스크롤 속도/뷰포트 높이 차이. amount를 0.2~0.3으로 낮추거나 y 변위를 키워 시각차를 크게.

 

체크리스트

  • whileInView가 뷰포트 진입 시점에 애니메이션을 트리거한다는 걸 이해했다.
  • viewport.once / amount / margin으로 재생 횟수/임계치/시점을 조절할 수 있다.
  • useInView 훅으로 inView 상태를 로직과 연결하는 패턴을 설명할 수 있다.
  • overflow, 초기 위치, 모바일 뷰포트 등 현실적인 함정을 처리할 수 있다.

(추가학습) 스크롤 경계값 무한 애니메이션 트리거를 해결해보자!

 

해결 방법: 히스테리시스 훅 적용하기

히스테리시스 훅이란?

사실 처음 들어본 말이다. GPT가 명명해준 훅 이름...

한마디로 “들어올 때 기준과, 나갈 때 기준을 다르게 두는 완충(버퍼)”다.

예를 들어,

  • 들어올 때는 기준을 좀 느슨하게(예: 60% 보이면 “들어왔다”)
  • 나갈 때는 기준을 좀 빡세게(예: 40% 미만으로 줄어들면 “나갔다”)
    → 이렇게 두 기준 사이에 여유를 둬서, 경계에서 들어옴↔나감이 깜빡깜빡 무한 반복되는 걸 막는 방법.

말로만 보면 뭘 해결하겠다는건지 잘 와닿지 않을 수 있다.

그래서 아래 영상을 함께 첨부해보겠다! 3-1 3-2를 눈여겨 비교해보면 된다!

3-1은 스크롤 경계값에서 애니메이션이 무한 트리거 되는 현상이 나타나지만, 3-2에서는 그렇지않다.

 

해결한 코드는 다음과 같다!

 

(useInViewHysterisis.ts)

// useInViewHysteresis.ts
import { useEffect, useRef, useState } from "react";

type Options = {
  enter?: number; // 이 비율 이상 보이면 true로 전환 (예: 0.6)
  leave?: number; // 이 비율 이하로 줄면 false로 전환 (예: 0.4)
  debounceMs?: number; // 급한 토글을 흡수하는 지연(ms)
  rootMargin?: string; // viewport.margin과 동일 개념 (예: "140px 0px")
};

const THRESHOLDS = Array.from({ length: 21 }, (_, i) => i / 20); // 0,0.05,...,1

export function useInViewHysteresis<T extends HTMLElement>({
  enter = 0.6,
  leave = 0.4,
  debounceMs = 120,
  rootMargin = "140px 0px", // 위/아래 140px 확장 → 조금 일찍 감지
}: Options = {}) {
  const el = useRef<T | null>(null);
  const [inView, setInView] = useState(false);
  const timer = useRef<number | null>(null);

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

    const io = new IntersectionObserver(
      (entries) => {
        const ratio = entries[0].intersectionRatio; // 0~1
        // 현재 상태에 따라 다음 상태 판정 기준을 다르게 적용
        const next = inView ? ratio > leave : ratio >= enter;

        if (next !== inView) {
          if (timer.current) window.clearTimeout(timer.current);
          timer.current = window.setTimeout(() => setInView(next), debounceMs);
        }
      },
      { root: null, rootMargin, threshold: THRESHOLDS }
    );

    io.observe(node);
    return () => {
      io.disconnect();
      if (timer.current) window.clearTimeout(timer.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [enter, leave, debounceMs, rootMargin, inView]); // inView를 의존성에 포함해 현재 상태 기반 판정

  return { el, inView };
}

 

(위의 실습 컴포넌트에 3번 아래에 3-2로 추가 붙여넣기한 DemoMargin2)

function DemoMargin2() {
  // 들어올 때는 60%만 보여도 true, 나갈 때는 40% 이하로 줄어들어야 false
  // 관찰 상자는 위/아래 140px 확장(조기 감지), 120ms 디바운스
  const { el, inView } = useInViewHysteresis<HTMLDivElement>({
    enter: 0.3,
    leave: 0.1,
    rootMargin: "-120px 0px",
    debounceMs: 120,
  });

  return (
    <div>
      <h3 style={{ fontWeight: 600, marginBottom: 8 }}>
        3-2) viewport.margin + 완충(히스테리시스) 버전
      </h3>
      <p style={{ color: "#6b7280", marginBottom: 12 }}>
        경계에서 무한 애니메이션이 일어나는 3-1과 달리 들어올 때/나갈 때 기준을
        다르게 둬서 경계 깜빡임을 줄였습니다.
      </p>

      <motion.div
        ref={el}
        initial={{ opacity: 0, y: 40 }}
        animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
        transition={{
          type: "spring",
          stiffness: 320,
          damping: 24,
          duration: 0.5,
        }}
        style={{ height: 90, borderRadius: 12, background: "#a7f3d0" }}
      />
    </div>
  );
}

 

다음은 드디어 Week2로 넘어갑니다!