Motion Foundations
모션 시스템의 기본 계층입니다. 하위 스킬(motion-patterns, motion-advanced)이 상속하는 모든 값, 제약 조건 및 규칙을 정의합니다.
애니메이션 작업을 시작하기 전에 이 스킬을 로드하세요.
활성화 시점
- 처음부터 애니메이션 컴포넌트를 시작할 때
- 토큰, 스프링 프리셋 또는 이징 값을 설정할 때
prefers-reduced-motion지원을 구현할 때- 애니메이션 초기 상태로 인한 하이드레이션 불일치를 디버깅할 때
- 애니메이션이 존재해야 하는지 여부를 평가할 때
산출물
이 스킬은 다음을 생성합니다:
- 공유
motionTokens객체 (지속 시간, 이징, 거리, 배율) - 공유
springs프리셋 맵 (5개의 명명된 설정) - 모든 컴포넌트에서 사용하는
shouldAnimate()게이트 useReducedMotion을 통한 접근성 준수 애니메이션 기본값- 하이드레이션 경고가 없는 SSR 안전 초기 상태
원칙
모션은 다음 중 하나 이상을 수행해야 하며, 그렇지 않으면 제거되어야 합니다:
- 주의를 안내
- 상태를 전달
- 공간적 연속성을 유지
반응성은 항상 부드러움보다 우선합니다. 입력 지연을 유발하는 60fps 애니메이션은 애니메이션이 없는 것보다 나쁩니다.
규칙
이 규칙은 절대적입니다. 시스템의 모든 컴포넌트에 적용됩니다.
motion/react만 사용하세요. 절대framer-motion에서 임포트하지 마세요. 같은 트리에서 둘을 혼용하지 마세요.initial은 서버 출력과 일치해야 합니다. 서버가opacity: 1을 렌더링하면initialprop도opacity: 1이어야 합니다. 예외는 없습니다.- Reduced motion이 모든 것을 재정의합니다.
useReducedMotion()이true를 반환하거나prefersReduced가true이면 모든 변형이 비활성화됩니다. 불투명도만 페이드(≤ 0.2초)가 유일하게 허용되는 대체입니다. - 레이아웃 속성을 애니메이트하지 마세요.
width,height,top,left,margin,padding은animate에서 금지됩니다.transform과opacity만 사용하세요. - 모든 토큰 값은
motionTokens에서 가져옵니다. 컴포넌트 파일에 하드코딩된 지속 시간과 이징은 금지됩니다. - 모든 스프링 설정은
springs맵에서 가져옵니다. 인라인stiffness/damping값은 금지됩니다. motion/react에서 임포트하는 모든 파일에는"use client"가 필요합니다.- 모듈 레벨에서
window나navigator를 읽지 마세요. 항상typeof window !== "undefined"로 보호하세요.
결정 가이드
지속 시간 선택
| 토큰 | 사용 시기 |
|---|---|
instant | 툴팁 표시/숨기기, 포커스 링, 배지 업데이트 |
fast | 버튼 피드백, 아이콘 교체, 칩 토글 |
normal | 모달 열기, 카드 확장, 페이지 요소 등장 |
slow | 히어로 등장, 전체 페이지 전환 |
crawl | 의도적인 스토리텔링; 아껴서 사용 |
스프링 선택
| 프리셋 | 사용 시기 |
|---|---|
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— 모든 변형을 비활성화하고 불투명도 전환을 ≤ 0.2초로 제한- 저사양 기기 감지 — 지속 시간을 줄이고 필수적이지 않은 애니메이션 제거
- 디자인 선호도 — 그 외 모든 것
모션은 점진적으로 저하되어야 합니다. 갑자기 사라져서 레이아웃 이동을 일으키거나 방향 감각을 혼란스럽게 해서는 안 됩니다.
// 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 전용 애니메이션 또는
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시퀀스와 커스텀 훅을 추가합니다.