Framer Motion whileInView 애니메이션이 스크린샷에서 사라지는 이유와 해결법
Framer Motion whileInView로 만든 스크롤 애니메이션이 Chrome DevTools MCP 스크린샷이나 OG 이미지 생성에서 보이지 않는 원인은 IntersectionObserver의 뷰포트 의존성이다. initial hidden 상태가 캡처되는 근본 원인과 3가지 해결 전략을 실전 코드로 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- whileInView 애니메이션은 IntersectionObserver 기반이라 뷰포트에 들어와야 트리거됨
- Chrome MCP 스크린샷·OG 이미지 생성기는 스크롤 없이 현재 뷰포트만 캡처해서 요소가
initial="hidden"상태로 찍힘- 해결법 1: viewport.once + fallback CSS 조합으로 no-JS 환경 대응
- 해결법 2: useReducedMotion 훅으로 접근성과 스크린샷 호환 동시 확보
- 해결법 3: SSR/SSG 환경에서는 **initial=**로 서버 렌더링 시 숨김 방지
디자인 리뷰 미팅이었다. Chrome MCP로 스크린샷을 찍어서 공유하는데, 페이지 하단 섹션이 통째로 안 보인다.
DOM을 열어보면 요소는 있다.
opacity: 0으로 숨겨져 있을 뿐이다.
Framer Motion의 whileInView를 쓴 컴포넌트들이 전부 initial="hidden" 상태에서 멈춰 있었다.
스크롤을 안 했으니 뷰포트에 진입한 적이 없는 거다.
이건 버그가 아니다. IntersectionObserver의 정상 동작이다. 근데 그걸 알아내기까지 한 시간을 소모했다.
🔥 증상 — 스크린샷에 요소가 없다

실제 브라우저에서 보면 문제 없는 페이지다. 스크롤하면 카드들이 하나씩 fade-in 되면서 올라온다. 꽤 깔끔한 애니메이션이다.
그런데 스크린샷을 찍으면 이런 결과가 나온다.
❌ 스크린샷에서 보이는 상태
┌─────────────────────────────┐
│ Header │ ← 정상
│ Hero Section │ ← 정상
│ Feature Cards │ ← 정상 (뷰포트 내)
│ │
│ ────── fold line ────── │
│ │ ← 여기서부터
│ (빈 공간) │ ← opacity: 0
│ (빈 공간) │ ← opacity: 0
│ (빈 공간) │ ← opacity: 0
│ Footer │ ← opacity: 0
└─────────────────────────────┘
Chrome DevTools의 Elements 탭에서 확인하면 DOM 노드는 있다.
Computed 탭에서 보면 opacity: 0, transform: translateY(20px).
정확히 initial prop에 넣어둔 값이다.
📌 핵심: 요소가 “없는” 게 아니라 “안 보이는” 상태다. DOM에는 있지만 IntersectionObserver가 뷰포트 진입을 감지하지 못해 애니메이션이 트리거되지 않은 것이다.
이 문제는 단순 스크린샷뿐 아니라 여러 상황에서 재현된다.
- Chrome DevTools MCP 스크린샷
- Puppeteer/Playwright 기반 OG 이미지 생성
- 접근성 도구(Lighthouse, axe)의 정적 분석
prefers-reduced-motion설정 사용자- SSR/SSG 빌드 시 서버 사이드 렌더링 결과
🔍 탐색 — IntersectionObserver의 뷰포트 의존성
처음엔 CSS 이슈인 줄 알았다.
z-index가 꼬였나, overflow: hidden이 먹었나 확인했다.
아니었다.
다음으로 Framer Motion 버전 버그를 의심했다. GitHub Issues를 뒤졌다. 관련 이슈가 있긴 했지만, “이건 정상 동작”이라는 답변뿐이었다.
결국 whileInView의 내부 구현을 추적해야 했다.
⚠️ whileInView의 동작 원리

