Communitygithub.com

Motion Patterns

React/Next.js용 프로덕션 레디 애니메이션 패턴 — 버튼, 모달, 토스트, 스테거, 페이지 전환, 종료 애니메이션, 스크롤 및 레이아웃. motion-foundations 토큰과 스프링을 기반으로 구축되었습니다. React 또는 Next.js에서 특정 UI 요소(버튼, 모달, 토스트, 스테거, 페이지 전환 또는 스크롤)를 애니메이션 처리할 때 사용하세요.

Motion Patterns란 무엇인가요?

Motion Patterns 스킬은 가장 일반적인 UI 애니메이션 요구 사항을 위한 복사-붙여넣기 가능한 패턴 모음입니다. 모든 패턴은 `motion-foundations`의 토큰과 스프링을 가져와 사용하므로, 새로운 지속 시간이나 이징 값을 직접 정의하지 않습니다. 이 스킬은 React 및 Next.js(특히 App Router)에서 버튼, 카드, 모달, 토스트 알림, 스테거 목록, 페이지 전환, 스크롤 반응형 요소, 확장 카드, 아코디언, 공유 요소 전환 등 다양한 UI 구성 요소에 접근 가능하고 SSR에 안전한 애니메이션을 제공합니다. `AnimatePresence`를 사용한 조건부 렌더링의 올바른 종료 동작, Next.js App Router를 위한 페이지 전환 래퍼 컴포넌트, `useScroll` + `useTransform`을 사용한 스크롤 연동 패턴, 그리고 `layout` 및 `layoutId`를 사용한 레이아웃 애니메이션을 생성합니다. 이 스킬은 animatePresence 계약(래핑, key, exit), layout vs layoutId 선택 기준, 그리고 `mode="wait"`/`mode="sync"`/`mode="popLayout"` 사용 상황에 대한 명확한 지침을 제공합니다. 또한, 안티 패턴 목록과 수정 방법을 포함하여 개발자가 흔히 저지르는 실수를 방지하도록 도와줍니다.

지원 대상~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/KilianBStar/mes-skills-/tree/HEAD/.claude/skills/motion-patterns

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

모션 패턴 (Motion Patterns)

가장 일반적인 UI 애니메이션 요구 사항을 위한 복사-붙여넣기 패턴입니다. 여기의 모든 패턴은 motion-foundations 토큰과 스프링을 기반으로 구축되었습니다. 새로운 지속 시간이나 이징 값을 여기서 정의하지 말고 가져와서 사용하세요.

활성화 시점

  • 버튼, 카드, 모달 또는 토스트 알림 애니메이션 처리
  • 스테거를 사용한 목록 항목 순차적 진입
  • Next.js App Router에서 페이지 전환 설정
  • 조건부 콘텐츠에 진입 또는 종료 애니메이션 추가
  • 스크롤 반응형 진입, 스크롤 연동 진행률 표시줄, 스티커 스토리 섹션 구현
  • 확장 카드, 아코디언 또는 공유 요소 전환 구축

결과물

이 스킬은 다음을 생성합니다:

  • 모든 표준 UI 컴포넌트에 대한 접근 가능하고 SSR에 안전한 애니메이션
  • 올바른 종료 동작을 가진 AnimatePresence로 래핑된 조건부 렌더링
  • Next.js App Router용 페이지 전환 래퍼 컴포넌트
  • useScroll + useTransform을 사용한 스크롤 반응형 및 스크롤 연동 패턴
  • 확장 및 크로스페이드 요소를 위한 레이아웃 애니메이션 패턴 (layout, layoutId)

원칙

  • 모든 패턴은 motion-foundations에서 가져옵니다. 원시 숫자는 사용하지 않습니다.
  • 모든 조건부 렌더링은 AnimatePresence로 래핑되고 key가 있어야 합니다.
  • 종료 애니메이션은 항상 진입 애니메이션과 함께 정의되어야 하며, 사후 고려 사항이 되어서는 안 됩니다.
  • layout은 작고 격리된 이동에만 사용합니다. 큰 하위 트리에는 명시적인 트랜스폼을 사용합니다.

