25. 웹 애니메이션 & 모션 가이드
0. 먼저 알고 가기 (30초 요약)
- 모션은 기능 전달을 돕는 선에서만 사용하면 효과가 큽니다.
- 과도한 애니메이션은 지연/피로를 키우므로 기본값은 짧고 안정적으로 둡니다.
- 사용자가 움직임을 줄이겠다는 설정에 반응하도록 배려하세요.
초심자용 한눈에 보기
모션은 예쁘기만 하면 좋은 것이 아니라, 사용자의 작업 흐름을 돕는 범위에서만 써야 합니다.
핵심 용어 빠르게 정리
| 용어 | 쉬운 뜻 |
|---|---|
easing |
애니메이션 속도 곡선(느리게/빠르게 변화) |
duration |
애니메이션 지속 시간 |
motion budget |
전체 화면에서 애니메이션이 허용되는 양 |
reduced-motion |
애니메이션을 줄여달라는 사용자 선호 설정 |
interrupt |
애니메이션 도중 다른 동작으로 중단되는 처리 |
| 분류 | 핵심 기술 | 상태 | Stable |
|---|---|---|---|
| 연관 가이드 | 20. 디자인 시스템, 08. 성능 | 도구 원칙 | 벤더 중립 |
| 핵심 테마 | View Transitions, Scroll-driven Animations, reduced motion, 성능, motion token | Update | 최신 기준 |
"좋은 애니메이션은 사용자가 인식하지 못하지만, 없으면 즉시 어색함을 느끼는 보이지 않는 UX다." 본 가이드는 특정 애니메이션 라이브러리보다 브라우저 표준, 접근성, 성능 예산, motion token, 점진적 향상을 우선합니다. 라이브러리는 복잡한 timeline, gesture, physics가 표준 API보다 명확한 이점을 줄 때 선택합니다.
현재 5월 핵심 변화 요약
- View Transitions: same-document View Transitions는 Baseline 2025로 넓어졌지만, cross-document와 framework 통합은 브라우저별 호환성을 계속 확인합니다.
- Scroll-driven Animations: scroll/view timeline은 JS scroll handler를 줄일 수 있으나, 미지원 브라우저에는 IntersectionObserver 또는 no-motion fallback을 둡니다.
- CSS-first motion:
linear(),@property, transform/opacity 중심 animation으로 main-thread 비용을 낮춥니다.- Reduced motion 기본값:
prefers-reduced-motion은 선택 옵션이 아니라 모든 motion system의 필수 분기입니다.- Library conditional: motion library, timeline library, Lottie renderer는 공식 release/라이선스/번들 크기/접근성 대응을 확인한 뒤 채택합니다.
추천 항목 (실무 우선순위)
-
시작 추천: motion을 쓰는 요소만 골라
duration과 easing을 토큰화합니다. - 안정 추천: reduced motion 사용자 대응은 기본 동작으로 먼저 적용하세요.
- 운영 추천: 전환/애니메이션은 성능 지표(프레임 드랍)와 사용자 회의감 기준으로만 확장하세요.
추천 항목 고도화 체크
-
첫 적용— interaction motion, reduced motion, animation budget 중 하나를 실제 PR이나 운영 이슈에 붙이고, 변경 전 기준을 먼저 적는다. -
증거 정리— motion spec, perf trace, reduced-motion capture, visual diff를 같은 작업 기록에 남긴다. -
재점검— INP 악화, long task, motion sickness complaint, dropped frames가 나아졌는지 30일 안에 확인하고 기준을 유지, 수정, 폐기 중 하나로 판정한다.
추천 항목 실행 기록 템플릿
-
작업: interaction motion, reduced motion, animation budget 적용 범위를 어느 화면, 패키지, 문서에 둘지 적는다. -
증거: motion spec, perf trace, reduced-motion capture, visual diff 중 실제로 남긴 항목만 링크한다. 판정: 유지/수정/폐기 중 하나와 이유를 한 문장으로 남긴다.-
다음 점검: INP 악화, long task, motion sickness complaint, dropped frames를 다시 볼 날짜와 담당자를 지정한다.
문서 책임 범위
| 이 문서가 결정하는 것 | 단일 출처로 따르는 문서 |
|---|---|
| motion 목적성, token, duration/easing, reduced-motion 분기 | 20. 디자인 시스템, 19. 접근성 |
| animation 성능 예산과 회귀 측정 | 08. 성능 |
| 브라우저 표준 API 채택과 fallback | 13. 브라우저 호환성 |
| AI가 만든 모션 코드 초안 검증 | 18. AI 개발 워크플로우 |
0. 모든 프론트엔드 그룹 공통 Baseline
| 기준 | 최소 적용 |
|---|---|
| 목적성 | motion은 상태 변화, 공간 이동, 피드백, 주의 유도처럼 명확한 UX 목적이 있을 때만 사용합니다. |
| 성능 예산 | transform/opacity 중심, long task/INP 악화 없음, animation frame drop 기준을 둡니다. |
| 접근성 |
prefers-reduced-motion, keyboard/focus visibility, vestibular
trigger 회피를 기본으로 둡니다.
|
| 토큰화 | duration, easing, delay, distance, choreography를 디자인 시스템 token으로 관리합니다. |
| 점진적 향상 | 표준 API 미지원 브라우저에서도 기능은 동작하고 motion만 생략됩니다. |
0.0 모션 설계·적용 흐름
flowchart TD
A[UX 목적 정의] --> B[Motion token + 기간/곡선 선택]
B --> C{prefers-reduced-motion}
C -->|Reduce| D[Static/최소 motion fallback]
C -->|No Preference| E[기술 선택: CSS/WAAPI/ViewTransition]
E --> F[성능 예산 검토]
F --> G{INP/Frame Drop 위반?}
G -->|Yes| H[라이브러리/오디오그래프 단순화]
G -->|No| I[접근성/키보드 smoke]
H --> E
I --> J{문제 없음}
J -->|Pass| K[Release gate 통과]
J -->|Fail| L[동작 범위/회귀 테스트 수정]
K --> M[운영 지표 모니터링]
L --> F
0.1 교차 검증 매트릭스
| 권고 | 1차 출처 | 실행 증거 | 운영 증거 | 철회 조건 |
|---|---|---|---|---|
| View Transitions는 Baseline/compat 확인 후 progressive enhancement로 둔다 | MDN Baseline, CSS View Transitions spec | feature detection, fallback E2E | navigation error, transition skip rate | 핵심 기능이 transition에 의존하게 될 때 |
| scroll animation은 CSS timeline 우선, JS는 fallback/고급 제어에 한정한다 | CSS Scroll-driven Animations spec/MDN | no-JS path, IntersectionObserver fallback | scroll jank, INP, dropped frame | 대상 브라우저가 CSS timeline을 충분히 지원하지 않을 때 |
| reduced motion은 모든 motion component의 필수 분기다 | WCAG/WAI, OS media query 문서 | reduced-motion visual test | motion-related complaint, accessibility issue | 사용자 설정을 무시해야 하는 안전/업무상 필수 motion일 때 |
| 외부 animation library는 bundle/license/a11y 검토 후 채택한다 | 라이브러리 공식 release/license | bundle diff, tree-shaking check | client JS size, runtime error | CSS/WAAPI로 동일 UX를 더 단순히 구현할 수 있을 때 |
0.2 운영 게이트
| Gate | Evidence | Owner | Rollback |
|---|---|---|---|
| Motion token gate | token diff, design review, usage lint | Design system owner | token 이전 값 복원 또는 component override 제거 |
| Performance gate | INP/long task comparison, frame drop trace | FE owner | motion flag off, simpler CSS transition |
| Reduced-motion gate | screenshot/video evidence, keyboard smoke | Accessibility owner | motion 제거 또는 static fallback |
| Library adoption gate | bundle diff, license check, a11y fallback | Platform owner | library 제거 후 CSS/WAAPI 구현으로 대체 |
1. AI 기반 애니메이션 자동화
이 섹션의 AI 입력 구조, 민감정보 제거, 검증 책임은 18. AI 개발 워크플로우를 단일 출처로 따릅니다. 여기서는 모션 설계, 성능, 접근성 검증에 필요한 도메인별 질문만 다룹니다.
일상 비유: 요리 레시피 초안을 AI에게 받아도, 손님 알레르기(접근성)·주방 화구 수(성능 예산)·식자재 보관 기준(브라우저 호환)을 셰프가 직접 검수해야 손님상에 낼 수 있다.
이 그림은 AI 모션 초안이 어떤 게이트를 거쳐 PR에 머지되는지 의사결정 흐름을 보여줍니다.
flowchart TD
Idea[모션 요구사항/UX 의도] --> AI[AI 초안<br/>코드/토큰 후보]
AI --> RM{prefers-reduced-motion<br/>분기 포함?}
RM -->|No| Fix1[reduced fallback 추가]
Fix1 --> RM
RM -->|Yes| Prop{transform/opacity<br/>중심?}
Prop -->|No| Fix2[GPU 안전 속성으로 교체]
Fix2 --> Prop
Prop -->|Yes| Perf{INP/long task<br/>예산 통과?}
Perf -->|No| Tune[duration 축소/<br/>composite 최적화]
Tune --> Perf
Perf -->|Yes| A11y[접근성 smoke<br/>focus/keyboard]
A11y --> Merge[PR Merge]
AI는 모션 코드 초안, 성능 개선 후보, 접근성 검증 항목을 빠르게 제안할 수 있다. 최종
모션 언어, 성능 예산, prefers-reduced-motion 대응은 디자인 시스템과 실제
브라우저 측정으로 검증한다.
| 시나리오 | 입력 | AI 산출물 | 필수 검증 | 승인 조건 |
|---|---|---|---|---|
| 컴포넌트 motion 초안 | 컴포넌트 코드, 상태 전이, motion token | CSS/Web Animations/motion library 후보 | interaction test, reduced-motion snapshot | motion이 기능 이해를 보조하고 방해하지 않음 |
| 성능 최적화 | animation code, DevTools trace, INP/LCP | transform/opacity 전환, layout thrash 제거안 | long task, dropped frame, INP 비교 | 성능 예산 악화 없음 |
| View Transitions | route/state transition, browser target | progressive enhancement 패턴 | feature detection, fallback E2E | 미지원 브라우저에서 기능 저하 없음 |
| Scroll animation | scroll trigger, content hierarchy | CSS scroll timeline 또는 IntersectionObserver 패턴 | scroll jank, accessibility smoke | scroll 없이도 콘텐츠 접근 가능 |
| Lottie/vector motion | asset size, renderer, fallback image | 최적화 후보, lazy load 전략 | bundle report, render cost, reduced-motion fallback | 핵심 경로를 막지 않음 |
모션 PR의 hard gate는 다음입니다.
prefers-reduced-motion에서 불필요한 이동/반복 motion 제거- transform/opacity 외 속성 animation은 성능 근거 필요
- layout shift 또는 focus visibility 회귀 없음
- motion token을 우회하는 임의 duration/easing 최소화
2. 환경별 모션 실험 및 검증
2.1 Preview 환경에서 모션 변형 검증
// src/shared/lib/motion-ab-test.ts
import { useFeatureFlag } from '@/shared/lib/feature-flags'
import { envConfig } from '@/shared/config/environment'
type MotionVariant = 'default' | 'variant-a' | 'variant-b'
interface MotionABTestConfig {
flagKey: string
variants: Record<MotionVariant, MotionPreset>
}
interface MotionPreset {
duration: number
ease: string
stagger: number
type: 'spring' | 'tween'
spring?: { stiffness: number; damping: number }
}
const MOTION_PRESETS: Record<MotionVariant, MotionPreset> = {
default: {
duration: 0.3,
ease: 'easeOut',
stagger: 0.05,
type: 'tween',
},
'variant-a': {
duration: 0.4,
ease: 'easeInOut',
stagger: 0.08,
type: 'spring',
spring: { stiffness: 300, damping: 30 },
},
'variant-b': {
duration: 0.2,
ease: 'linear',
stagger: 0.03,
type: 'tween',
},
}
export function useMotionVariant(flagKey: string): MotionPreset {
const variant = useFeatureFlag(flagKey, false)
const variantB = useFeatureFlag(`${flagKey}-b`, false)
if (variantB) return MOTION_PRESETS['variant-b']
if (variant) return MOTION_PRESETS['variant-a']
return MOTION_PRESETS['default']
}
// Preview 환경 전용: 모든 모션 변형을 나란히 비교
export function useMotionComparison(): {
enabled: boolean
variants: Record<MotionVariant, MotionPreset>
} {
return {
enabled: envConfig.deployEnv === 'preview',
variants: MOTION_PRESETS,
}
}
// src/shared/ui/MotionComparisonPanel.tsx
// Preview 환경에서만 렌더링되는 모션 비교 패널
import { type ReactNode } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { useMotionComparison } from '@/shared/lib/motion-ab-test'
interface MotionComparisonPanelProps {
children: (preset: { duration: number; ease: string; stagger: number }) => ReactNode
}
export function MotionComparisonPanel({ children }: MotionComparisonPanelProps): ReactNode {
const { enabled, variants } = useMotionComparison()
if (!enabled) return null
return (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(0,0,0,0.9)',
padding: '16px',
zIndex: 9999,
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '16px',
}}
>
{Object.entries(variants).map(([name, preset]) => (
<div key={name} style={{ border: '1px solid #444', padding: '8px', borderRadius: '8px' }}>
<p style={{ color: '#fff', fontSize: '12px', marginBottom: '8px' }}>
{name} (duration: {preset.duration}s, ease: {preset.ease})
</p>
<AnimatePresence mode="wait">{children(preset)}</AnimatePresence>
</div>
))}
</div>
)
}
2.2 Feature Flag로 모션 on/off
// src/shared/lib/motion-feature-flags.ts
import { useFeatureFlag } from '@/shared/lib/feature-flags'
interface MotionConfig {
/** 전체 애니메이션 활성화 여부 */
animationsEnabled: boolean
/** 페이지 전환 애니메이션 */
pageTransitions: boolean
/** 마이크로 인터랙션 (hover, tap) */
microInteractions: boolean
/** 스크롤 애니메이션 */
scrollAnimations: boolean
/** Lottie 애니메이션 */
lottieAnimations: boolean
/** 로딩 스켈레톤 애니메이션 */
skeletonAnimations: boolean
}
export function useMotionConfig(): MotionConfig {
const animationsEnabled = useFeatureFlag('motion-enabled', true)
const pageTransitions = useFeatureFlag('motion-page-transitions', true)
const microInteractions = useFeatureFlag('motion-micro-interactions', true)
const scrollAnimations = useFeatureFlag('motion-scroll-animations', true)
const lottieAnimations = useFeatureFlag('motion-lottie', true)
const skeletonAnimations = useFeatureFlag('motion-skeleton', true)
return {
animationsEnabled,
pageTransitions: animationsEnabled && pageTransitions,
microInteractions: animationsEnabled && microInteractions,
scrollAnimations: animationsEnabled && scrollAnimations,
lottieAnimations: animationsEnabled && lottieAnimations,
skeletonAnimations: animationsEnabled && skeletonAnimations,
}
}
// Feature Flag 연동 모션 래퍼
export function useConditionalMotion(
flagKey: keyof MotionConfig,
animateProps: Record<string, unknown>,
initialProps: Record<string, unknown> = {},
): Record<string, unknown> {
const config = useMotionConfig()
if (!config[flagKey]) {
// 애니메이션 비활성화: 최종 상태를 즉시 적용
return { ...initialProps, ...animateProps, transition: { duration: 0 } }
}
return animateProps
}
2.3 환경별 reduced-motion 테스트
// src/shared/lib/reduced-motion.ts
import { useEffect, useState } from 'react'
import { envConfig } from '@/shared/config/environment'
/**
* reduced-motion 감지 훅
* Preview 환경에서는 URL 파라미터로 강제 토글 가능
* ?reduced-motion=true 또는 ?reduced-motion=false
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false)
useEffect(() => {
// 1. Preview 환경: URL 파라미터 우선
if (envConfig.deployEnv === 'preview') {
const params = new URLSearchParams(window.location.search)
const forced = params.get('reduced-motion')
if (forced !== null) {
setReduced(forced === 'true')
return
}
}
// 2. 시스템 설정 감지
const mql = window.matchMedia('(prefers-reduced-motion: reduce)')
setReduced(mql.matches)
const handler = (e: MediaQueryListEvent): void => setReduced(e.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [])
return reduced
}
/**
* reduced-motion 테스트 유틸 (Preview 환경 전용)
* 화면에 토글 버튼을 표시하여 수동 테스트 가능
*/
export function useReducedMotionDebug(): {
isReduced: boolean
toggle: () => void
setReduced: (value: boolean) => void
} {
const [overridden, setOverridden] = useState<boolean | null>(null)
const systemReduced = useReducedMotion()
const isReduced = overridden ?? systemReduced
return {
isReduced,
toggle: () => setOverridden((prev) => !(prev ?? systemReduced)),
setReduced: (value: boolean) => setOverridden(value),
}
}
// src/shared/ui/ReducedMotionDebugPanel.tsx
// Preview 환경에서만 표시되는 reduced-motion 테스트 패널
import { type ReactNode } from 'react'
import { envConfig } from '@/shared/config/environment'
import { useReducedMotionDebug } from '@/shared/lib/reduced-motion'
export function ReducedMotionDebugPanel(): ReactNode {
const { isReduced, toggle } = useReducedMotionDebug()
if (envConfig.deployEnv !== 'preview') return null
return (
<div
style={{
position: 'fixed',
top: 8,
right: 8,
zIndex: 10000,
background: isReduced ? '#ef4444' : '#22c55e',
color: '#fff',
padding: '6px 12px',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
fontFamily: 'monospace',
}}
onClick={toggle}
>
Motion: {isReduced ? 'REDUCED' : 'FULL'}
</div>
)
}
3. 애니메이션 기술 비교
일상 비유: 카페에서 컵 그림을 그리는 방법과 같다. 칠판에 분필(CSS)은 빠르고 손쉽고, 종이에 펜(WAAPI)은 정밀 제어가 가능하며, 디지털 태블릿(GSAP/Timeline)은 정교한 시퀀스가 가능하지만 장비가 무겁다. 인스타용 짧은 영상(Lottie)은 사전 렌더링된 결과물에 가깝다.
이 그림은 사용 목적에 따라 어떤 애니메이션 기술을 골라야 하는지 결정 트리로 보여줍니다.
flowchart TD
Start[애니메이션 도입 결정] --> Q1{스크롤 연동?}
Q1 -->|Yes| Q1a{Baseline<br/>CSS Scroll-Driven 지원?}
Q1a -->|Yes| CSS_SD[CSS Scroll-Driven]
Q1a -->|No| IO[IntersectionObserver fallback]
Q1 -->|No| Q2{페이지/라우트 전환?}
Q2 -->|Yes| VT[View Transitions API<br/>+ fallback]
Q2 -->|No| Q3{React 컴포넌트<br/>진입/퇴장?}
Q3 -->|Yes| Q3a{복잡한 시퀀스/SVG?}
Q3a -->|Yes| GSAP[Timeline lib<br/>GSAP/Motion]
Q3a -->|No| Motion[React motion lib]
Q3 -->|No| Q4{단순 호버/전환?}
Q4 -->|Yes| CSSA[CSS Animations/Transitions]
Q4 -->|No| WAAPI[Web Animations API]
| 기술 | 적합 용도 | 번들 크기 | 성능 | 접근성 | 학습 곡선 |
|---|---|---|---|---|---|
| CSS Animations | 간단한 전환, 호버 | 0 KB | 최고 | 기본 지원 | 낮음 |
CSS linear() easing
|
스프링 물리 곡선 | 0 KB | 최고 | 기본 지원 | 낮음 |
| Web Animations API | JS 제어 필요한 경우 | 0 KB | 높음 | 수동 | 중간 |
| React motion library | React 컴포넌트 애니메이션 | 라이브러리별 상이 | 높음 | 훅 제공 여부 확인 | 중간 |
| Timeline library | 복잡한 타임라인, SVG, 정밀 제어 | 라이브러리별 상이 | 높음 | 수동 | 높음 |
| View Transitions L1 | SPA 페이지 전환 | 0 KB | 높음 | 브라우저 내장 | 낮음 |
| View Transitions L2 (MPA) | 다중 페이지 전환 (cross-document) | 0 KB | 높음 | 브라우저 내장 | 낮음 |
| Vector animation renderer | 일러스트/아이콘 애니메이션 | 렌더러별 상이 | 중간 | 수동 | 낮음 |
| Lightweight timeline library | 경량 타임라인 | 라이브러리별 상이 | 높음 | 수동 | 낮음 |
| CSS Scroll-Driven | 스크롤 연동 (Baseline 2025) | 0 KB | 최고 | 기본 지원 | 중간 |
선택 가이드
일상 비유: 시계의 째깍 소리(CSS)는 기계가 자동으로 내고, 음악 메트로놈(JS)은 사람이 박자를 직접 조절한다. 같은 60bpm을 내더라도 자동(CSS)이 배터리(메인스레드)를 덜 쓴다. 단, 사람이 박자를 도중에 바꾸려면 메트로놈(JS)이 필요하다.
이 그림은 CSS와 JS 애니메이션을 어떤 기준으로 나눠 쓸지 보여줍니다.
flowchart LR
Trigger[애니메이션 트리거] --> Q1{사용자 입력에<br/>실시간 반응?}
Q1 -->|Yes| Q2{값을 도중에<br/>변경/일시정지?}
Q1 -->|No| Q3{상태 전환만?}
Q2 -->|Yes| JS[Web Animations API<br/>또는 motion lib]
Q2 -->|No| Q3
Q3 -->|Yes| CSS[CSS transition/animation]
Q3 -->|No| Q4{스크롤/뷰포트?}
Q4 -->|Yes| ScrollCSS[CSS scroll/view timeline]
Q4 -->|No| CSS
JS --> Budget[INP/long task 예산 검증]
CSS --> Budget
ScrollCSS --> Budget
간단한 호버/전환 → CSS Animations (JS 불필요)
React 컴포넌트 진입/퇴장 → React motion library
페이지 전환 → View Transitions API (+ fallback)
스크롤 기반 → CSS scroll() / IntersectionObserver fallback
복잡한 시퀀스 → Timeline library
일러스트 애니메이션 → Vector animation renderer
스크롤 연동 (JS 없이) → CSS Scroll-Driven Animations
CSS Animations 기본 패턴
/* GPU 가속 속성만 사용하는 애니메이션 */
/* 1. fadeInUp - 가장 범용적인 진입 애니메이션 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out both;
}
/* 2. 스켈레톤 로딩 - 콘텐츠 로드 전 시각적 피드백 */
@keyframes shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
.skeleton {
position: relative;
overflow: hidden;
background: #e5e7eb;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: shimmer 1.5s infinite;
}
/* 3. 스핀 로더 - transform만 사용하여 GPU 가속 */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
/* 4. 펄스 효과 - 주의를 끄는 인터랙션 */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* 5. reduced-motion 대응 - 반드시 포함 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Web Animations API 패턴
// src/shared/lib/web-animations.ts
// 네이티브 Web Animations API 유틸리티 (번들 0KB)
interface AnimationOptions {
duration?: number
easing?: string
fill?: FillMode
delay?: number
}
/** fadeInUp 애니메이션 - 가장 범용적인 진입 효과 */
export function fadeInUp(element: HTMLElement, options: AnimationOptions = {}): Animation {
const { duration = 300, easing = 'ease-out', fill = 'forwards', delay = 0 } = options
return element.animate(
[
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration, easing, fill, delay },
)
}
/** stagger 패턴 - 자식 요소를 순차적으로 등장시킴 */
export function staggerChildren(
container: HTMLElement,
selector: string,
options: AnimationOptions & { stagger?: number } = {},
): Animation[] {
const { stagger = 50, ...animOptions } = options
const children = container.querySelectorAll<HTMLElement>(selector)
return Array.from(children).map((child, i) =>
fadeInUp(child, { ...animOptions, delay: i * stagger }),
)
}
/** IntersectionObserver 연동: 뷰포트 진입 시 애니메이션 실행 */
export function animateOnScroll(
elements: HTMLElement[],
animationFn: (el: HTMLElement) => Animation,
threshold: number = 0.2,
): () => void {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
animationFn(entry.target as HTMLElement)
observer.unobserve(entry.target)
}
}
},
{ threshold },
)
for (const el of elements) observer.observe(el)
// 클린업 함수 반환
return () => observer.disconnect()
}
/**
* 키프레임 체이닝 - 복수 애니메이션을 순차 실행
* Web Animations API의 finished Promise를 활용
*/
export async function chainAnimations(
element: HTMLElement,
keyframeSequence: Keyframe[][],
options: AnimationOptions = {},
): Promise<void> {
for (const keyframes of keyframeSequence) {
const anim = element.animate(keyframes, {
duration: options.duration ?? 300,
easing: options.easing ?? 'ease-out',
fill: 'forwards',
})
await anim.finished // 이전 애니메이션 완료 후 다음 실행
}
}
React motion library 실전 패턴
채택 기준: 특정 라이브러리를 표준으로 고정하지 않습니다. 공식 release note, 라이선스, 번들 크기, React/프레임워크 호환성, reduced motion 지원, SSR/RSC 호환성을 확인한 뒤 프로젝트별로 선택합니다.
# 예시: 선택한 motion library를 route-level로 지연 로드
pnpm add <motion-library>
// src/shared/ui/AnimatedList.tsx
// 리스트 아이템 stagger 진입/퇴장 애니메이션
import { motion, AnimatePresence, type Variants } from 'motion/react'
import { type ReactNode } from 'react'
import { useReducedMotion } from '@/shared/lib/reduced-motion'
import { useMotionConfig } from '@/shared/lib/motion-feature-flags'
// 컨테이너: stagger로 자식 순차 등장
const containerVariants: Variants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.05 },
},
exit: {},
}
// 아이템: fadeInUp 진입 + fadeOut 퇴장
const itemVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.3, ease: 'easeOut' },
},
exit: {
opacity: 0,
y: -10,
transition: { duration: 0.2 },
},
}
// reduced-motion 사용자: opacity만 즉시 변경
const reducedVariants: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.01 } },
exit: { opacity: 0, transition: { duration: 0.01 } },
}
interface AnimatedListProps<T> {
items: T[]
keyExtractor: (item: T) => string
renderItem: (item: T) => ReactNode
}
export function AnimatedList<T>({
items,
keyExtractor,
renderItem,
}: AnimatedListProps<T>): ReactNode {
const prefersReduced = useReducedMotion()
const { animationsEnabled } = useMotionConfig()
const shouldAnimate = animationsEnabled && !prefersReduced
const variants = shouldAnimate ? itemVariants : reducedVariants
return (
<motion.ul variants={containerVariants} initial="hidden" animate="visible" exit="exit">
<AnimatePresence mode="popLayout">
{items.map((item) => (
<motion.li key={keyExtractor(item)} variants={variants} layout={shouldAnimate}>
{renderItem(item)}
</motion.li>
))}
</AnimatePresence>
</motion.ul>
)
}
Motion 12: Shared Layout Animation
// src/shared/ui/SharedLayoutTabs.tsx
// 탭 전환 시 indicator가 부드럽게 이동하는 패턴
import { motion, AnimatePresence } from 'motion/react'
import { useState, type ReactNode } from 'react'
import { useReducedMotion } from '@/shared/lib/reduced-motion'
interface Tab {
id: string
label: string
content: ReactNode
}
interface SharedLayoutTabsProps {
tabs: Tab[]
defaultTab?: string
}
export function SharedLayoutTabs({ tabs, defaultTab }: SharedLayoutTabsProps): ReactNode {
const [activeTab, setActiveTab] = useState(defaultTab ?? tabs[0]?.id ?? '')
const prefersReduced = useReducedMotion()
return (
<div>
{/* 탭 헤더 */}
<div style={{ display: 'flex', gap: '4px', borderBottom: '1px solid #e5e7eb' }}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
position: 'relative',
padding: '8px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: activeTab === tab.id ? '#3b82f6' : '#6b7280',
fontWeight: activeTab === tab.id ? 600 : 400,
}}
>
{tab.label}
{/* layoutId로 indicator가 탭 간 부드럽게 이동 */}
{activeTab === tab.id && (
<motion.div
layoutId="tab-indicator"
style={{
position: 'absolute',
bottom: -1,
left: 0,
right: 0,
height: 2,
background: '#3b82f6',
}}
transition={
prefersReduced ? { duration: 0 } : { type: 'spring', stiffness: 500, damping: 30 }
}
/>
)}
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={prefersReduced ? {} : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReduced ? {} : { opacity: 0, y: -10 }}
transition={{ duration: prefersReduced ? 0 : 0.2 }}
style={{ padding: '16px 0' }}
>
{tabs.find((t) => t.id === activeTab)?.content}
</motion.div>
</AnimatePresence>
</div>
)
}
4. View Transitions API 실전 패턴
일상 비유: 뮤지컬 무대의 암전(snapshot)→세트 교체(DOM 변경)→조명 점등(animation)과 같다. 관객(사용자)은 깜빡임 없이 새 장면을 본다. 미지원 브라우저는 암전 없이 세트만 바뀌므로 fallback이 필요하다.
이 그림은 View Transitions API의 라이프사이클과 상태 전이를 보여줍니다.
stateDiagram-v2
[*] --> Idle
Idle --> CapturingOld: startViewTransition()
CapturingOld --> DomUpdate: snapshot 완료
DomUpdate --> CapturingNew: 콜백 실행
CapturingNew --> Animating: new snapshot 완료
Animating --> Finished: animation end
Animating --> Skipped: skipTransition()
Skipped --> Idle
Finished --> Idle
note right of CapturingOld
이전 화면 픽셀 저장
::view-transition-old(root)
end note
note right of CapturingNew
새 화면 픽셀 저장
::view-transition-new(root)
end note
이 그림은 호출자 코드와 브라우저가 View Transition을 어떻게 주고받는지 보여줍니다.
sequenceDiagram participant U as 사용자 participant App as App 코드 participant VT as document.startViewTransition participant DOM as DOM participant Anim as Compositor U->>App: 라우트/상태 변경 App->>VT: startViewTransition(callback) VT->>DOM: 1) old snapshot 캡처 VT->>App: 2) callback 실행 App->>DOM: 3) DOM 업데이트 VT->>DOM: 4) new snapshot 캡처 VT->>Anim: 5) ::view-transition pseudo CSS 적용 Anim-->>U: 6) crossfade/morph 실행 Anim-->>App: transition.finished resolve
참고: View Transitions API는 현재 공식 문서 기준 Chrome, Edge, Safari 18+에서 정식 지원되며, Firefox는 진행 중입니다(L1 부분 지원). 더 자세한 호환성 현황은 13. 브라우저 호환성 가이드에서 확인하세요.
Level 1 vs Level 2 차이
- L1 (Same-Document): SPA 내에서
document.startViewTransition()호출 -- 같은 문서 안에서 DOM 변경 전환.- L2 (Cross-Document, 최신 안정화): MPA에서
@view-transition { navigation: auto }CSS 한 줄로 페이지 간 전환을 JS 없이 활성화. Next.js MPA 라우팅이나 Astro 같은 정적 페이지에서 즉시 활용 가능.
Level 2 (Cross-Document) 최소 설정
/* src/shared/styles/cross-document-transitions.css */
/* MPA -- HTML 페이지 간 자동 전환 */
@view-transition {
navigation: auto;
}
/* 페이지 공통 전환 -- crossfade */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 300ms;
/* 현재 Baseline -- 스프링 물리는 CSS만으로 표현 가능 */
animation-timing-function: linear(
0,
0.045,
0.18,
0.4,
0.7,
1.025,
1.205,
1.3,
1.32,
1.3,
1.225,
1.135,
1.05,
0.985,
0.94,
0.92,
0.92,
0.935,
0.965,
1,
1.03,
1.045,
1.045,
1.035,
1.02,
1.01,
1,
0.995,
0.995,
1
);
}
/* 특정 요소만 morph -- 공유 요소 애니메이션 */
.product-hero {
view-transition-name: product-hero;
}
<!-- HTML head에 활성화 메타가 필요한 경우 -->
<meta name="view-transition" content="same-origin" />
Cross-document 전환은 동일 출처(same-origin) 페이지 간에만 작동하며,
prefers-reduced-motion사용자에게는 자동으로 비활성화된다.
useViewTransition 훅
// src/shared/lib/use-view-transition.ts
import { useCallback, useRef } from 'react'
type TransitionType = 'crossfade' | 'slide-left' | 'slide-right' | 'scale' | 'none'
interface ViewTransitionOptions {
type?: TransitionType
duration?: number
}
interface ViewTransitionResult {
startTransition: (callback: () => void | Promise<void>) => Promise<void>
isSupported: boolean
}
export function useViewTransition(options: ViewTransitionOptions = {}): ViewTransitionResult {
const { type = 'crossfade', duration = 300 } = options
const transitionRef = useRef<ViewTransition | null>(null)
const isSupported = typeof document !== 'undefined' && 'startViewTransition' in document
const startTransition = useCallback(
async (callback: () => void | Promise<void>) => {
if (!isSupported) {
// 미지원 브라우저: 즉시 콜백 실행 (fallback)
await callback()
return
}
// 전환 타입 CSS class 설정
document.documentElement.dataset.transitionType = type
document.documentElement.style.setProperty('--transition-duration', `${duration}ms`)
const transition = document.startViewTransition(async () => {
await callback()
})
transitionRef.current = transition
try {
await transition.finished
} finally {
delete document.documentElement.dataset.transitionType
}
},
[isSupported, type, duration],
)
return { startTransition, isSupported }
}
View Transition CSS
/* src/shared/styles/view-transitions.css */
/* 기본: crossfade - 가장 자연스러운 페이지 전환 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: var(--transition-duration, 300ms);
}
/* slide-left - "다음 페이지" 느낌 */
[data-transition-type='slide-left']::view-transition-old(root) {
animation: slide-out-left var(--transition-duration) ease-in-out;
}
[data-transition-type='slide-left']::view-transition-new(root) {
animation: slide-in-right var(--transition-duration) ease-in-out;
}
/* slide-right - "뒤로 가기" 느낌 */
[data-transition-type='slide-right']::view-transition-old(root) {
animation: slide-out-right var(--transition-duration) ease-in-out;
}
[data-transition-type='slide-right']::view-transition-new(root) {
animation: slide-in-left var(--transition-duration) ease-in-out;
}
/* scale - 모달/다이얼로그 열기/닫기 */
[data-transition-type='scale']::view-transition-old(root) {
animation: scale-down var(--transition-duration) ease-in-out;
}
[data-transition-type='scale']::view-transition-new(root) {
animation: scale-up var(--transition-duration) ease-in-out;
}
@keyframes slide-out-left {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes slide-out-right {
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes slide-in-left {
from {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes scale-down {
to {
transform: scale(0.95);
opacity: 0;
}
}
@keyframes scale-up {
from {
transform: scale(0.95);
opacity: 0;
}
}
/* reduced-motion 사용자: 전환 즉시 완료 */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.01ms !important;
}
}
React Router v7 + View Transitions 통합
// src/app/router/TransitionLink.tsx
// React Router v7과 View Transitions API를 결합한 네비게이션 컴포넌트
import { useNavigate } from 'react-router'
import { useViewTransition } from '@/shared/lib/use-view-transition'
import { type ReactNode, type MouseEvent } from 'react'
interface TransitionLinkProps {
to: string
transition?: 'crossfade' | 'slide-left' | 'slide-right' | 'scale'
children: ReactNode
className?: string
}
export function TransitionLink({
to,
transition = 'crossfade',
children,
className,
}: TransitionLinkProps): ReactNode {
const navigate = useNavigate()
const { startTransition, isSupported } = useViewTransition({ type: transition })
const handleClick = async (e: MouseEvent<HTMLAnchorElement>): Promise<void> => {
e.preventDefault()
if (isSupported) {
// View Transitions API 지원 시: 부드러운 전환
await startTransition(() => {
navigate(to)
})
} else {
// 미지원 시: 즉시 이동
navigate(to)
}
}
return (
<a href={to} onClick={handleClick} className={className}>
{children}
</a>
)
}
View Transition 디버그 오버레이 (Preview 전용)
// src/shared/ui/ViewTransitionDebug.tsx
import { useEffect, useState, type ReactNode } from 'react'
import { envConfig } from '@/shared/config/environment'
interface TransitionLog {
type: string
duration: string
timestamp: number
}
export function ViewTransitionDebug(): ReactNode {
const [logs, setLogs] = useState<TransitionLog[]>([])
useEffect(() => {
if (envConfig.deployEnv !== 'preview') return
// data-transition-type 속성 변경을 감지하여 로그 수집
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === 'data-transition-type') {
const type = document.documentElement.dataset.transitionType
const duration = getComputedStyle(document.documentElement).getPropertyValue(
'--transition-duration',
)
if (type) {
setLogs((prev) => [...prev.slice(-9), { type, duration, timestamp: Date.now() }])
}
}
}
})
observer.observe(document.documentElement, { attributes: true })
return () => observer.disconnect()
}, [])
if (envConfig.deployEnv !== 'preview' || logs.length === 0) return null
return (
<div
style={{
position: 'fixed',
bottom: 8,
left: 8,
background: 'rgba(0,0,0,0.85)',
color: '#10b981',
padding: '8px 12px',
borderRadius: '6px',
fontSize: '11px',
fontFamily: 'monospace',
zIndex: 10000,
maxHeight: '200px',
overflow: 'auto',
}}
>
<strong>View Transitions</strong>
{logs.map((log, i) => (
<div key={i}>
[{new Date(log.timestamp).toLocaleTimeString()}] {log.type} ({log.duration})
</div>
))}
</div>
)
}
5. 스크롤 기반 애니메이션
일상 비유: 도서관에서 책장을 따라 걸어갈 때 책 제목이 또렷해지는 경험 같다. CSS Scroll-Driven은 책장이 직접 조명을 켜는 방식(브라우저 합성)이고, IntersectionObserver fallback은 사서(JS)가 옆에 서서 알려주는 방식이다.
이 그림은 스크롤 애니메이션 기술을 어떤 순서로 시도하고 어디서 fallback할지 보여줍니다.
flowchart TD
Start[스크롤 연동 요구] --> Feat{Baseline<br/>animation-timeline?}
Feat -->|Yes| CSS[CSS scroll/view timeline]
Feat -->|No| IO{IntersectionObserver<br/>지원?}
IO -->|Yes| JS[IO 기반 reveal/parallax]
IO -->|No| Static[정적 콘텐츠 유지<br/>motion 생략]
CSS --> RM{prefers-reduced-motion}
JS --> RM
RM -->|reduce| Static
RM -->|no-preference| Verify[성능/회귀 검증]
Static --> Verify
참고: CSS Scroll-Driven Animations는 현재 공식 문서 기준 Chrome/Edge 115+, Safari 26+에서 정식 지원되며, Firefox 137부터 부분 지원이 시작되었습니다. 미지원 브라우저를 위해 IntersectionObserver 기반 JS 폴백을 함께 구현하세요. 성능 측정은 08. 성능 최적화 가이드를 참고합니다.
CSS
linear()스프링 물리 (최신 Baseline) 기존cubic-bezier로 표현하기 어려운 스프링/바운스 곡선을 CSS만으로 구현 가능하다. JavaScript Spring physics 라이브러리 의존도를 크게 줄일 수 있다.
css /* 스프링 물리 -- React Spring 없이 CSS만으로 */ .spring-in { animation: spring-in 600ms forwards; animation-timing-function: linear( 0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%, 0.723 12.9%, 0.938 16.7%, 1.017, 1.077, 1.121, 1.149 24.3%, 1.159, 1.163, 1.161, 1.154 29.9%, 1.129 32.8%, 1.051 39.6%, 1.017 43.1%, 0.991, 0.977 51%, 0.974, 0.975, 0.991 65.5%, 1 ); } /* Scroll Timeline + linear() 조합 -- 스크롤에 스프링 반응 */ .reveal-spring { animation: reveal linear both; animation-timeline: view(); animation-range: entry 10% cover 40%; animation-timing-function: linear(0, 0.5 30%, 0.95 60%, 1); }
CSS Scroll-Driven Animations (네이티브)
/* 프로그레스 바: CSS만으로 구현 (JS 불필요, 최고 성능) */
.scroll-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: #3b82f6;
transform-origin: left;
animation: progress-bar linear;
animation-timeline: scroll(root); /* 루트 스크롤에 연동 */
}
@keyframes progress-bar {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
/* 요소가 뷰포트를 통과하며 애니메이션 - view() 타임라인 */
.reveal-on-scroll {
animation: reveal linear both;
animation-timeline: view(); /* 요소의 뷰포트 가시성에 연동 */
animation-range: entry 10% cover 40%; /* 진입 10%~40% 구간에서 실행 */
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 패럴랙스: CSS scroll()로 깊이감 표현 */
.parallax-layer {
animation: parallax linear;
animation-timeline: scroll(root);
}
@keyframes parallax {
to {
transform: translateY(-30%);
}
}
/* 카드 스택 효과: 스크롤에 따라 카드가 겹치며 올라옴 */
.card-stack-item {
animation: card-stack-reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 50%;
}
@keyframes card-stack-reveal {
from {
opacity: 0;
transform: translateY(100px) scale(0.9) rotateX(10deg);
}
to {
opacity: 1;
transform: translateY(0) scale(1) rotateX(0deg);
}
}
/* 텍스트 하이라이트 진행: 스크롤 위치에 따라 텍스트 강조 */
.highlight-on-scroll {
background: linear-gradient(to right, #fbbf24, #fbbf24) no-repeat;
background-size: 0% 100%;
animation: highlight-progress linear both;
animation-timeline: view();
animation-range: entry 20% cover 60%;
}
@keyframes highlight-progress {
to {
background-size: 100% 100%;
}
}
React 스크롤 애니메이션 훅
// src/shared/lib/use-scroll-animation.ts
// IntersectionObserver 기반 스크롤 애니메이션 (모든 브라우저 지원)
import { useEffect, useRef, useState, type RefObject } from 'react'
interface ScrollAnimationOptions {
threshold?: number // 요소가 얼마나 보여야 트리거 (0~1)
rootMargin?: string // 뷰포트 마진 (조기/지연 트리거)
once?: boolean // 한 번만 실행 여부
}
export function useScrollAnimation<T extends HTMLElement>(
options: ScrollAnimationOptions = {},
): { ref: RefObject<T | null>; isInView: boolean; progress: number } {
const { threshold = 0.2, rootMargin = '-50px', once = true } = options
const ref = useRef<T | null>(null)
const [isInView, setIsInView] = useState(false)
const [progress, setProgress] = useState(0)
useEffect(() => {
const el = ref.current
if (!el) return
// 뷰포트 진입 감지
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true)
if (once) observer.unobserve(el)
} else if (!once) {
setIsInView(false)
}
},
{ threshold, rootMargin },
)
observer.observe(el)
// 스크롤 진행률 추적 (패럴랙스 등에 활용)
const handleScroll = (): void => {
const rect = el.getBoundingClientRect()
const viewHeight = window.innerHeight
const p = Math.max(0, Math.min(1, 1 - rect.top / viewHeight))
setProgress(p)
}
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
observer.disconnect()
window.removeEventListener('scroll', handleScroll)
}
}, [threshold, rootMargin, once])
return { ref, isInView, progress }
}
useParallax 훅
// src/shared/lib/use-parallax.ts
// JS 기반 패럴랙스 효과 (CSS Scroll-Driven 미지원 브라우저 폴백)
import { useEffect, useRef, type RefObject } from 'react'
import { useReducedMotion } from '@/shared/lib/reduced-motion'
interface ParallaxOptions {
speed?: number // 스크롤 대비 이동 비율 (0.1~1.0, 기본 0.3)
direction?: 'up' | 'down' // 이동 방향
}
export function useParallax<T extends HTMLElement>(
options: ParallaxOptions = {},
): RefObject<T | null> {
const { speed = 0.3, direction = 'up' } = options
const ref = useRef<T | null>(null)
const prefersReduced = useReducedMotion()
useEffect(() => {
const el = ref.current
if (!el || prefersReduced) return // reduced-motion 시 비활성화
let rafId: number
const handleScroll = (): void => {
rafId = requestAnimationFrame(() => {
const rect = el.getBoundingClientRect()
const scrollProgress = (window.innerHeight - rect.top) / (window.innerHeight + rect.height)
const offset = (scrollProgress - 0.5) * speed * 100
const sign = direction === 'up' ? -1 : 1
// transform만 사용하여 GPU 가속 보장
el.style.transform = `translateY(${sign * offset}px)`
})
}
window.addEventListener('scroll', handleScroll, { passive: true })
handleScroll() // 초기 위치 설정
return () => {
window.removeEventListener('scroll', handleScroll)
cancelAnimationFrame(rafId)
}
}, [speed, direction, prefersReduced])
return ref
}
// src/shared/ui/ScrollReveal.tsx
// 뷰포트 진입 시 애니메이션 실행 컴포넌트
import { motion, useInView } from 'motion/react'
import { useRef, type ReactNode } from 'react'
import { useReducedMotion } from '@/shared/lib/reduced-motion'
interface ScrollRevealProps {
children: ReactNode
variant?: 'fadeUp' | 'fadeLeft' | 'scaleIn' | 'none'
delay?: number
once?: boolean
}
const variants = {
fadeUp: {
hidden: { opacity: 0, y: 40 },
visible: { opacity: 1, y: 0 },
},
fadeLeft: {
hidden: { opacity: 0, x: -40 },
visible: { opacity: 1, x: 0 },
},
scaleIn: {
hidden: { opacity: 0, scale: 0.9 },
visible: { opacity: 1, scale: 1 },
},
none: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
},
}
export function ScrollReveal({
children,
variant = 'fadeUp',
delay = 0,
once = true,
}: ScrollRevealProps): ReactNode {
const ref = useRef<HTMLDivElement | null>(null)
const isInView = useInView(ref, { once, margin: '-50px' })
const prefersReduced = useReducedMotion()
// reduced-motion 사용자: transform 없이 opacity만
const selectedVariant = prefersReduced ? 'none' : variant
return (
<motion.div
ref={ref}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
variants={variants[selectedVariant]}
transition={{ duration: 0.5, ease: 'easeOut', delay }}
>
{children}
</motion.div>
)
}
6. 타임라인 라이브러리 & 고급 시퀀스
복잡한 멀티 스텝 애니메이션 시퀀스, SVG 모핑, 정밀한 타이밍 제어는 CSS/WAAPI만으로 유지보수가 어려울 수 있습니다. 이때는 타임라인 라이브러리를 검토하되, 라이브러리명 자체를 조직 표준으로 고정하지 않습니다.
선택 가이드 최신
- CSS Scroll-Driven 또는 Web Animations API로 충분한 케이스에서는 외부 라이브러리 도입을 자제합니다.
- SVG path morph, FLIP, 복잡한 텍스트 분해, 정밀 타임라인처럼 표준 API만으로 비용이 큰 케이스에서만 채택합니다.
- 무료/상용 라이선스, 플러그인 라이선스, tree-shaking, SSR 호환성을 PR에서 증거로 남깁니다.
GSAP React 통합 훅
// src/shared/lib/use-gsap.ts
// GSAP을 React에서 안전하게 사용하기 위한 훅
import { useEffect, useRef, useCallback } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useReducedMotion } from '@/shared/lib/reduced-motion'
// GSAP 플러그인 등록 (한 번만 실행)
gsap.registerPlugin(ScrollTrigger)
interface UseGsapOptions {
/** reduced-motion 시 애니메이션 비활성화 */
respectReducedMotion?: boolean
}
/**
* GSAP 타임라인을 React 생명주기에 맞게 관리하는 훅
* 컴포넌트 언마운트 시 자동으로 kill 처리
*/
export function useGsapTimeline(options: UseGsapOptions = {}) {
const { respectReducedMotion = true } = options
const timelineRef = useRef<gsap.core.Timeline | null>(null)
const prefersReduced = useReducedMotion()
// 타임라인 생성 함수
const createTimeline = useCallback(
(config?: gsap.TimelineVars): gsap.core.Timeline => {
// 기존 타임라인 정리
timelineRef.current?.kill()
const tl = gsap.timeline({
...config,
// reduced-motion 시 즉시 완료
...(respectReducedMotion && prefersReduced ? { duration: 0 } : {}),
})
timelineRef.current = tl
return tl
},
[prefersReduced, respectReducedMotion],
)
// 클린업: 컴포넌트 언마운트 시 타임라인 제거
useEffect(() => {
return () => {
timelineRef.current?.kill()
}
}, [])
return { createTimeline, timeline: timelineRef }
}
GSAP 히어로 섹션 애니메이션
// src/features/hero/HeroAnimation.tsx
// 랜딩 페이지 히어로 섹션의 복잡한 시퀀스 애니메이션
import { useEffect, useRef, type ReactNode } from 'react'
import { useGsapTimeline } from '@/shared/lib/use-gsap'
export function HeroAnimation(): ReactNode {
const containerRef = useRef<HTMLDivElement>(null)
const { createTimeline } = useGsapTimeline()
useEffect(() => {
const container = containerRef.current
if (!container) return
// 멀티 스텝 시퀀스: 배경 → 제목 → 부제목 → CTA 버튼 순서
const tl = createTimeline({ defaults: { ease: 'power3.out' } })
tl
// 1단계: 배경 커튼 열기
.from('[data-hero-bg]', {
scaleY: 0,
transformOrigin: 'top',
duration: 0.8,
})
// 2단계: 제목 글자 단위 stagger 진입
.from(
'[data-hero-title] .char',
{
y: 100,
opacity: 0,
stagger: 0.03,
duration: 0.6,
},
'-=0.3',
) // 이전 애니메이션과 0.3초 겹침
// 3단계: 부제목 페이드인
.from(
'[data-hero-subtitle]',
{
y: 30,
opacity: 0,
duration: 0.5,
},
'-=0.2',
)
// 4단계: CTA 버튼 바운스 진입
.from('[data-hero-cta]', {
scale: 0.8,
opacity: 0,
duration: 0.4,
ease: 'back.out(1.7)', // 탄성 바운스 효과
})
}, [createTimeline])
return (
<div ref={containerRef}>
<div data-hero-bg style={{ background: '#1e293b', minHeight: '60vh' }}>
<h1 data-hero-title>
{/* SplitText 등으로 글자 분리 시 .char 클래스 부여 */}
<span className="char">W</span>
<span className="char">e</span>
<span className="char">l</span>
<span className="char">c</span>
<span className="char">o</span>
<span className="char">m</span>
<span className="char">e</span>
</h1>
<p data-hero-subtitle>부제목 텍스트</p>
<button data-hero-cta>시작하기</button>
</div>
</div>
)
}
GSAP ScrollTrigger 스크롤 연동
// src/shared/lib/use-gsap-scroll.ts
// ScrollTrigger를 활용한 스크롤 기반 애니메이션
import { useEffect, useRef, type RefObject } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useReducedMotion } from '@/shared/lib/reduced-motion'
interface ScrollAnimationConfig {
/** 트리거 시작 위치 (예: 'top 80%') */
start?: string
/** 트리거 종료 위치 (예: 'bottom 20%') */
end?: string
/** 스크롤에 고정 (pin) 여부 */
pin?: boolean
/** 스크롤 진행률에 따라 애니메이션 스크러빙 */
scrub?: boolean | number
}
export function useGsapScrollTrigger<T extends HTMLElement>(
animationFn: (el: T, tl: gsap.core.Timeline) => void,
config: ScrollAnimationConfig = {},
): RefObject<T | null> {
const ref = useRef<T | null>(null)
const prefersReduced = useReducedMotion()
const { start = 'top 80%', end = 'bottom 20%', pin = false, scrub = false } = config
useEffect(() => {
const el = ref.current
if (!el || prefersReduced) return // reduced-motion 시 비활성화
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start,
end,
pin,
scrub,
// Preview 환경에서 마커 표시 (디버그용)
markers: import.meta.env.MODE === 'preview',
},
})
animationFn(el, tl)
return () => {
tl.kill()
ScrollTrigger.getAll().forEach((st) => st.kill())
}
}, [animationFn, start, end, pin, scrub, prefersReduced])
return ref
}
7. 성능 최적화
일상 비유: 60fps는 한 프레임당 약 16.67ms의 짧은 식사 시간이다. 이 시간 안에 스크립트 요리·스타일 차림·레이아웃 세팅·페인트 그리기·합성 서빙을 끝내야 다음 손님(다음 프레임)을 받을 수 있다. transform/opacity는 합성 단계만 사용하므로 식사 시간이 가장 짧다.
이 그림은 16.67ms 안에서 각 단계가 어떤 비중으로 시간을 잡아먹는지를 보여줍니다.
gantt
title 60fps 한 프레임 (16.67ms) 예산 분배 예시
dateFormat ss.SSS
axisFormat %S.%L
section 합성만 (이상적)
Script :s1, 00.000, 0.002s
Style/Layout :s2, 00.002, 0.001s
Paint :s3, 00.003, 0.001s
Composite :s4, 00.004, 0.003s
Idle (여유) :s5, 00.007, 0.010s
section 레이아웃 트리거 (위험)
Script(JS anim) :w1, 00.000, 0.005s
Style :w2, 00.005, 0.003s
Layout reflow :w3, 00.008, 0.005s
Paint :w4, 00.013, 0.003s
Composite :w5, 00.016, 0.001s
이 그림은 한 페이지의 motion budget이 어떻게 분배되는지를 보여줍니다.
flowchart TD Budget[페이지 motion budget<br/>예: total 4초 / frame drop 0] --> Hero[Hero 진입<br/>0.8초 1회] Budget --> Micro[마이크로 인터랙션<br/>0.1-0.3초 N회] Budget --> Page[페이지 전환<br/>0.3-0.5초] Budget --> Scroll[스크롤 연동<br/>지속·CSS scroll/view timeline] Hero --> Reserve[INP/long task<br/>예산 50ms 남김] Micro --> Reserve Page --> Reserve Scroll --> Reserve Reserve --> Verify[Lighthouse/RUM<br/>p75 검증]
참고: 애니메이션 성능은 Core Web Vitals의 INP(Interaction to Next Paint)에 직접 영향을 줍니다. 08. 성능 최적화 가이드의 INP 섹션을 함께 참고하세요.
성능 규칙
| 규칙 | 설명 | 위반 시 영향 |
|---|---|---|
| GPU 속성만 애니메이션 |
transform, opacity만 사용. width,
height, top, left 금지
|
레이아웃 리플로우 발생, 프레임 드롭 |
| will-change 최소 사용 | 애니메이션 시작 직전 추가, 종료 후 제거 | 메모리 과다 소비, GPU 레이어 폭증 |
| contain 활용 | contain: layout style paint로 리플로우 격리 |
부모/형제 요소까지 리플로우 전파 |
| 뷰포트 밖 정지 | IntersectionObserver로 보이지 않는 애니메이션 정지 | CPU/GPU 리소스 낭비 |
| requestAnimationFrame | JS 애니메이션은 반드시 rAF 사용 | 프레임 타이밍 불일치, 쟁크 |
| composite 최적화 | Web Animations API의 composite: 'add' 활용 |
애니메이션 합성 비효율 |
| CSS 우선 원칙 | CSS로 가능한 것은 JS 미사용 | 메인 스레드 차단, 번들 사이즈 증가 |
will-change 안전 사용 패턴
// src/shared/lib/will-change-manager.ts
// will-change를 안전하게 관리하는 유틸 (메모리 누수 방지)
/**
* will-change는 브라우저에 "곧 이 속성이 변경될 것"이라고 알려주는 힌트.
* 남용하면 GPU 레이어가 과도하게 생성되어 오히려 성능 저하 발생.
* 이 유틸은 애니메이션 직전에 추가하고 완료 후 자동 제거한다.
*/
export function applyWillChange(
element: HTMLElement,
properties: string[],
durationMs: number = 1000,
): () => void {
// will-change 속성 설정
element.style.willChange = properties.join(', ')
// 지정 시간 후 자동 제거 (메모리 보호)
const timerId = window.setTimeout(() => {
element.style.willChange = 'auto'
}, durationMs)
// 수동 정리 함수 반환
return () => {
window.clearTimeout(timerId)
element.style.willChange = 'auto'
}
}
/**
* 호버/포커스 시에만 will-change 적용하는 이벤트 리스너
* 상시 will-change보다 메모리 효율적
*/
export function applyWillChangeOnInteraction(
element: HTMLElement,
properties: string[],
): () => void {
const handleEnter = (): void => {
element.style.willChange = properties.join(', ')
}
const handleLeave = (): void => {
element.style.willChange = 'auto'
}
element.addEventListener('mouseenter', handleEnter)
element.addEventListener('mouseleave', handleLeave)
element.addEventListener('focusin', handleEnter)
element.addEventListener('focusout', handleLeave)
return () => {
element.removeEventListener('mouseenter', handleEnter)
element.removeEventListener('mouseleave', handleLeave)
element.removeEventListener('focusin', handleEnter)
element.removeEventListener('focusout', handleLeave)
element.style.willChange = 'auto'
}
}
애니메이션 성능 모니터 (Preview 전용)
// src/shared/lib/animation-performance-monitor.ts
import { envConfig } from '@/shared/config/environment'
interface AnimationMetric {
name: string
fps: number
droppedFrames: number
duration: number
}
/** 애니메이션 실행 중 실시간 FPS를 측정하는 유틸 */
export function measureAnimationPerformance(
name: string,
durationMs: number = 1000,
): Promise<AnimationMetric> {
// 프로덕션에서는 측정 생략 (성능 오버헤드 방지)
if (envConfig.deployEnv !== 'preview') {
return Promise.resolve({ name, fps: 60, droppedFrames: 0, duration: durationMs })
}
return new Promise((resolve) => {
let frameCount = 0
let startTime = 0
const countFrame = (timestamp: number): void => {
if (startTime === 0) startTime = timestamp
frameCount++
if (timestamp - startTime < durationMs) {
requestAnimationFrame(countFrame)
} else {
const elapsed = timestamp - startTime
const fps = Math.round((frameCount / elapsed) * 1000)
const expectedFrames = Math.round((elapsed / 1000) * 60)
const droppedFrames = Math.max(0, expectedFrames - frameCount)
const metric: AnimationMetric = { name, fps, droppedFrames, duration: elapsed }
// 50fps 미만: 성능 경고 출력
if (fps < 50) {
console.warn(
`[Animation Performance] "${name}": ${fps}fps (dropped ${droppedFrames} frames)`,
)
}
resolve(metric)
}
}
requestAnimationFrame(countFrame)
})
}
/**
* Performance Observer로 long animation frame 감지
* Chrome 123+의 LoAF(Long Animation Frames) API 활용
*/
export function observeLongAnimationFrames(
callback: (entry: PerformanceEntry) => void,
): () => void {
if (envConfig.deployEnv !== 'preview') return () => {}
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 50ms 초과 프레임 감지 (16.7ms x 3)
if (entry.duration > 50) {
callback(entry)
}
}
})
observer.observe({ type: 'long-animation-frame', buffered: true })
return () => observer.disconnect()
} catch {
// LoAF API 미지원 브라우저: 무시
return () => {}
}
}
8. 접근성 (prefers-reduced-motion)
일상 비유: 식당의 매운맛 선택과 같다. 사용자가 OS 설정에서 "맵게 빼주세요(reduce)"를 표시했는데도 매운 향신료(parallax/큰 이동)를 그대로 내면 손님이 불편(어지러움)을 호소한다. 메뉴(애니메이션)마다 "안 맵게(opacity만)"·"덜 맵게(짧은 duration)"·"빼기(none)" 3옵션을 준비한다.
이 그림은 한 애니메이션을 reduced-motion 사용자에게 어떻게 변환할지 결정하는 흐름을 보여줍니다.
flowchart TD
Start[애니메이션 항목] --> Kind{역할 분류}
Kind -->|장식적| Dec{필수 정보 포함?}
Dec -->|No| Disable[비활성화<br/>aria-hidden]
Dec -->|Yes| Convert1[opacity-only<br/>0.01ms]
Kind -->|기능적| Func[duration 단축 후<br/>최종 상태로 즉시 전환]
Kind -->|상태 피드백| Feed[유지하되 짧게<br/>aria-live=assertive]
Disable --> Snapshot[Playwright screenshot<br/>reducedMotion: reduce]
Convert1 --> Snapshot
Func --> Snapshot
Feed --> Snapshot
Snapshot --> Pass{회귀 없음?}
Pass -->|Yes| Merge[PR 통과]
Pass -->|No| Fix[fallback 재설계]
Fix --> Kind
이 그림은 접근성 검증이 모션 PR의 어디서 자동/수동으로 발생하는지를 보여줍니다.
flowchart LR Dev[모션 구현] --> Lint[ESLint motion 규칙<br/>토큰/속성/RM 분기] Lint --> Unit[단위 테스트<br/>variants reduced 분기] Unit --> E2E[Playwright emulateMedia<br/>reducedMotion reduce] E2E --> A11yScan[axe-core/Pa11y<br/>focus/ARIA] A11yScan --> Manual[수동 키보드 smoke<br/>탭/엔터/Esc] Manual --> Sign[A11y owner sign-off]
참고: 접근성 관련 상세 가이드라인은 19. 웹 접근성 가이드를 참고하세요. 본 섹션은 애니메이션 특화 접근성에 집중합니다.
완전한 접근성 대응
// src/shared/lib/motion-accessibility.ts
import { type Variants } from 'motion/react'
/**
* reduced-motion 사용자를 위한 variant 생성 유틸
* 모든 애니메이션에 적용하여 일관된 접근성 보장
*
* 사용 예시:
* const { full, reduced } = createAccessibleVariants(myVariants);
* const variants = prefersReduced ? reduced : full;
*/
export function createAccessibleVariants(
fullMotion: Variants,
reducedMotion?: Variants,
): { full: Variants; reduced: Variants } {
// reduced-motion 기본값: opacity만 변경, transform 제거
const defaultReduced: Variants = {}
for (const [key, value] of Object.entries(fullMotion)) {
if (typeof value === 'object' && value !== null) {
const v = value as Record<string, unknown>
defaultReduced[key] = {
opacity: v.opacity ?? 1,
transition: { duration: 0.01 },
}
}
}
return {
full: fullMotion,
reduced: reducedMotion ?? defaultReduced,
}
}
/**
* 애니메이션이 있는 요소에 필수 ARIA 속성 반환
* 스크린 리더 사용자에게 애니메이션 콘텐츠의 존재를 알림
*/
export function getMotionAriaProps(description: string): Record<string, string> {
return {
'aria-live': 'polite',
'aria-atomic': 'true',
'aria-label': description,
}
}
/**
* 애니메이션 타입별 접근성 권장사항
* 각 애니메이션 유형에 따라 적절한 접근성 처리를 안내
*/
export const MOTION_A11Y_GUIDELINES = {
/** 장식적 애니메이션: reduced-motion 시 완전 비활성화 */
decorative: {
reducedBehavior: 'disable', // 완전 제거
ariaHidden: true, // 스크린 리더 무시
},
/** 기능적 애니메이션: reduced-motion 시 즉시 완료 */
functional: {
reducedBehavior: 'instant', // duration: 0으로 즉시 최종 상태
ariaHidden: false, // 콘텐츠 정보 유지
},
/** 상태 피드백: reduced-motion과 무관하게 유지 (짧은 duration) */
feedback: {
reducedBehavior: 'keep', // 유지하되 짧게
ariaLive: 'assertive', // 즉시 알림
},
} as const
전역 reduced-motion CSS
/* src/shared/styles/reduced-motion.css */
/* 전역 reduced-motion 스타일 */
@media (prefers-reduced-motion: reduce) {
/* 모든 애니메이션 비활성화 */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Lottie 컨테이너: 정적 이미지로 대체 */
[data-lottie-container] {
display: none;
}
[data-lottie-fallback] {
display: block;
}
/* 스켈레톤: 정적 회색 블록 (shimmer 제거) */
.skeleton::after {
animation: none !important;
}
/* 스크롤 프로그레스: 숨김 (장식적 요소) */
.scroll-progress {
display: none;
}
/* 패럴랙스: 비활성화 (어지러움 유발 가능) */
.parallax-layer {
animation: none !important;
transform: none !important;
}
}
접근성 테스트 E2E (Playwright)
// tests/e2e/motion-accessibility.spec.ts
// 애니메이션 접근성 자동화 테스트 - 07. 테스팅 가이드 참고
import { test, expect } from '@playwright/test'
test.describe('애니메이션 접근성', () => {
test('reduced-motion 설정 시 모든 애니메이션이 비활성화된다', async ({ page }) => {
// 시스템 설정: reduced-motion 활성화
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/')
// 모든 요소의 animation-duration이 0에 가까운지 확인
const animatedElements = await page.locator('[class*="animate"]').all()
for (const el of animatedElements) {
const duration = await el.evaluate((e) => getComputedStyle(e).animationDuration)
expect(parseFloat(duration)).toBeLessThanOrEqual(0.01)
}
})
test('Lottie 대신 정적 이미지가 표시된다', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/')
// Lottie 컨테이너가 숨겨져야 함
const lottieContainers = page.locator('[data-lottie-container]')
await expect(lottieContainers).toHaveCount(0)
// 폴백 이미지가 표시되어야 함
const fallbacks = page.locator('[data-lottie-fallback]')
const count = await fallbacks.count()
if (count > 0) {
await expect(fallbacks.first()).toBeVisible()
}
})
test('Lottie 요소에 적절한 ARIA 속성이 있다', async ({ page }) => {
await page.goto('/')
const lottieElements = page.locator('[data-lottie-container], [data-lottie-fallback]')
const count = await lottieElements.count()
for (let i = 0; i < count; i++) {
const el = lottieElements.nth(i)
await expect(el).toHaveAttribute('role', 'img')
await expect(el).toHaveAttribute('aria-label', /.+/)
}
})
})
9. 마이크로 인터랙션 & Lottie
마이크로 인터랙션 프리셋
// src/shared/ui/micro-interactions.ts
// 재사용 가능한 마이크로 인터랙션 프리셋 모음
// 디자인 시스템 가이드(20)의 토큰과 연동하여 일관성 유지
import { type Variants, type MotionProps } from 'motion/react'
/** 버튼 탭 피드백 - 모든 버튼에 기본 적용 */
export const tapFeedback: MotionProps = {
whileTap: { scale: 0.97 },
transition: { type: 'spring', stiffness: 500, damping: 30 },
}
/** 카드 호버 - 인터랙티브 카드에 적용 */
export const cardHover: MotionProps = {
whileHover: {
scale: 1.02,
boxShadow: '0 8px 30px rgba(0,0,0,0.12)',
transition: { duration: 0.2 },
},
whileTap: { scale: 0.98 },
}
/** 토글 스위치 - on/off 상태 전환 */
export const toggleVariants: Variants = {
off: { x: 0, backgroundColor: '#d1d5db' },
on: { x: 20, backgroundColor: '#3b82f6' },
}
/** 알림 뱃지 진입 - 숫자 뱃지가 팝업되는 효과 */
export const badgePop: Variants = {
hidden: { scale: 0, opacity: 0 },
visible: {
scale: 1,
opacity: 1,
transition: { type: 'spring', stiffness: 500, damping: 25, delay: 0.2 },
},
}
/** 셰이크 (에러 피드백) - 폼 유효성 검증 실패 시 */
export const shakeVariants: Variants = {
idle: { x: 0 },
shake: {
x: [0, -10, 10, -10, 10, 0],
transition: { duration: 0.4 },
},
}
/** 슬라이드 인 (알림/알림) - 화면 외부에서 진입 */
export const slideInVariants: Variants = {
hidden: { x: '100%', opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: { type: 'spring', stiffness: 300, damping: 30 },
},
exit: {
x: '100%',
opacity: 0,
transition: { duration: 0.2, ease: 'easeIn' },
},
}
/** 스켈레톤 펄스 - 콘텐츠 로딩 대기 상태 */
export const skeletonPulse: MotionProps = {
animate: { opacity: [0.5, 1, 0.5] },
transition: { duration: 1.5, repeat: Infinity, ease: 'easeInOut' },
}
/** 포커스 링 확장 - 접근성 강화 포커스 표시 */
export const focusRing: MotionProps = {
whileFocus: {
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.5)',
transition: { duration: 0.15 },
},
}
Lottie 최적화 래퍼
// src/shared/ui/OptimizedLottie.tsx
// Lottie 애니메이션 프로덕션 최적화 래퍼
// - 지연 로딩 (Suspense + IntersectionObserver)
// - reduced-motion 시 정적 이미지 대체
// - Canvas 렌더러 (대형 애니메이션 성능 우수)
import { lazy, Suspense, useRef, useEffect, useState, type ReactNode } from 'react'
import { useReducedMotion } from '@/shared/lib/reduced-motion'
import { envConfig } from '@/shared/config/environment'
// dotLottie 플레이어 지연 로딩 (초기 번들에 포함하지 않음)
const DotLottiePlayer = lazy(() =>
import('@dotlottie/react-player').then((m) => ({ default: m.DotLottiePlayer })),
)
interface OptimizedLottieProps {
src: string
fallbackSrc: string // 정적 이미지 (reduced-motion용)
width?: number
height?: number
loop?: boolean
autoplay?: boolean
ariaLabel: string // 접근성 필수
renderer?: 'svg' | 'canvas'
}
export function OptimizedLottie({
src,
fallbackSrc,
width = 200,
height = 200,
loop = true,
autoplay = true,
ariaLabel,
renderer = 'canvas', // Canvas가 대형 애니메이션에서 SVG보다 성능 우수
}: OptimizedLottieProps): ReactNode {
const prefersReduced = useReducedMotion()
const containerRef = useRef<HTMLDivElement | null>(null)
const [isVisible, setIsVisible] = useState(false)
// IntersectionObserver로 뷰포트 진입 시에만 로드
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.unobserve(el) // 한 번 로드 후 관찰 중단
}
},
{ rootMargin: '100px' }, // 100px 미리 로드 시작
)
observer.observe(el)
return () => observer.disconnect()
}, [])
// reduced-motion 사용자: 정적 이미지로 완전 대체
if (prefersReduced) {
return (
<div data-lottie-fallback="" role="img" aria-label={ariaLabel}>
<img src={fallbackSrc} width={width} height={height} alt={ariaLabel} />
</div>
)
}
return (
<div
ref={containerRef}
data-lottie-container=""
role="img"
aria-label={ariaLabel}
style={{ width, height }}
>
{isVisible && (
<Suspense fallback={<div style={{ width, height, background: '#f3f4f6' }} />}>
<DotLottiePlayer
src={src}
loop={loop}
autoplay={autoplay}
renderer={renderer}
style={{ width, height }}
/>
</Suspense>
)}
{/* Preview 환경: Lottie 디버그 정보 표시 */}
{envConfig.deployEnv === 'preview' && isVisible && (
<div
style={{
fontSize: '10px',
color: '#6b7280',
textAlign: 'center',
fontFamily: 'monospace',
}}
>
{src.split('/').pop()} | {renderer} | {loop ? 'loop' : 'once'}
</div>
)}
</div>
)
}
10. 모션 디자인 토큰 & 시스템 통합
참고: 디자인 토큰 체계는 20. 디자인 시스템 가이드의 토큰 섹션과 통합하여 사용합니다.
애니메이션의 일관성을 보장하기 위해 duration, easing, delay 등을 디자인 토큰으로 관리합니다. 하드코딩된 값 대신 토큰을 사용하면 전역 조정과 실험군 검증이 쉬워집니다.
일상 비유: 모든 직원이 회사 표준 시계(토큰)를 차도록 강제하면, 매장마다 다른 시간으로 회의를 잡는 일이 사라진다. 디자이너가 표준 시계의 분 단위를 바꾸면 전 매장이 동시에 따라간다.
이 그림은 디자인 토큰이 어디서 정의되고 어떤 경로로 코드/도구에 흘러 들어가는지 의존성을 보여줍니다.
flowchart TD Source[Figma Tokens / DTCG] --> Build[Style Dictionary 빌드] Build --> CSS["motion-tokens.css<br/>@theme custom property"] Build --> TS["motion-tokens.ts<br/>DURATION/EASE/STAGGER"] Build --> Tailwind["tailwind.config<br/>animation utilities"] CSS --> ComponentCSS[CSS/SCSS 컴포넌트] TS --> Motion[motion/react variants] TS --> GSAP[GSAP timeline] Tailwind --> Class[Tailwind class 사용처] ComponentCSS --> Lint[ESLint no-hardcoded-duration] Motion --> Lint GSAP --> Lint Class --> Lint Lint --> PR[PR 게이트]
모션 토큰 정의
/* src/shared/styles/motion-tokens.css */
/* Tailwind v4 CSS-first 설정과 통합 */
@theme {
/* ── Duration 토큰 ── */
--duration-instant: 0ms; /* 상태 변경 (즉시) */
--duration-fast: 100ms; /* 마이크로 인터랙션 (호버, 탭) */
--duration-normal: 200ms; /* 일반 전환 (토글, 드롭다운) */
--duration-slow: 300ms; /* 진입/퇴장 애니메이션 */
--duration-slower: 500ms; /* 페이지 전환, 모달 */
--duration-slowest: 800ms; /* 히어로 섹션, 랜딩 */
/* ── Easing 토큰 ── */
--ease-default: cubic-bezier(0.4, 0, 0.2, 1); /* Material 기본 */
--ease-in: cubic-bezier(0.4, 0, 1, 1); /* 가속 (퇴장) */
--ease-out: cubic-bezier(0, 0, 0.2, 1); /* 감속 (진입) */
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* 가속→감속 */
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); /* 바운스 느낌 */
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55); /* 강한 바운스 */
/* ── Stagger 토큰 ── */
--stagger-fast: 30ms; /* 리스트 아이템 */
--stagger-normal: 50ms; /* 카드 그리드 */
--stagger-slow: 80ms; /* 히어로 텍스트 */
}
TypeScript 모션 토큰
// src/shared/config/motion-tokens.ts
// CSS 토큰과 동기화된 TypeScript 상수
/** Duration 토큰 (초 단위 - Framer Motion 호환) */
export const DURATION = {
instant: 0,
fast: 0.1,
normal: 0.2,
slow: 0.3,
slower: 0.5,
slowest: 0.8,
} as const
/** Easing 토큰 */
export const EASE = {
default: [0.4, 0, 0.2, 1] as const,
in: [0.4, 0, 1, 1] as const,
out: [0, 0, 0.2, 1] as const,
inOut: [0.4, 0, 0.2, 1] as const,
spring: { type: 'spring' as const, stiffness: 300, damping: 30 },
bounce: { type: 'spring' as const, stiffness: 500, damping: 25 },
} as const
/** Stagger 토큰 (초 단위) */
export const STAGGER = {
fast: 0.03,
normal: 0.05,
slow: 0.08,
} as const
/**
* 토큰 기반 transition 생성 헬퍼
* 하드코딩 대신 토큰을 사용하여 일관성 보장
*/
export function createTransition(
duration: keyof typeof DURATION = 'normal',
ease: keyof Omit<typeof EASE, 'spring' | 'bounce'> = 'default',
) {
return {
duration: DURATION[duration],
ease: EASE[ease],
}
}
ESLint 규칙: 하드코딩 방지
// eslint-plugin-motion/no-hardcoded-duration.ts
// 애니메이션 duration 하드코딩을 감지하고 토큰 사용을 권장하는 ESLint 규칙
/*
* 잘못된 예:
* transition={{ duration: 0.3 }}
*
* 올바른 예:
* import { DURATION } from '@/shared/config/motion-tokens';
* transition={{ duration: DURATION.slow }}
*/
11. 체크리스트
기본 품질 체크리스트
| # | 항목 | 확인 |
|---|---|---|
| 1 | GPU 가속 속성만 사용 (transform, opacity) |
[ ] |
| 2 | prefers-reduced-motion 대응 완료 |
[ ] |
| 3 | 뷰포트 밖 애니메이션 정지 (IntersectionObserver) | [ ] |
| 4 | will-change 최소 사용 (시작 전 추가, 종료 후 제거) |
[ ] |
| 5 | Lottie 파일 dotLottie 변환 완료 | [ ] |
| 6 | 접근성 ARIA 속성 추가 (role, aria-label) |
[ ] |
| 7 | Feature Flag로 모션 on/off 가능 | [ ] |
| 8 | Preview 환경에서 디버그 도구 동작 확인 | [ ] |
| 9 | 모션 실험군 설정 완료 | [ ] |
| 10 | 60fps 이상 유지 확인 (DevTools Performance 탭) | [ ] |
성능 체크리스트
| # | 항목 | 확인 |
|---|---|---|
| 1 |
left/top/width/height
애니메이션 금지 (transform 사용)
|
[ ] |
| 2 | CSS로 가능한 애니메이션은 JS 미사용 | [ ] |
| 3 |
Framer Motion tree-shaking 적용 (import { motion } 개별 임포트)
|
[ ] |
| 4 | Lottie 지연 로딩 (React.lazy + Suspense) | [ ] |
| 5 | contain: layout style paint 적용 (리플로우 격리) |
[ ] |
| 6 | rAF 기반 JS 애니메이션 (setTimeout/setInterval 금지) | [ ] |
| 7 | 모션 토큰 사용 (하드코딩 duration/easing 금지) | [ ] |
| 8 | 번들 분석에서 미사용 GSAP 플러그인 제외 확인 | [ ] |
접근성 체크리스트
| # | 항목 | 확인 |
|---|---|---|
| 1 | prefers-reduced-motion: reduce 미디어 쿼리 전역 적용 |
[ ] |
| 2 | 장식적 애니메이션: reduced-motion 시 완전 비활성화 | [ ] |
| 3 | 기능적 애니메이션: reduced-motion 시 즉시 완료 (duration: 0) | [ ] |
| 4 | Lottie/Canvas에 role="img" + aria-label 추가 |
[ ] |
| 5 | 자동 재생 애니메이션에 일시정지 버튼 제공 (WCAG 2.2.2) | [ ] |
| 6 | 깜빡임 주파수 3Hz 이하 유지 (WCAG 2.3.1) | [ ] |
| 7 | Playwright E2E 테스트에 reduced-motion 시나리오 포함 | [ ] |
배포 전 체크리스트
| # | 항목 | 확인 |
|---|---|---|
| 1 | Preview 환경에서 모든 애니메이션 육안 검증 | [ ] |
| 2 | 디버그 패널/오버레이가 프로덕션에서 제거되는지 확인 | [ ] |
| 3 | View Transitions L1/L2 미지원 브라우저 (13. 호환성 가이드) 폴백 동작 확인 | [ ] |
| 4 | 모바일 디바이스 (저사양) 성능 확인 | [ ] |
| 5 | Feature Flag 기본값이 올바른지 확인 | [ ] |
| 6 | Core Web Vitals INP 영향 확인 (08. 성능 가이드) | [ ] |
최신 신기술 도입 체크리스트
| # | 항목 | 확인 |
|---|---|---|
| 1 | React motion library 사용 시 공식 release/라이선스/번들 크기 검증 완료 | [ ] |
| 2 |
MPA 전환에 @view-transition { navigation: auto } 적용 (Level 2)
|
[ ] |
| 3 |
스프링 곡선을 linear() easing으로 표현 (JS 라이브러리 제거 가능
여부 확인)
|
[ ] |
| 4 | CSS Scroll-Driven Animations로 JS scroll library 대체 가능한 부분 식별 | [ ] |
| 5 | Timeline library 사용 시 플러그인별 라이선스와 tree-shaking 확인 | [ ] |
| 6 | LoAF(Long Animation Frames) PerformanceObserver로 50ms+ 프레임 모니터링 | [ ] |
| 7 |
View Transitions L2와 prefers-reduced-motion 충돌 없음 확인
|
[ ] |
연관 가이드
| 가이드 | 연관 내용 |
|---|---|
| 02. React 19 실무 가이드 | React 19 호환 애니메이션 패턴, Suspense 연동 |
| 07. 테스팅 가이드 | Playwright E2E 애니메이션 테스트, reduced-motion 시나리오 |
| 08. 성능 최적화 가이드 | Core Web Vitals, INP, 번들 사이즈 최적화 |
| 13. 브라우저 호환성 가이드 | View Transitions API 지원 현황, 폴백 전략 |
| 19. 웹 접근성 가이드 | WCAG 모션 관련 기준, prefers-reduced-motion |
| 20. 디자인 시스템 가이드 | 디자인 토큰, Tailwind v4, 컴포넌트 아키텍처 |
실무 적용 가이드
언제 이 문서를 펼칠까
- 모션이 UX를 돕는지 방해하는지 판단해야 할 때
- route transition이나 scroll animation이 성능/접근성을 악화시킬 때
- reduced motion 대응을 표준화해야 할 때
적용 순서
- 모션의 목적을 feedback, continuity, hierarchy, delight 중 하나로 정한다.
- duration, easing, interruption, cancel 조건을 정의한다.
- prefers-reduced-motion 대응을 먼저 만든다.
- INP, long task, frame time을 trace로 확인한다.
- flag나 fallback으로 모션을 끌 수 있게 한다.
함께 두는 파일
- feature 전용 motion config와 component를 같은 폴더에 둔다.
- 공통 transition primitive는 접근성/성능 테스트와 함께 shared로 둔다.
- route transition fallback은 route 가까이에 둔다.
흔한 실수
- 장식 목적으로 모든 상태에 animation을 넣는다.
- 사용자 입력 중 애니메이션이 취소되지 않는다.
- reduced motion에서 opacity/transform만 줄이고 flow를 깨뜨린다.
- 무거운 motion library를 초기 번들에 넣는다.
PR 완료 기준
- [ ] motion 목적과 off 조건이 있다.
- [ ] reduced motion 테스트가 있다.
- [ ] performance trace가 있다.
- [ ] fallback/rollback이 가능하다.
추천 항목 실행 우선순위 매핑
-
P1(7일 내)— interaction motion, reduced motion, animation budget 중 하나를 작은 변경 1건에 적용하고 증거(motion spec)를 남긴다. -
P2(30일 내)— 모션 기준을 팀 템플릿, 체크리스트, CI 중 한 곳에 고정한다. -
P3(90일 내)— INP 악화, long task, motion sickness complaint, dropped frames 추이를 보고 기준을 유지할지 조정할지 결정한다. -
완료 기준— 모션 오너가 증거와 철회 조건을 확인했다는 기록을 남긴다.
추천 항목 실행 체크리스트
-
[ ]
1단계(7일): interaction motion, reduced motion, animation budget 적용 대상을 1개로 좁힌다. -
[ ]
2단계(30일): 증거(motion spec, perf trace, reduced-motion capture, visual diff)를 PR, ADR, 회고 중 한 곳에 연결한다. -
[ ]
3단계(60일): INP 악화, long task, motion sickness complaint, dropped frames가 기준 안에 들어왔는지 확인한다. -
[ ]
문제 대응: 미달성 사유와 다음 조치, 중단 여부를 같은 기록에 남긴다.
추천 항목 실행 운영 규칙
-
실행 게이트: 모션이 상태 변화를 설명하는지, 장식인지 먼저 구분한다. -
승인 체계: 모션 오너가 영향 범위와 rollback 담당자를 적용 전에 확인한다. -
재개 조건: reduced motion과 performance trace가 통과하면 적용 범위를 넓힌다. -
정지 조건: INP나 accessibility preference를 해치면 motion을 끄거나 단순화한다. -
리스크 점수: 애니메이션 빈도, viewport 점유, device 성능으로 산정한다. 리더 승인자: UX 엔지니어링 리드가 최종 승인 책임을 맡는다.승인 역할: 모션 작성자, 검토자, 운영 확인자를 분리해 기록한다.-
재평가 주기: 주요 interaction 변경마다 reduced-motion 경로를 다시 본다.