whileInView는 내부적으로 IntersectionObserver API를 사용한다.
정확히는 Motion 라이브러리가 성능을 위해 pooled IntersectionObserver를 운영한다.
동작 플로우는 이렇다.
- 컴포넌트 마운트 시
initial상태로 렌더링 (예:opacity: 0) - IntersectionObserver가 요소를 감시 등록
- 요소가 뷰포트에 진입하면 콜백 실행
whileInView타겟으로 애니메이션 트리거 (예:opacity: 1)- 요소가 뷰포트를 벗어나면 다시
initial상태로 복귀 (once가 아닌 경우)
// Framer Motion 내부 동작 (간소화)
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// ✅ 뷰포트 진입 → whileInView 애니메이션 실행
controls.start("visible")
} else {
// 뷰포트 이탈 → initial 상태로 복귀
controls.start("hidden")
}
})
},
{ rootMargin: "-30px" } // viewport.margin 설정값
)
💡 팁: IntersectionObserver의
rootMargin기본값은"0px"이다. Motion의viewport.margin으로 커스터마이징할 수 있는데, 음수 마진을 쓰면 요소가 뷰포트 안쪽으로 더 들어와야 트리거된다.
왜 스크린샷에서 문제가 되는가
핵심은 스크린샷 도구가 스크롤을 하지 않는다는 점이다.
Puppeteer의 page.screenshot()이든, Chrome MCP의 스크린샷이든, 페이지를 로드한 뒤 현재 뷰포트 상태를 그대로 캡처한다.
fullPage: true 옵션을 써도 마찬가지다.
전체 페이지를 한 장으로 찍지만, 실제로 스크롤 이벤트를 발생시키는 건 아니다.
// Puppeteer fullPage 스크린샷
await page.screenshot({ fullPage: true })
// ↑ 전체 페이지를 이어붙이지만, IntersectionObserver는 트리거되지 않음
IntersectionObserver는 뷰포트 기준으로 동작한다.
뷰포트 바깥 요소는 아직 isIntersecting: false다.
따라서 whileInView 애니메이션은 실행되지 않는다.
결과적으로 스크린샷에는 initial 상태 — opacity: 0 — 의 투명한 요소만 찍힌다.
⚠️ 주의:
fullPage: true가 IntersectionObserver를 트리거할 거라고 기대하면 안 된다. fullPage는 뷰포트를 문서 전체 높이로 확장하는 게 아니라, 뷰포트를 여러 번 이동하며 캡처한 이미지를 이어붙이는 방식이다.
🛠️ 해결 — 3가지 전략

