Communitygithub.com

KilianBStar/motion-patterns

React / Next.js向けのプロダクション対応アニメーションパターン集 — ボタン、モーダル、トースト、スタガー、ページ遷移、終了アニメーション、スクロール、レイアウト。motion-foundationsのトークンとスプリング上に構築されています。ReactまたはNext.jsで特定のUI要素をアニメーションさせる際に使用します。

motion-patterns とは?

このスキルは、ReactおよびNext.jsアプリケーションにおける一般的なUIアニメーションのための、コピーペースト可能なパターンを提供します。すべてのパターンは「motion-foundations」のトークンとスプリングに依存しており、生の数値や期間の定義は禁止されています。対象となるユースケースは、ボタン、カード、モーダル、トースト通知、スタガーリスト、Next.js App Routerのページ遷移、条件付きコンテンツの出入りアニメーション、スクロールリビール、スクロール連動プログレス、展開カード、アコーディオン、共有要素クロスフェードなど多岐にわたります。スキルは8つの厳格なルールに基づいており、条件付きレンダリングには必ずAnimatePresenceとkeyを使用すること、enterとexitアニメーションは常に対で定義すること、ページ遷移ではmode="wait"を使用することなどが規定されています。また、アンチパターンとその修正方法についても明確に示されています。このスキル単体ではトークン定義やドラッグ操作、SVGアニメーションなどはカバーせず、それらは「motion-foundations」や「motion-advanced」といった関連スキルに委ねられます。

対応~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 のトークンとスプリング上に構築されています。 新しい duration や easing の値をここで定義しないでください — それらをインポートしてください。

アクティベートするタイミング

  • ボタン、カード、モーダル、またはトースト通知をアニメーションさせる場合
  • スタガーを使ったリストの登場エフェクトを構築する場合
  • Next.js App Router でページ遷移を設定する場合
  • 条件付きコンテンツに登場・退場アニメーションを追加する場合
  • スクロールリビール、スクロール連動プログレス、スティッキーストーリーセクションを実装する場合
  • 展開カード、アコーディオン、または共有要素トランジションを構築する場合

アウトプット

このスキルが生成するもの:

  • アクセシブルでSSRセーフな、すべての標準UIコンポーネント向けアニメーション
  • 正しい退場動作を持つ 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
アイテムのリストが順に読み込まれるスタガーバリアント
ルート間を移動するページ遷移ラッパー
要素がその場でサイズ変更されるlayout プロパティ
同じ要素がページコンテキスト間を移動するlayoutId
要素がビューポートにスクロールされたときに表示されるwhileInView
スクロール位置に紐づく値useScroll + useTransform

mode="wait" と mode="sync" の使い分け

モード使用する場面
waitページ遷移、コンテンツの入れ替え(一度に一つ)
sync積み重ねられた通知、リストアイテム(重なりは問題ない)
popLayoutリフローリストから削除されるアイテム

コアコンセプト

AnimatePresence の契約

以下の3つが常に真でなければなりません:

  1. AnimatePresence が条件をラップしている
  2. 直接の子要素に key がある
  3. 子要素に exit プロパティがある

これらのいずれかを欠くと、退場アニメーションは静かに失敗します。

layout と layoutId の違い

  • layout — 要素自身のサイズ/位置の変化をその場でアニメーション
  • layoutId — 2つの別々の要素をリンクし、レンダリング間でクロスフェード

展開コンテナ内のテキストには 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 を追加する
initial + animate に exit がないルール2常に3つすべてを一緒に定義する
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、テキスト、カスタムフック、および命令的なシーケンスで拡張します。このスキルのパターンを再定義することはありません。

関連スキル