モーションパターン は何をしますか?
最も一般的な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は小さな独立した移動にのみ使用します。大きなサブツリーには明示的なトランスフォームを使用します。
ルール
- 条件付きレンダリングは常に
AnimatePresenceでラップし、直接の子要素にkeyを指定すること。keyがないと終了アニメーションは決して発火しません。 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 |
| アイテムが順番に読み込まれるリスト | スタガーバリアント |
| ルート間の移動 | ページ遷移ラッパー |
| 要素がその場でサイズ変更する | layout プロパティ |
| 同じ要素がページコンテキストを超えて移動する | layoutId |
| スクロールしてビューに入ったときに要素が登場する | whileInView |
| スクロール位置に紐づく値 | useScroll + useTransform |
mode="wait" と mode="sync" の使い分け
| モード | 使用タイミング |
|---|---|
wait | ページ遷移、コンテンツの入れ替え(一度に一つ) |
sync | 積み上げられた通知、リストアイテム(重なりは問題ない) |
popLayout | リフローリストからアイテムが削除される場合 |
コアコンセプト
AnimatePresence 契約
次の3つは常に真でなければなりません:
AnimatePresenceが条件をラップしている- 直接の子要素に
keyがある - 子要素に
exitプロパティがある
これらのうち1つでも欠けると、終了アニメーションは静かに失敗します。
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}>削除</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" なしのページ遷移 | ルール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、テキスト、カスタムフック、命令型シーケンスで拡張します。このスキルのパターンを再定義することはありません。