전략 1: viewport.once + fallback 클래스
가장 실용적인 방법이다.
once: true로 한 번만 애니메이션하면서, CSS fallback으로 no-JS 환경을 대응한다.
❌ Before — fallback 없는 whileInView
// FadeInSection.tsx
// ❌ 스크린샷 도구에서 opacity: 0으로 고정됨
function FadeInSection({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ margin: "-30px" }}
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
>
{children}
</motion.div>
)
}
이 코드의 문제점은 두 가지다.
- 스크롤하지 않으면 영원히
hidden상태 once가 없어서 스크롤 아웃 시 다시 사라짐
✅ After — viewport.once + CSS fallback
// FadeInSection.tsx
// ✅ once로 한 번만 애니메이션 + noscript 대응
import { motion } from "motion/react"
function FadeInSection({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
// once: true → 한 번 보이면 visible 상태 유지
// 스크롤 아웃해도 다시 숨겨지지 않음
variants={{
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: "easeOut" },
},
}}
className="fade-in-section"
>
{children}
</motion.div>
)
}
CSS fallback을 함께 적용한다.
/* globals.css */
/* JS 비활성화 또는 IntersectionObserver 미지원 환경 대응 */
@media (scripting: none) {
.fade-in-section {
opacity: 1 !important;
transform: none !important;
}
}
📌 핵심:
viewport.once: true는 SEO에도 유리하다. 검색 크롤러가 페이지를 렌더링할 때 스크롤을 시뮬레이션하는데, once가 없으면 크롤러가 스크롤 아웃 시 콘텐츠를 다시 숨긴 상태로 인식할 수 있다.
전략 2: useReducedMotion 훅 활용
접근성과 스크린샷 호환을 동시에 잡는 방법이다.
prefers-reduced-motion 미디어 쿼리를 존중하면서, 해당 설정에서는 애니메이션을 완전히 건너뛴다.
// FadeInSection.tsx
// ✅ 접근성 + 스크린샷 호환
import { motion, useReducedMotion } from "motion/react"
function FadeInSection({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion()
return (
<motion.div
// reduced motion이면 initial 없이 바로 보임
initial={shouldReduceMotion ? false : "hidden"}
whileInView="visible"
viewport={{ once: true }}
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
>
{children}
</motion.div>
)
}
💡 팁: Puppeteer에서
prefers-reduced-motion을 강제하면 이 패턴으로 스크린샷 문제를 해결할 수 있다.await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }])
Puppeteer 스크린샷 스크립트에서 이렇게 쓸 수 있다.
// screenshot.ts — Puppeteer에서 reduced motion 강제
const browser = await puppeteer.launch()
const page = await browser.newPage()
// 모션 축소 강제 → whileInView가 initial 없이 바로 visible
await page.emulateMediaFeatures([
{ name: "prefers-reduced-motion", value: "reduce" },
])
await page.goto("https://example.com", { waitUntil: "networkidle0" })
await page.screenshot({ fullPage: true, path: "screenshot.png" })
전략 3: SSR/SSG 환경에서 initial=
Next.js 같은 SSR/SSG 프레임워크에서는 서버 렌더링 시 initial 상태가 HTML에 인라인 스타일로 찍힌다.
이건 CLS(Cumulative Layout Shift) 문제도 유발한다.
// FadeInSection.tsx
// ✅ SSR 환경에서 서버 렌더링 시 콘텐츠 노출 보장
import { motion, useReducedMotion } from "motion/react"
import { useEffect, useState } from "react"
function FadeInSection({ children }: { children: React.ReactNode }) {
const [isMounted, setIsMounted] = useState(false)
const shouldReduceMotion = useReducedMotion()
useEffect(() => {
setIsMounted(true)
}, [])
// SSR 시에는 initial 없이 렌더링 → 콘텐츠 바로 보임
// 클라이언트 hydration 후에 애니메이션 활성화
const shouldAnimate = isMounted && !shouldReduceMotion
return (
<motion.div
initial={shouldAnimate ? "hidden" : false}
whileInView={shouldAnimate ? "visible" : undefined}
viewport={{ once: true }}
variants={{
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6 },
},
}}
>
{children}
</motion.div>
)
}
⚠️ 주의:
initial={false}를 쓰면 서버에서opacity: 0인라인 스타일이 안 찍히므로, 첫 로드 시 콘텐츠가 바로 보였다가 hydration 후 잠깐 깜빡일 수 있다.isMounted패턴으로 이를 제어한다.
이 패턴의 동작 플로우를 정리하면 이렇다.
- SSR 렌더링:
isMounted=false→initial={false}→ 콘텐츠 바로 보임 (HTML에 opacity: 0 없음) - Hydration:
useEffect실행 →isMounted=true→ 이미 뷰포트에 있는 요소는 바로 visible - 스크롤: 뷰포트 밖 요소는 정상적으로 whileInView 애니메이션 실행
- 스크린샷: Puppeteer가 SSR HTML을 캡처하면 콘텐츠가 보이는 상태
✅ 검증 — 수정 전/후 비교

각 전략 적용 후 실제로 확인해야 할 체크리스트다.
검증 방법
# 1. Puppeteer 스크린샷 테스트
npx ts-node screenshot-test.ts
# 2. Lighthouse 접근성 점수 확인
npx lighthouse http://localhost:3000 --only-categories=accessibility
# 3. SSR HTML 확인 (Next.js)
curl http://localhost:3000 | grep "opacity"
# → opacity: 0 인라인 스타일이 없어야 함
수정 전/후 비교
수정 전 (whileInView only):
┌─────────────────────────────┐
│ Header ✅ 보임 │
│ Hero ✅ 보임 │
│ ────── fold ────── │
│ Cards ❌ 안 보임 │ ← opacity: 0
│ CTA ❌ 안 보임 │ ← opacity: 0
│ Footer ❌ 안 보임 │ ← opacity: 0
└─────────────────────────────┘
수정 후 (once + reduced motion + SSR):
┌─────────────────────────────┐
│ Header ✅ 보임 │
│ Hero ✅ 보임 │
│ ────── fold ────── │
│ Cards ✅ 보임 │ ← 스크린샷 시 visible
│ CTA ✅ 보임 │ ← 스크린샷 시 visible
│ Footer ✅ 보임 │ ← 스크린샷 시 visible
└─────────────────────────────┘
🛡️ 예방 — 스크롤 애니메이션 체크리스트

