📌실습파일 전체 다운: 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분)
- 한 번만 vs 반복
DemoRepeat의 once:false → true로 바꿔 보고, 스크롤로 재진입해도 재생 안 되는지 확인. - 느낌 조정
amount: 0.3 → 0.8로 바꾸면 거의 다 보여야 트리거됨(늦게 시작). 차이를 눈으로 비교. - 일찍 시작
margin: "-200px 0px -200px 0px"로 조정해 영역 바깥에서 미리 시작되는지 체감. - 훅 커스터마이즈
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로 넘어갑니다!
'Study Notes > Framer Motion' 카테고리의 다른 글
| [Framer Motion] Framer Motion을 정복해보자😵💫-1(3) (0) | 2025.10.15 |
|---|---|
| [Framer Motion] Framer Motion을 정복해보자😵💫-1(2) (0) | 2025.09.19 |
| [Framer Motion] Framer Motion을 정복해보자😵💫-1(1) (0) | 2025.09.19 |
| [Framer Motion] Framer Motion을 정복해보자😵💫-0 (0) | 2025.09.19 |