Motion 고급은(는) 무엇을 하나요?
복잡하고 상호작용적이며 물리 기반의 애니메이션 패턴.
먼저 motion-foundations가 설정되어 있어야 합니다.
motion-patterns로 충분하지 않을 때 사용하세요.
활성화 시점
- 드래그 해제 시트, 스와이프 제스처 또는 재정렬 가능 목록을 구축할 때
- 텍스트를 단어별, 문자별로 애니메이션하거나 라이브 카운터로 사용할 때
- SVG 경로를 그리고, 아이콘을 변형하거나, 원형 진행률을 애니메이션할 때
- 커스텀 애니메이션 훅(
useScrollReveal, 마그네틱 버튼, 커서 따라가기)을 작성할 때 useAnimate로 멀티스텝 애니메이션을 명령형으로 시퀀싱할 때- 스피너, 샤이머 스켈레톤, 펄스 표시기 또는 로딩 버튼 상태를 구축할 때
산출물
이 스킬은 다음을 생성합니다:
- 드래그 상호작용: 드래그 가능 카드, 드래그 해제 시트,
Reorder.Group목록 - 제스처 훅: 스와이프 감지, 롱 프레스, 핀치 개요
- 텍스트 애니메이션 컴포넌트: 단어 등장, 문자 타자기, 숫자 카운터
- SVG 애니메이션: 경로 그리기, 아이콘 변형, 스트로크 진행률 링
- 커스텀 훅:
useScrollReveal,useHoverScale,useNavigationDirection,useInViewOnce useAnimate를 활용한 명령형 시퀀스 (인터럽트 안전async/await)- 로더 컴포넌트: 스피너, 샤이머, 펄스 도트, 진행 바, 버튼 로딩 상태
원칙
- 물리 기반 모션(
useSpring,springs.*)은 직접 조작 시 항상 지속 시간 기반보다 더 자연스럽습니다. useMotionValue+useTransform은 리렌더링을 유발하지 않고 파생 값을 계산합니다.useAnimate시퀀스는 명령형이며 인터럽트 안전합니다. 중간에animate()를 호출하면 이전 애니메이션이 자동으로 취소됩니다.- 모션 값(
useMotionValue,useSpring)은 SSR 안전하며 수화 오류를 일으키지 않습니다.
규칙
- 드래그 상호작용은 마우스뿐만 아니라 터치 기기에서도 테스트되어야 합니다.
drag속성은 둘 다에서 작동하지만 느낌과 임계값이 다릅니다. - 무한 애니메이션은
document.visibilityState === "hidden"일 때 일시 중지되어야 합니다. 백그라운드 탭은 GPU/CPU를 소비해서는 안 됩니다. - 스와이프 임계값은 명시적이어야 합니다. 속도만으로 의도를 추론하지 말고
offset+velocity검사를 결합하세요. useAnimate스코프 참조는 마운트된 DOM 요소에 연결되어야 합니다. 마운트 전에animate()를 호출하면 조용히 실패합니다.- 모션 값은 렌더링 시 재생성되어서는 안 됩니다. 컴포넌트 본문 내부의
useMotionValue(0)는 올바르지만, 렌더링에서new MotionValue(0)는 올바르지 않습니다. - 모든 토큰 값은
motion-foundations에서 가져옵니다. 인라인 숫자는 사용하지 않습니다. - 커스텀 훅은 클린업을 처리해야 합니다. 모든
window.addEventListener에는useEffect반환문에 일치하는removeEventListener가 필요합니다. - SVG 변형에는 동일한 경로 명령 개수가 필요합니다. 명령 구조가 다른 경로는 보간 대신 스냅됩니다.
결정 가이드
올바른 고급 API 선택하기
| 시나리오 | API |
|---|---|
| 릴리스 시 물리 기반 드래그 | drag + dragTransition: springs.release |
| 순서가 있는 드래그 재정렬 목록 | Reorder.Group + Reorder.Item |
| 드래그 오프셋으로 해제 | drag="y" + onDragEnd 오프셋 확인 |
| 왼쪽/오른쪽 스와이프 | drag="x" + onDragEnd 오프셋 확인 |
| 롱 프레스 | useLongPress 훅 |
| 시간 경과에 따라 부드러워진 값 | useSpring |
| 다른 값에서 파생된 값 | useTransform |
| 멀티스텝 시퀀스 | useAnimate + async/await |
| 원샷 명령형 애니메이션 | motion의 animate() |
| 단어별로 등장하는 텍스트 | inline-block 범위에 스태거 |
| SVG 그리기 | pathLength 0 → 1 |
| SVG 변형 | d 속성 트윈 (동일한 명령) |
| 원형 진행률 | strokeDashoffset 트윈 |
useSpring vs 스프링 전환 사용 시기
useSpring | transition: springs.* | |
|---|---|---|
| 사용처 | 커서 따라가기, 포인터 추적 값 | 개별 상태 전환 |
| 업데이트 | 연속적, 모든 프레임 | 상태 변경에 의해 트리거됨 |
| 인터럽트 | 부드러움 — 물리가 속도에서 다시 시작 | 현재 값에서 다시 시작 |
핵심 개념
useMotionValue + useTransform
리렌더링 없는 반응형 계산:
const x = useMotionValue(0)
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0])
// opacity는 x가 변경될 때마다 모든 프레임에서 업데이트됩니다 — setState, 리렌더링 없음
useAnimate
[scope, animate]를 반환합니다. 스코프 참조는 DOM 요소에 연결되어야 합니다.
animate() 호출은 인터럽트 안전합니다. 중간에 호출하면 이전 실행이 취소됩니다.
const [scope, animate] = useAnimate()
async function play() {
await animate(".step-1", { opacity: 1 }, { duration: 0.3 })
await animate(".step-2", { x: 0 }, { duration: 0.4 })
animate(".step-3", { scale: 1 }, { duration: 0.25 }) // 파이어 앤 포겟
}
return <div ref={scope}>...</div>
코드 예제
드래그 가능 카드
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.div
drag
dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
dragElastic={0.1}
whileDrag={{
scale: motionTokens.scale.pop,
boxShadow: "0 16px 40px rgba(0,0,0,0.2)",
}}
dragTransition={springs.release}
/>
드래그 해제 시트
"use client"
import { motion, useMotionValue, useTransform } from "motion/react"
export function BottomSheet({ onClose }: { onClose: () => void }) {
const y = useMotionValue(0)
const opacity = useTransform(y, [0, 200], [1, 0])
return (
<motion.div
drag="y"
dragConstraints={{ top: 0 }}
style={{ y, opacity }}
onDragEnd={(_, info) => {
// 규칙 3: offset + velocity 결합
if (info.offset.y > 120 || info.velocity.y > 500) onClose()
}}
/>
)
}
재정렬 가능 목록
"use client"
import { Reorder } from "motion/react"
export function SortableList() {
const [items, setItems] = useState(initialItems)
return (
<Reorder.Group axis="y" values={items} onReorder={setItems}>
{items.map((item) => (
<Reorder.Item key={item.id} value={item}>
{item.label}
</Reorder.Item>
))}
</Reorder.Group>
)
}
스와이프 감지
"use client"
import { motion } from "motion/react"
const OFFSET_THRESHOLD = 50
const VELOCITY_THRESHOLD = 300
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
onDragEnd={(_, info) => {
const swipedRight = info.offset.x > OFFSET_THRESHOLD || info.velocity.x > VELOCITY_THRESHOLD
const swipedLeft = info.offset.x < -OFFSET_THRESHOLD || info.velocity.x < -VELOCITY_THRESHOLD
if (swipedRight) onSwipeRight()
if (swipedLeft) onSwipeLeft()
}}
/>
롱 프레스 훅
import { useRef } from "react"
export function useLongPress(callback: () => void, ms = 600) {
const timerRef = useRef<ReturnType<typeof setTimeout>>()
return {
onPointerDown: () => { timerRef.current = setTimeout(callback, ms) },
onPointerUp: () => clearTimeout(timerRef.current),
onPointerLeave: () => clearTimeout(timerRef.current),
}
}
단어별 등장
"use client"
import { motion } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function AnimatedText({ text }: { text: string }) {
return (
<motion.p
variants={{ visible: { transition: { staggerChildren: 0.05 } } }}
initial="hidden"
animate="visible"
>
{text.split(" ").map((word, i) => (
<motion.span
key={i}
className="inline-block mr-1"
variants={{
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}}
>
{word}
</motion.span>
))}
</motion.p>
)
}
숫자 카운터
"use client"
import { useRef, useEffect } from "react"
import { animate } from "motion"
import { motionTokens } from "@/lib/motion-tokens"
export function Counter({ to }: { to: number }) {
const nodeRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
const controls = animate(0, to, {
duration: motionTokens.duration.crawl,
ease: motionTokens.easing.smooth,
onUpdate: (v) => {
if (nodeRef.current) nodeRef.current.textContent = Math.round(v).toString()
},
})
return controls.stop // 규칙 7: 클린업
}, [to])
return <span ref={nodeRef} />
}
SVG 경로 그리기
"use client"
import { motion } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
<motion.path
d="M 0 100 Q 50 0 100 100"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
스트로크 진행률 링
"use client"
import { motion } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
const CIRCUMFERENCE = 2 * Math.PI * 40 // r=40
export function ProgressRing({ progress }: { progress: number }) {
return (
<svg width="100" height="100" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="#e5e7eb" strokeWidth="8" />
<motion.circle
cx="50" cy="50" r="40"
fill="none" stroke="#6366f1" strokeWidth="8"
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
animate={{ strokeDashoffset: CIRCUMFERENCE - (progress / 100) * CIRCUMFERENCE }}
transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }}
style={{ rotate: -90, transformOrigin: "center" }}
/>
</svg>
)
}
useScrollReveal 훅
"use client"
import { useRef } from "react"
import { useScroll, useTransform } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function useScrollReveal() {
const ref = useRef(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
const opacity = useTransform(scrollYProgress, [0, 0.3], [0, 1])
const y = useTransform(scrollYProgress, [0, 0.3], [motionTokens.distance.lg, 0])
return { ref, style: { opacity, y } }
}
// 사용법
const { ref, style } = useScrollReveal()
<motion.section ref={ref} style={style} />
커서 따라가기
"use client"
import { useEffect } from "react"
import { motion, useMotionValue, useSpring } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function CursorFollower() {
const x = useMotionValue(-100)
const y = useMotionValue(-100)
const sx = useSpring(x, springs.gentle)
const sy = useSpring(y, springs.gentle)
useEffect(() => {
const move = (e: MouseEvent) => { x.set(e.clientX); y.set(e.clientY) }
window.addEventListener("mousemove", move)
return () => window.removeEventListener("mousemove", move) // 규칙 7
}, [])
return (
<motion.div
className="fixed top-0 left-0 w-6 h-6 rounded-full bg-indigo-500
pointer-events-none -translate-x-1/2 -translate-y-1/2 z-50"
style={{ x: sx, y: sy }}
/>
)
}
샤이머 스켈레톤
"use client"
import { useEffect } from "react"
import { motion, useAnimation } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function ShimmerSkeleton({ className = "" }: { className?: string }) {
const controls = useAnimation()
useEffect(() => {
const play = () =>
controls.start({
x: ["-100%", "100%"],
transition: {
repeat: Infinity,
duration: motionTokens.duration.crawl,
ease: motionTokens.easing.linear,
},
})
const handleVisibility = () => {
if (document.visibilityState === "hidden") controls.stop()
else void play()
}
void play()
document.addEventListener("visibilitychange", handleVisibility)
return () => {
controls.stop()
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [controls])
return (
<div className={`relative overflow-hidden bg-gray-200 rounded ${className}`}>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/60 to-transparent"
initial={{ x: "-100%" }}
animate={controls}
/>
</div>
)
}
버튼 로딩 상태
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
export function LoadingButton({
loading,
label,
onClick,
}: {
loading: boolean
label: string
onClick: () => void
}) {
return (
<motion.button
onClick={onClick}
animate={{ opacity: loading ? 0.7 : 1 }}
whileTap={loading ? {} : { scale: motionTokens.scale.press }}
transition={springs.snappy}
disabled={loading}
>
<AnimatePresence mode="wait">
{loading ? (
<motion.span
key="loading"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
…
</motion.span>
) : (
<motion.span
key="label"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
{label}
</motion.span>
)}
</AnimatePresence>
</motion.button>
)
}
가시성 일시 중지가 있는 무한 애니메이션
"use client"
import { useEffect } from "react"
import { motion, useAnimation } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function PulseDot() {
const controls = useAnimation()
useEffect(() => {
const pulse = () =>
controls.start({
scale: [1, 1.4, 1],
opacity: [1, 0.6, 1],
transition: { repeat: Infinity, duration: motionTokens.duration.crawl },
})
// 규칙 2: 탭이 숨겨지면 일시 중지
const handleVisibility = () => {
if (document.visibilityState === "hidden") controls.stop()
else void pulse()
}
void pulse()
document.addEventListener("visibilitychange", handleVisibility)
return () => {
controls.stop()
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [controls])
return (
<motion.div animate={controls} className="w-3 h-3 rounded-full bg-indigo-500" />
)
}
SVG 아이콘 변형 (모프)
"use client"
import { useState } from "react"
import { motion } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
// 경로는 동일한 수의 명령을 가져야 합니다. d 속성 트윈은 인덱스별로 일치합니다.
const menuPath = "M 3 5 h 18 M 3 12 h 18 M 3 19 h 18" // 햄버거 메뉴 (3개 명령)
const closePath = "M 5 5 l 14 14 M 19 5 l -14 14" // X 닫기 (2개 명령) → 부적합
// 대신 동일한 명령 구조로 구성:
const closePathFixed = "M 4 4 L 20 20 M 20 4 L 4 20" // 2개 명령
export function MorphIcon() {
const [isOpen, setIsOpen] = useState(false)
return (
<motion.svg
width="24" height="24" viewBox="0 0 24 24"
stroke="currentColor" strokeWidth="2" fill="none"
style={{ cursor: "pointer" }}
onClick={() => setIsOpen(!isOpen)}
>
<motion.path
d={isOpen ? closePathFixed : menuPath}
animate={{ d: isOpen ? closePathFixed : menuPath }}
transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }}
/>
</motion.svg>
)
}
결론
Motion Advanced는 motion-foundations 위에 구축된 복잡하고 물리 기반이며 상호작용적인 애니메이션을 위한 최종 레이어입니다. 드래그 앤 드롭, 제스처, 텍스트 효과, SVG 모션, 커스텀 훅, 명령형 시퀀스를 다룹니다. 기본 API인 drag, Reorder, useAnimate, useSpring 및 useTransform 간의 전환 방법을 이해하면 견고하고 성능이 뛰어난 직관적인 애니메이션을 구축할 수 있습니다.