규칙

  1. 조건부 렌더링은 항상 AnimatePresence로 래핑하고, 직접 자식에 key를 지정하세요. key가 없으면 종료 애니메이션이 실행되지 않습니다.
  2. initial + animate를 정의할 때는 항상 exit도 함께 정의하세요. 종료가 없는 애니메이션은 불완전합니다.
  3. 페이지 전환에는 mode="wait"를 사용하세요. 종료가 완료될 때까지 진입이 시작되어서는 안 됩니다.
  4. 자식이 약 5개를 초과하거나 DOM 중첩이 깊은 하위 트리에는 절대 layout을 사용하지 마세요. 대신 명시적인 x/y 트랜스폼을 사용하세요.
  5. 스테거 간격은 반드시 0.05s에서 0.10s 사이여야 합니다. 그 이하는 기계적으로 느껴지고, 그 이상은 느리게 느껴집니다.
  6. 모달은 반드시 다음을 포함해야 합니다: 포커스 트랩, Escape 키 닫기, 스크롤 잠금, role="dialog", aria-modal="true".
  7. 스크롤 반응형 진입은 viewport={{ once: true }}를 사용하세요. 스크롤 아웃 시 반복되는 것은 유익하기보다 방해가 됩니다.
  8. 모든 토큰 값은 motion-foundations에서 가져옵니다. 인라인 숫자는 사용하지 않습니다.

결정 가이드

올바른 패턴 선택하기

상황패턴
요소가 나타나거나 사라짐AnimatePresence
항목 목록이 순차적으로 로드됨스테거 variants
경로 간 이동페이지 전환 래퍼
요소가 제자리에서 크기 변경layout prop
동일한 요소가 페이지 컨텍스트 간 이동layoutId
스크롤되어 뷰에 들어올 때 요소 진입whileInView
스크롤 위치에 고정된 값useScroll + useTransform

mode="wait" vs mode="sync" 사용 시기

모드사용 시기
wait페이지 전환, 콘텐츠 교체 (한 번에 하나씩)
sync쌓인 알림, 목록 항목 (겹쳐도 괜찮음)
popLayout재배치 목록에서 제거되는 항목

핵심 개념

AnimatePresence 계약

세 가지가 항상 참이어야 합니다:

  1. AnimatePresence가 조건부를 래핑합니다.
  2. 직접 자식에 key가 있습니다.
  3. 자식에 exit prop이 있습니다.

이 중 하나라도 놓치면 종료 애니메이션이 조용히 실패합니다.

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>

종단간 예제

마운트 시 진입하고, 조건부 존재를 처리하며, 축소된 모션을 존중하는 스테거 목록입니다. 토큰, 스프링, AnimatePresence 및 motion-foundations의 접근성 훅을 결합합니다:

"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 추가
exit 없는 initial + animate규칙 2항상 세 가지를 함께 정의
mode="wait" 없는 페이지 전환규칙 3AnimatePresence에 mode="wait" 추가
50개 항목 목록에 layout 사용규칙 4mode="popLayout" 또는 명시적 트랜스폼 사용
10개 항목 목록에 staggerChildren: 0.2규칙 50.08–0.10으로 제한
포커스 트랩 없는 모달규칙 6focus-trap-react 또는 Radix Dialog 추가
viewport={{ once: true }} 없는 whileInView규칙 7반복되는 진입은 방해, 정보 전달 아님
인라인 transition={{ duration: 0.3 }}규칙 8motionTokens.duration.normal 사용

관련 스킬

  • motion-foundations — 모든 패턴이 가져오는 모든 토큰, 스프링, useSafeMotion 훅 및 SSR 가드를 정의합니다. 먼저 설정해야 합니다.
  • motion-advanced — 드래그, 제스처, SVG, 텍스트, 사용자 정의 훅 및 명령형 시퀀싱으로 이 패턴을 확장합니다. 이 스킬의 패턴을 재정의하지 않습니다.

관련 스킬