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 대응을 표준화해야 할 때

적용 순서

  1. 모션의 목적을 feedback, continuity, hierarchy, delight 중 하나로 정한다.
  2. duration, easing, interruption, cancel 조건을 정의한다.
  3. prefers-reduced-motion 대응을 먼저 만든다.
  4. INP, long task, frame time을 trace로 확인한다.
  5. 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 경로를 다시 본다.