Motion Patterns
가장 일반적인 UI 애니메이션 요구 사항을 위한 복사-붙여넣기 패턴입니다.
모든 패턴은 motion-foundations 토큰과 스프링을 기반으로 합니다.
여기서 새로운 duration이나 easing 값을 정의하지 말고 가져와서 사용하세요.
활성화 시점
- 버튼, 카드, 모달 또는 토스트 알림에 애니메이션 적용
- 스테거를 사용한 리스트 요소 진입 구현
- Next.js App Router에서 페이지 전환 설정
- 조건부 콘텐츠에 진입 또는 종료 애니메이션 추가
- 스크롤-리빌, 스크롤 연결 프로그레스 또는 고정 스토리 섹션 구현
- 확장 카드, 아코디언 또는 공유 요소 전환 구축
산출물
이 스킬은 다음을 생성합니다:
- 모든 표준 UI 컴포넌트에 대한 접근 가능하고 SSR-안전한 애니메이션
- 올바른 종료 동작이 있는 조건부 렌더링
AnimatePresence래핑 - Next.js App Router용 페이지 전환 래퍼 컴포넌트
useScroll+useTransform을 사용한 스크롤-리빌 및 스크롤 연결 패턴- 확장 및 크로스페이드 요소를 위한 레이아웃 애니메이션 패턴 (
layout,layoutId)
원칙
- 모든 패턴은
motion-foundations에서 가져옵니다. 숫자는 직접 사용하지 않습니다. - 모든 조건부 렌더링은
key와 함께AnimatePresence로 래핑됩니다. - 종료 애니메이션은 항상 진입 애니메이션과 함께 정의됩니다. 사후 고려 사항이 아닙니다.
layout은 작고 격리된 이동에만 사용됩니다. 큰 하위 트리에는 명시적인 트랜스폼을 사용합니다.
규칙
- 조건부 렌더링은 항상 직접 자식에
key를 사용하여AnimatePresence로 래핑합니다. 키가 없으면 종료 애니메이션이 실행되지 않습니다. initial+animate를 정의할 때는 항상exit도 함께 정의합니다. 종료 애니메이션이 없는 애니메이션은 불완전합니다.- 페이지 전환에는
mode="wait"를 사용합니다. 종료가 완료될 때까지 진입이 시작되지 않아야 합니다. - 약 5개 이상의 자식 또는 깊이 중첩된 DOM이 있는 하위 트리에는
layout을 사용하지 마십시오. 대신 명시적인x/y트랜스폼을 사용하세요. - 스테거 간격은
0.05s에서0.10s사이로 유지해야 합니다. 그 이하에서는 기계적이고, 그 이상에서는 느리게 느껴집니다. - 모달에는 다음이 항상 포함되어야 합니다: 포커스 트랩, Escape 키 닫기, 스크롤 잠금,
role="dialog",aria-modal="true". - 스크롤 리빌은
viewport={{ once: true }}를 사용합니다. 스크롤 아웃 시 반복은 유익하기보다 산만합니다. - 모든 토큰 값은
motion-foundations에서 가져옵니다. 인라인 숫자는 사용하지 않습니다.
의사 결정 가이드
올바른 패턴 선택하기
| 상황 | 패턴 |
|---|---|
| 요소가 나타나거나 사라짐 | AnimatePresence |
| 항목 목록이 순서대로 로드됨 | 스테거 variants |
| 라우트 간 이동 | 페이지 전환 래퍼 |
| 요소가 제자리에서 크기 변경 | layout prop |
| 동일한 요소가 페이지 컨텍스트 간 이동 | layoutId |
| 요소가 스크롤되어 뷰에 들어올 때 | whileInView |
| 값을 스크롤 위치에 연결 | useScroll + useTransform |
mode="wait" vs mode="sync" 사용 시기
| 모드 | 사용 시기 |
|---|---|
wait | 페이지 전환, 콘텐츠 교체 (한 번에 하나씩) |
sync | 쌓인 알림, 목록 항목 (겹쳐도 괜찮음) |
popLayout | 재배치 목록에서 제거된 항목 |
핵심 개념
AnimatePresence 계약
세 가지가 항상 참이어야 합니다:
AnimatePresence가 조건부를 감싸고 있습니다.- 직접 자식에
key가 있습니다. - 자식에
exitprop이 있습니다.
이 중 하나라도 누락하면 종료 애니메이션이 자동으로 실패합니다.
layout vs layoutId
layout— 요소 자체의 크기/위치 변경을 제자리에서 애니메이션 처리합니다.layoutId— 두 개의 개별 요소를 연결하여 렌더링 전반에 걸쳐 크로스페이드합니다.
확장 컨테이너 내부의 텍스트에 layout="position"을 사용하여 텍스트 리플로우가 애니메이션되는 것을 방지하세요.
코드 예제
버튼 피드백
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.button
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
transition={springs.snappy}
/>
스테거 리스트
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // 0.05–0.10 규칙 내
delayChildren: 0.1,
},
},
}
const item = {
hidden: { opacity: 0, y: motionTokens.distance.md },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}
<motion.ul variants={container} initial="hidden" animate="visible">
{items.map((i) => (
<motion.li key={i.id} variants={item} />
))}
</motion.ul>
모달
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
// 호출 지점에서 래핑:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
export function Modal({ onClose }: { onClose: () => void }) {
return (
<>
{/* 오버레이 */}
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* 패널 — 접근성 요구 사항: 포커스 트랩, Escape 닫기,
스크롤 잠금, role="dialog", aria-modal="true" */}
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
initial={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
transition={springs.gentle}
/>
</>
)
}
토스트 스택
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<AnimatePresence mode="sync">
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
transition={springs.snappy}
/>
))}
</AnimatePresence>
페이지 전환 (Next.js App Router)
// components/page-transition.tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
import { motionTokens } from "@/lib/motion-tokens"
const variants = {
initial: { opacity: 0, y: motionTokens.distance.sm },
enter: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -motionTokens.distance.sm },
}
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
variants={variants}
initial="initial"
animate="enter"
exit="exit"
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
>
{children}
</motion.div>
</AnimatePresence>
)
}
스크롤 리빌
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.lg }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }} // once: true — 규칙 7
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
스크롤 진행률 표시줄
"use client"
import { motion, useScroll } from "motion/react"
export function ScrollProgress() {
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
style={{ scaleX: scrollYProgress }}
/>
)
}
확장 카드
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
export function ExpandingCard({ title, body }: { title: string; body: string }) {
const [expanded, setExpanded] = useState(false)
return (
<motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
{/* layout="position"은 텍스트 리플로우가 애니메이션되는 것을 방지합니다 */}
<motion.h2 layout="position" className="font-semibold">
{title}
</motion.h2>
<AnimatePresence>
{expanded && (
<motion.p
key="body"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
{body}
</motion.p>
)}
</AnimatePresence>
</motion.div>
)
}
공유 요소 크로스페이드
// 소스 컨텍스트
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
// 대상 컨텍스트 (동일한 layoutId — motion이 전환을 처리합니다)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />
아코디언
<motion.div
initial={false}
animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
style={{ transformOrigin: "top", overflow: "hidden" }}
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
> {children}
</motion.div>
종단 간 예제
마운트 시 진입하고, 조건부 표시를 처리하며, 움직임 줄이기를 존중하는
스테거 리스트 — motion-foundations의 토큰, 스프링, AnimatePresence 및
접근성 훅을 결합:
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"
const containerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
}
function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
const safe = useSafeMotion(motionTokens.distance.sm)
return (
<motion.li
variants={{
hidden: safe.initial,
visible: safe.animate,
}}
exit={safe.exit}
transition={springs.gentle}
className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
>
<span>{label}</span>
<button onClick={onRemove}>Remove</button>
</motion.li>
)
}
export function AnimatedList({ items, onRemove }: {
items: { id: string; label: string }[]
onRemove: (id: string) => void
}) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-2"
>
<AnimatePresence mode="popLayout">
{items.map((item) => (
<ListItem
key={item.id}
label={item.label}
onRemove={() => onRemove(item.id)}
/>
))}
</AnimatePresence>
</motion.ul>
)
}
제약 사항 / 비목표
이 스킬은 다음을 다루지 않습니다:
- 토큰 및 스프링 정의 →
motion-foundations참조 - 드래그 상호작용, 스와이프 제스처, 재정렬 가능한 목록 →
motion-advanced참조 - 텍스트 애니메이션 (단어/문자 리빌, 카운터) →
motion-advanced참조 - SVG 패스 드로잉 또는 모핑 →
motion-advanced참조 - 사용자 정의 애니메이션 훅 →
motion-advanced참조 motion/react를 사용하지 않는 CSS 전용 전환
안티-패턴
| 안티-패턴 | 위반한 규칙 | 수정 방법 |
|---|---|---|
AnimatePresence 자식에 key 누락 | 규칙 1 | 직접 자식에 안정적인 key 추가 |
initial + animate만 있고 exit 없음 | 규칙 2 | 항상 세 가지를 함께 정의 |
mode="wait" 없는 페이지 전환 | 규칙 3 | AnimatePresence에 mode="wait" 추가 |
50개 항목 리스트에 layout 사용 | 규칙 4 | mode="popLayout" 또는 명시적 트랜스폼 사용 |
10개 항목 리스트에 staggerChildren: 0.2 | 규칙 5 | 0.08–0.10으로 제한 |
| 포커스 트랩 없는 모달 | 규칙 6 | focus-trap-react 또는 Radix Dialog 추가 |
viewport={{ once: true }} 없는 whileInView | 규칙 7 | 반복 진입은 산만하고 유익하지 않음 |
인라인 transition={{ duration: 0.3 }} | 규칙 8 | motionTokens.duration.normal 사용 |
관련 스킬
motion-foundations— 이 스킬의 모든 패턴이 가져오는 모든 토큰, 스프링,useSafeMotion훅 및 SSR 가드를 정의합니다. 먼저 설정해야 합니다.motion-advanced— 이 패턴을 드래그, 제스처, SVG, 텍스트, 사용자 정의 훅 및 명령형 시퀀싱으로 확장합니다. 이 스킬의 패턴을 다시 정의하지 않습니다.