トランジションとアニメーション
トムはデータダッシュボードのプロジェクトを終えたばかりだったが、プロダクトマネージャーから、ページの遷移が唐突すぎると指摘された。リストが読み込まれる際にデータが「ちらつき」ながら表示され、ポップアップも瞬時に開いたり閉じたりしていたのだ。トムは、すべての機能を盛り込んだからといって、必ずしもユーザー体験が良好になるとは限らないことに気づいた。彼は、ページに滑らかな遷移アニメーションを追加する必要があった。
1. 学習内容
- CSSのトランジションとアニメーションの基本的な使い方と違い
- Framer MotionのモーションコンポーネントとAnimatePresence
- マウント/アンマウントのアニメーション
- リスト項目の一括アニメーション(レイアウトアニメーション)
- ジェスチャーによるインタラクティブなアニメーション(ドラッグ、ホバー)
2. 概念図
以下の図は、アニメーションのオプションを選択する際に、素早く判断を下すのに役立ちます:
flowchart TD
A[Animation effects are needed] --> B{Effect Complexity}
B -->|Simple Transition| C[CSS Transition]
B -->|Keyframe Animation| D[CSS Animation]
B -->|Complex Interactions| E[Framer Motion]
C --> C1["hover / focus Effect"]
C --> C2["opacity / transform Fade Animation"]
C --> C3["Changes to a Single Attribute"]
D --> D1["@keyframes Defining Multi-Stage"]
D --> D2["Infinite Loop Animation"]
D --> D3["Loading the skeleton screen"]
E --> E1["mount/unmount Animation"]
E --> E2["Drag / Gestures"]
E --> E3["List layout Animation"]
E --> E4["Spring Physical Animation Effects"]
style C fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#fff3e0,stroke:#e65100
3. 実際の事例
トムのダッシュボードには、タスクリストの読み込み、モーダルウィンドウの表示・非表示の切り替え、カードのドラッグと並べ替えという3つの主要な操作が含まれています。彼の目標は:
- タスクリストが読み込まれると、各タスクが左側からスライドして表示されます。
- ポップアップが開くと、背景がフェードインし、ポップアップがズームインします。閉じる際は、このアニメーションが逆再生されます。
- カードをドラッグすると指の動きに合わせて移動し、指を離すと元の位置に戻ります。
彼は最もシンプルなCSSトランジションから始め、徐々にFramer Motionを使って複雑なアニメーションを作成するようになった。
(1) CSSトランジション — 最もシンプルなプログレッシブ・エンハンスメント
CSSのトランジションは、初期状態と最終状態の間で滑らかな遷移を実現するために使用されます。JavaScriptライブラリは一切必要とせず、CSSで宣言するだけで効果を発揮します。
遷移の4つの主要な部分特性
/* transition: property duration timing-function delay */
.example {
transition: opacity 0.3s ease 0s;
/* Complete Syntax:
transition-property: opacity
transition-duration: 0.3s
transition-timing-function: ease
transition-delay: 0s
*/
}
timing-function の一般的な値:
ease: 低速 → 高速 → 低速(デフォルト)ease-in: 低速 → 一定速度ease-out: 一定速度 → 低速ease-in-out: 両端は遅いが、真ん中は速いlinear: 終始一定の速度cubic-bezier(n, n, n, n): カスタムベジエ曲線
(1) ▶ サンプル:CSS トランジションを使用したアコーディオンコンポーネントの実装
import { useState } from 'react'
const accordionData = [
{ title: 'What is React?', content: 'React It is a tool for building user interfaces. JavaScript library.' },
{ title: 'What Is a Component??', content: 'A component is React The smallest independent unit of an application,Reusable and combinable。' },
{ title: 'What is State?', content: 'State It is variable data within the component,Driver UI Update。' },
]
function Accordion() {
const [openIndex, setOpenIndex] = useState(null)
function toggle(index) {
setOpenIndex(openIndex === index ? null : index)
}
return (
<div style={{ maxWidth: 500, fontFamily: 'sans-serif' }}>
{accordionData.map((item, index) => (
<div
key={index}
style={{
border: '1px solid #e8e8e8',
borderRadius: 8,
marginBottom: 8,
overflow: 'hidden',
}}
>
<button
onClick={() => toggle(index)}
style={{
width: '100%',
padding: '12px 16px',
background: openIndex === index ? '#e6f7ff' : '#fafafa',
border: 'none',
cursor: 'pointer',
fontSize: 15,
fontWeight: 600,
textAlign: 'left',
transition: 'background 0.2s ease',
}}
>
{item.title}
</button>
<div
style={{
maxHeight: openIndex === index ? 80 : 0,
padding: openIndex === index ? '12px 16px' : '0 16px',
opacity: openIndex === index ? 1 : 0,
transition: 'all 0.3s ease',
color: '#666',
fontSize: 14,
lineHeight: 1.6,
}}
>
{item.content}
</div>
</div>
))}
</div>
)
}
ポイント: maxHeight + opacity + padding を同時に遷移させることで、コンテンツ領域の展開・折りたたみ効果を実現します。なお、maxHeight の値は、実際のコンテンツの高さよりわずかに大きく設定する必要があります。
(2) CSSアニメーション — キーフレーム駆動型アニメーション
アニメーションの段階が 2 つ以上ある場合(単に「開始→終了」を超える場合)や、ループさせる必要がある場合は、CSS アニメーションの方が適しています。CSS アニメーションでは、@keyframes を使用して、複数の段階からなるアニメーションシーケンスを定義します。
主要な属性
/* animation: name duration timing-function delay iteration-count direction fill-mode */
.pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
(2) ▶ サンプル:スケルトン画面の読み込みアニメーション
import './Skeleton.css'
function Skeleton({ count = 3 }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-avatar" />
<div style={{ flex: 1 }}>
<div className="skeleton-line" style={{ width: '60%' }} />
<div className="skeleton-line" style={{ width: '90%', marginTop: 8 }} />
</div>
</div>
))}
</div>
)
}
export default Skeleton
/* Skeleton.css */
.skeleton-row {
display: flex;
gap: 12px;
align-items: center;
}
.skeleton-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-line {
height: 14px;
border-radius: 4px;
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
スケルトン画面のアニメーションの仕組み: background-position の動きは、読み込み中に画面を横切る光の軌跡という視覚効果を再現しています。CSSアニメーションの infinite プロパティにより、実際のコンテンツの読み込みが完了してスケルトン画面が置き換わるまで、この光の軌跡が繰り返し再生されます。
(3) Framer Motion — 複雑な React アニメーション
Framer Motionは、Reactエコシステムにおいて最も強力なアニメーションライブラリであり、以下の3つの主要な機能を提供しています:
| 機能 | 解決される課題 | 対応するAPI |
|---|---|---|
| 表示・非表示のアニメーション | コンポーネントのマウント・アンマウント時のアニメーション | motion.div + AnimatePresence |
| レイアウトアニメーション | リストの順序が変わったときのスムーズな遷移 | layout プロパティ |
| ジェスチャーアニメーション | ドラッグ、ホバー、タップのジェスチャー | drag / whileHover / whileTap |
インストール
npm install framer-motion
(3) ▶ サンプル:Framer Motion を使った To-Do リストの実装
import { useState } from 'react'
import { motion, AnimatePresence, Reorder } from 'framer-motion'
const initialTodos = [
{ id: 1, text: 'Done React Learning Animation', done: false },
{ id: 2, text: 'Reworking the Dashboard Components', done: false },
{ id: 3, text: 'Code Review PR #142', done: false },
{ id: 4, text: 'Update Project Documentation', done: true },
{ id: 5, text: 'Fix the Responsive Navigation BarBUG', done: false },
]
function TodoList() {
const [todos, setTodos] = useState(initialTodos)
function toggleDone(id) {
setTodos(prev =>
prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
)
}
function removeTodo(id) {
setTodos(prev => prev.filter(t => t.id !== id))
}
return (
<div style={{ maxWidth: 480, fontFamily: 'sans-serif' }}>
<AnimatePresence>
{todos.map(todo => (
<motion.div
key={todo.id}
layout
initial={{ opacity: 0, x: -60 }}
animate={{
opacity: 1,
x: 0,
background: todo.done ? '#f6ffed' : '#fff',
}}
exit={{ opacity: 0, x: 100, height: 0, marginBottom: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
marginBottom: 8,
borderRadius: 8,
border: '1px solid #f0f0f0',
cursor: 'pointer',
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleDone(todo.id)}
style={{ width: 18, height: 18, cursor: 'pointer' }}
/>
<span
style={{
textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#999' : '#333',
fontSize: 15,
}}
>
{todo.text}
</span>
</div>
<button
onClick={() => removeTodo(todo.id)}
style={{
border: 'none',
background: 'none',
color: '#ff4d4f',
cursor: 'pointer',
fontSize: 18,
fontWeight: 'bold',
}}
>
x
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)
}
export default TodoList
このコードは、Framer Motionの4つの主な機能を紹介しています:
initial/animate/exit:コンポーネントのエントリ、表示、およびエグジットの状態を定義します。AnimatePresenceを使用すると、条件付きレンダリングによってエグジットアニメーションもトリガーできます。layoutプロパティ:doneの状態変化によりリスト項目の位置が変更された場合、座標を計算することなく、自動的に新しい位置へ滑らかに遷移します。transition:springの物理スプリングアニメーションを使用します。stiffnessはスプリングの剛性を制御し、dampingは減衰を制御します(値が大きいほど、弾力性が弱くなります)。whileHover/whileTap:インタラクティブなジェスチャーアニメーション。マウスをホバーさせると1.02倍に拡大し、クリックすると0.98倍に縮小します。
(4) ▶ サンプル:Framer Motion によるモーダルポップアップアニメーション
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
function Modal({ isOpen, onClose, children }) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={onClose}
>
<motion.div
key="modal"
initial={{ scale: 0.85, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.85, opacity: 0, y: 20 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
background: '#fff',
padding: 24,
borderRadius: 12,
minWidth: 320,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function App() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button
onClick={() => setIsOpen(true)}
style={{ padding: '8px 20px', fontSize: 15, cursor: 'pointer' }}
>
Open the pop-up window
</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2 style={{ margin: '0 0 12px' }}>Confirm Action</h2>
<p style={{ color: '#666', marginBottom: 20 }}>
Are you sure you want to delete this record??This action cannot be undone.。
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button
onClick={() => setIsOpen(false)}
style={{ padding: '8px 20px', cursor: 'pointer' }}
>
Cancel
</button>
<button
onClick={() => {
alert('Deleted')
setIsOpen(false)
}}
style={{
padding: '8px 20px',
background: '#ff4d4f',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
}}
>
Confirm Deletion
</button>
</div>
</Modal>
</div>
)
}
ポップアップアニメーションを設計する際の要点:
- オーバーレイとモーダルはそれぞれ異なるアニメーションセットを使用していますが、
AnimatePresenceを使用して統一的に管理されています。 - ポップアップには
springアニメーションを使用し、反応を素早くするにはstiffness: 400を、バウンスを抑えるにはdamping: 30を使用してください - 表示時:ポップアップは、0.85倍のスケールで、下から20pxの位置から表示されます
- 終了時:ズームアウト+下方向への移動。開始時のアニメーションを鏡像のように再現する
4. アニメーションのパフォーマンスに関するベストプラクティス
| 属性 | トリガーフェーズ | パフォーマンス | 推奨事項 |
|---|---|---|---|
transform |
複合 | ✅ 最高 | ⭐⭐⭐ |
opacity |
複合 | ✅ 最高 | ⭐⭐⭐ |
filter |
ペイント | ⚠️ 中級 | ⭐⭐ |
box-shadow |
ペイント | ⚠️ 中級 | ⭐ |
width/height |
レイアウト | ❌ 高価 | ❌ |
top/left/margin |
レイアウト | ❌ 高価 | ❌ |
| ルール | 説明 |
|---|---|
デフォルトでは transform および opacity を使用 |
これらのプロパティは、レイアウトやペイントを引き起こすことなくコンポジタースレッドを起動します |
アニメーションを避ける width / height / top / left |
これらのプロパティはレイアウトのリフローを引き起こし、リソースを大量に消費します |
will-change を使用してブラウザにプロンプトを表示する |
will-change: transform, opacity を使用して事前に合成レイヤーを作成する |
| 一度にアニメーションさせる要素の数を多くしすぎない | 20個以上の要素を同時にアニメーションさせると、フレーム落ちが発生する可能性があります |
requestAnimationFrame の使用 |
JavaScript によるアニメーションで setTimeout の使用を避ける |
(1) ▶ サンプル:CSS キーフレームアニメーション — 読み込み中のスケルトン画面
function SkeletonCard() {
return (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 16, overflow: 'hidden' }}>
<div style={{
height: 120, borderRadius: 4, marginBottom: 12,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<div style={{
height: 16, width: '60%', borderRadius: 4, marginBottom: 8,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<div style={{
height: 12, width: '40%', borderRadius: 4,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<style>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
function LoadingGrid() {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, maxWidth: 800, margin: '0 auto' }}>
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
</div>
)
}
❓ よくある質問
AnimatePresence が時々機能しないのはなぜですか?key が正しく設定されていないことです。AnimatePresenceはkeyを介して子要素を追跡します。キーが変更されていない場合、その要素を「削除された」ではなく「更新された」と解釈してしまうため、退出アニメーションがトリガーされません。また、AnimatePresenceの直接の子要素は、motionコンポーネントでなければなりません。transform および opacity アニメーションのみを使用するようにしてください。これらのプロパティは、モバイルの GPU 合成レイヤー上で実行されるためです。Framer Motion はデフォルトで transform を使用しており、これにより良好なパフォーマンスが確保されます。モバイルデバイスで複雑なアニメーションを作成する必要がある場合は、useWillChangeフックを使用するか、will-changeを手動で設定してください。layoutプロパティをlayoutIdおよびAnimatePresenceと組み合わせて使用することで、安定したレイアウト遷移を実現できます。CSSを使用する場合は、位置アニメーションにおいてleftの代わりにtransform: translateX()の使用を検討してください。useTransitionとFramer Motionの違いは何ですか?useTransitionはReactに組み込まれた並行処理機能で、特定の状態更新(検索結果のフィルタリングなど)を「非緊急」としてマークし、緊急の更新(入力フィールドへの入力など)を優先的に処理することで、カクつきを防ぐものです。Framer Motionは、UI要素の移動、拡大縮小、不透明度などの視覚効果を扱うアニメーションライブラリです。この2つは異なる課題に対処しています。useTransitionは「インタラクションの遅延」に対処するのに対し、Framer Motionは「視覚的な遷移」に対処します。📖 まとめ
- CSSトランジションは、状態が変化した際に自動的にトリガーされる、単純な2段階のトランジションに適しています。
- CSSアニメーションを
@keyframesと組み合わせて使用すると、複雑で多段階のループアニメーションを作成できます。 - Framer Motion の
motionおよびAnimatePresenceコンポーネントは、React コンポーネントのあらゆるアニメーションのシナリオを網羅しています - 3つのプロパティ
initial、animate、およびexitは、それぞれ「進入」、「表示」、「退出」の状態を制御します。 layoutpropを使用すると、リストの順序を変更する際に、座標を手動で計算する必要なく、スムーズな遷移を実現できます- アニメーションパフォーマンスの基本原則:常に
transformとopacityを優先する
📝 練習問題
- CSSのトランジションを使用して「ページトップへ」ボタンを実装する:ページが300px以上スクロールされるとフェードインし、クリックするとスムーズにページトップまでスクロールし、ページトップに到達するとフェードアウトして非表示になる。
- CSSアニメーションを使用して、ローディングスピナー(回転する円のアニメーション)を作成します。
@keyframesを使用して回転角度を 0deg から 360deg まで制御し、linearを使用して速度曲線を設定し、一定の速度を維持します。 - Framer Motion を使用して、ドラッグ可能なフローティングアクションボタン(FAB)を作成します。
dragプロパティを設定することで、画面上の任意の場所にドラッグできるようになります。ドラッグが終了したら、springアニメーションを使用して、最も近い画面の端にスナップさせます。