whileInView를 쓸 때 항상 확인해야 할 6가지.
viewport.once: true기본 적용 — 대부분의 UI에서 한 번만 애니메이션하면 충분하다. 반복 트리거가 필요한 경우만once: false- CSS fallback 추가 —
@media (scripting: none)또는.no-js클래스로 no-JS 환경 대응 useReducedMotion존중 — 접근성은 선택이 아니라 필수다. WCAG 2.1 기준,prefers-reduced-motion: reduce사용자에게 큰 움직임을 강제하면 안 된다- SSR initial 처리 — Next.js, Astro 등 SSR/SSG 환경에서
initial상태가 HTML에 찍히는지 확인 - OG 이미지 생성 파이프라인 점검 — Puppeteer로 OG 이미지를 생성한다면,
emulateMediaFeatures로prefers-reduced-motion: reduce설정 - E2E 스크린샷 테스트 추가 — Playwright의
page.screenshot()으로 fold 아래 콘텐츠가 보이는지 CI에서 자동 검증
📊 데이터: WebAIM의 2024년 조사에 따르면 상위 100만 개 웹사이트 중 95.9%가 WCAG 기준을 위반하고 있다.
prefers-reduced-motion미지원도 접근성 위반 항목이다.
// playwright.config.ts — E2E 스크린샷 검증 예시
import { test, expect } from "@playwright/test"
test("fold 아래 콘텐츠가 스크린샷에 보인다", async ({ page }) => {
await page.goto("/")
// reduced motion 설정으로 애니메이션 건너뛰기
await page.emulateMedia({ reducedMotion: "reduce" })
const screenshot = await page.screenshot({ fullPage: true })
// 특정 섹션이 visible 상태인지 확인
const ctaSection = page.locator("[data-testid='cta-section']")
await expect(ctaSection).toBeVisible()
})
📋 정리 — 핵심 요약

| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| 스크린샷에 요소 안 보임 | whileInView 단독 사용 | viewport.once + CSS fallback |
| 접근성 도구 경고 | reduced motion 미처리 | useReducedMotion 훅으로 분기 |
| SSR HTML에 opacity: 0 | initial="hidden" 고정 | isMounted 패턴으로 SSR 시 initial={false} |
| OG 이미지에 빈 영역 | Puppeteer 기본 설정 | emulateMediaFeatures reduced motion |
| E2E 테스트 실패 | fold 아래 미검증 | Playwright reducedMotion: "reduce" |
whileInView는 강력한 API다. 근데 “뷰포트 의존적”이라는 전제를 까먹으면, 스크린샷에서 콘텐츠가 통째로 사라지는 공포를 경험하게 된다.
once: true + useReducedMotion + SSR fallback.
이 3종 세트를 기본으로 깔고 시작하면 된다 ✨
📚 React 프론트엔드 삽질기 시리즈 (9편)
- 1. Vite 6.x 프록시에서 PATCH만 CORS 에러? 소문자 메서드 함정과 해결법
- 2. React Admin DataProvider 커스터마이징 삽질기
- 3. BE 응답 래퍼 언래핑 패턴 — API 200인데 왜 에러?
- 4. React useEffect 비동기 cleanup이 GPU를 죽이는 과정 — Pixi.js RenderTexture 실종 사건
- 5. shadcn init 실행했더니 프라이머리 컬러가 검정으로 — CSS 변수 덮어쓰기 트러블슈팅
- 6. Framer Motion whileInView 애니메이션이 스크린샷에서 사라지는 이유와 해결법
- 7. react-hook-form + Zod 연동에서 겪는 실전 함정 6가지 — 에러가 안 뜨는 이유부터 타입 불일치까지
- 8. Refine useCustom config.query가 정수를 보장하지 않는 함정 — 타입은 number인데 왜 400이야?
- 9. 패키지 설치 후 Invalid hook call? Vite 캐시 무효화가 답이다