Motion Foundations
모션 시스템의 기본 계층입니다. 모든 값, 제약 조건, 규칙을 정의하며, 다운스트림 스킬(motion-patterns, motion-advanced)이 이를 상속받습니다. 애니메이션 작업을 시작하기 전에 이 스킬을 로드하세요.
활성화 시점
- 처음부터 애니메이션 컴포넌트를 만들 때
- 토큰, 스프링 프리셋, 이징 값을 설정할 때
prefers-reduced-motion지원을 구현할 때- 애니메이션 초기 상태로 인한 수화 불일치를 디버깅할 때
- 애니메이션이 존재해야 하는지 평가할 때
출력
이 스킬은 다음을 생성합니다:
- 공유
motionTokens객체 (duration, easing, distance, scale) - 공유
springs프리셋 맵 (5개의 명명된 구성) - 모든 컴포넌트에서 사용하는
shouldAnimate()게이트 useReducedMotion을 통한 접근성 준수 애니메이션 기본값- 수화 경고가 없는 SSR 안전 초기 상태
원칙
모션은 다음 중 하나 이상을 수행해야 하며, 그렇지 않으면 제거되어야 합니다:
- 주의를 유도
- 상태를 전달
- 공간적 연속성을 유지
반응성은 항상 부드러움보다 우선합니다. 입력 지연을 유발하는 60fps 애니메이션은 애니메이션이 없는 것보다 나쁩니다.
규칙
이 규칙은 절대적으로 지켜야 합니다. 시스템의 모든 컴포넌트에 적용됩니다.
motion/react만 사용하세요. 절대framer-motion에서 가져오지 마세요. 같은 트리에서 둘을 섞지 마세요.initial은 서버 출력과 일치해야 합니다. 서버가opacity: 1을 렌더링하면initialprop도opacity: 1이어야 합니다. 예외는 없습니다.- Reduced motion이 모든 것을 재정의합니다.
useReducedMotion()이true를 반환하거나prefersReduced가true이면 모든 트랜스폼이 비활성화됩니다. ≤ 0.2초의opacity-only 페이드만 허용되는 대체 수단입니다. - 절대 layout 속성을 애니메이트하지 마세요.
width,height,top,left,margin,padding은animate에서 금지됩니다.transform과opacity만 사용하세요. - 모든 토큰 값은
motionTokens에서 가져옵니다. 컴포넌트 파일에 하드코딩된 duration과 easing은 금지됩니다. - 모든 스프링 구성은
springs맵에서 가져옵니다. 인라인stiffness/damping값은 금지됩니다. motion/react에서 import하는 모든 파일에는"use client"가 필요합니다.- 모듈 레벨에서
window나navigator를 절대 읽지 마세요. 항상typeof window !== "undefined"로 보호하세요.
결정 가이드
Duration 선택
| 토큰 | 사용 시점 |
|---|---|
instant | 툴팁 표시/숨기기, 포커스 링, 배지 업데이트 |
fast | 버튼 피드백, 아이콘 교체, 칩 토글 |
normal | 모달 열기, 카드 확장, 페이지 요소 진입 |
slow | 히어로 진입, 전체 페이지 전환 |
crawl | 의도적인 스토리텔링; 드물게 사용 |
Spring 선택
| 프리셋 | 사용 시점 |
|---|---|
snappy | 기본 UI — 버튼, 칩, 네비게이션 아이템 |
gentle | 카드, 모달, 패널 부드럽게 안착 |
bouncy | 장난스러운 순간 — 빈 상태, 온보딩 |
instant | 툴팁, 팝오버, 드롭다운 |
release | 드래그 릴리스 — 자연스러운 물리 느낌 |
애니메이션을 완전히 비활성화해야 하는 경우
다음 경우에 shouldAnimate()가 false를 반환하도록 비활성화하세요:
prefersReduced가true인 경우isLowEnd가true이고 애니메이션이 필수가 아닌 경우- 요소가 화면 밖에 있고 뷰포트에 진입하지 않을 경우
- 애니메이션이 순수하게 장식적이며 UX 목적이 없는 경우
핵심 개념
토큰 시스템
// lib/motion-tokens.ts
export const motionTokens = {
duration: {
instant: 0.08,
fast: 0.18,
normal: 0.35,
slow: 0.6,
crawl: 1.0,
},
easing: {
smooth: [0.22, 1, 0.36, 1],
sharp: [0.4, 0, 0.2, 1],
bounce: [0.34, 1.56, 0.64, 1],
linear: [0, 0, 1, 1],
},
distance: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 48,
},
scale: {
subtle: 0.98,
press: 0.95,
pop: 1.04,
},
}
export const springs = {
snappy: { type: "spring", stiffness: 300, damping: 30 },
gentle: { type: "spring", stiffness: 120, damping: 14 },
bouncy: { type: "spring", stiffness: 400, damping: 10 },
instant: { type: "spring", stiffness: 600, damping: 35 },
release: { type: "spring", stiffness: 200, damping: 20, restDelta: 0.001 },
}
런타임 플래그
// lib/motion-config.ts
export const motionConfig = {
isLowEnd() {
return (
typeof navigator !== "undefined" &&
navigator.hardwareConcurrency <= 4
)
},
prefersReduced() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
},
shouldAnimate({ essential = false } = {}) {
if (this.prefersReduced()) return false
if (!essential && this.isLowEnd()) return false
return true
},
duration() {
return this.isLowEnd() || this.prefersReduced()
? motionTokens.duration.instant
: motionTokens.duration.normal
},
}
접근성
우선순위 (높은 순에서 낮은 순):
prefers-reduced-motion: reduce— 모든 트랜스폼을 비활성화하고 opacity 전환을 ≤ 0.2초로 제한- 저사양 기기 감지 — duration을 줄이고 필수가 아닌 애니메이션 제거
- 디자인 선호도 — 그 외 모든 것
모션은 우아하게 저하되어야 합니다. 레이아웃 이동을 일으키거나 방향 감각을 혼란스럽게 하는 방식으로 갑자기 사라져서는 안 됩니다.
// hooks/use-reduced-motion.tsx
"use client"
import { useReducedMotion } from "motion/react"
export function useSafeMotion(fullY: number = 16) {
const reduce = useReducedMotion()
return {
initial: { opacity: 0, y: reduce ? 0 : fullY },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: reduce ? 0 : -fullY },
}
}
/* globals.css */
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition { transition: opacity 0.15s; }
.motion-reduce-transform { transform: none !important; }
}
<!-- Tailwind -->
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
SSR / 수화 안전성
규칙: initial은 항상 서버가 렌더링하는 것과 일치해야 합니다.
// 잘못된 예 — 서버는 opacity:1을 렌더링하지만 initial은 0 → 수화 불일치
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
// 올바른 예 — AnimatePresence를 사용하거나 클라이언트 마운트로 지연
"use client"
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
<motion.div
initial={{ opacity: mounted ? 0 : 1 }}
animate={{ opacity: 1 }}
/>
코드 예제
종합: 토큰 + 스프링 + 접근성 + SSR 가드
// components/fade-in-card.tsx
"use client"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"
import { motionConfig } from "@/lib/motion-config"
interface FadeInCardProps {
children: React.ReactNode
delay?: number
}
export function FadeInCard({ children, delay = 0 }: FadeInCardProps) {
// SSR 가드 — initial은 서버 출력(opacity: 1)과 일치해야 함
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// 접근성 — reduced motion이 선호되면 트랜스폼 비활성화
const safeMotion = useSafeMotion(motionTokens.distance.md)
// 기기 게이트 — 저사양 하드웨어에서 애니메이션 건너뛰기
if (!motionConfig.shouldAnimate() || !mounted) {
return <div>{children}</div>
}
return (
<motion.div
initial={safeMotion.initial}
animate={safeMotion.animate}
exit={safeMotion.exit}
transition={{
...springs.gentle,
delay,
}}
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
>
{children}
</motion.div>
)
}
제약 조건 / 비목표
이 스킬은 다음을 다루지 않습니다:
- UI 컴포넌트 패턴 (버튼, 모달, 스태거) →
motion-patterns참고 - 드래그, 제스처, SVG, 텍스트 애니메이션, 커스텀 훅 →
motion-advanced참고 - CSS-only 애니메이션 또는
motion/react없는 Tailwindanimate-*클래스 - 서드파티 애니메이션 라이브러리 (GSAP, anime.js 등)
- 모션 디자인 결정 (언제 애니메이트할지, 무엇을 강조할지) — 이는 코드 제약이 아닌 디자인 영역
안티 패턴
| 안티 패턴 | 위반한 규칙 | 수정 방법 |
|---|---|---|
import { motion } from "framer-motion" | 규칙 1 | motion/react 사용 |
SSR 컴포넌트에서 initial={{ opacity: 0 }} | 규칙 2 | 마운트 가드 추가 |
useReducedMotion 확인 생략 | 규칙 3 | useSafeMotion 훅 사용 |
animate={{ width: "100%" }} | 규칙 4 | 대신 scaleX 트랜스폼 사용 |
인라인 transition={{ duration: 0.4 }} | 규칙 5 | motionTokens.duration.normal 사용 |
인라인 { stiffness: 300, damping: 30 } | 규칙 6 | springs.snappy 사용 |
"use client" 지시문 누락 | 규칙 7 | 파일 상단에 추가 |
모듈 레벨에서 navigator.hardwareConcurrency | 규칙 8 | typeof navigator !== "undefined"로 감싸기 |
관련 스킬
motion-patterns— 여기에 정의된 토큰과 스프링을 소비하여 버튼, 모달, 스태거, 페이지 전환, 스크롤 패턴을 구축합니다. 어떤 값도 재정의하지 않습니다.motion-advanced— 여기에 정의된 토큰과 스프링을 소비하여 드래그, SVG, 텍스트, 제스처 패턴을 구현합니다. 이 기반 위에useAnimate시퀀스와 커스텀 훅을 추가합니다.