T-CREATOR

ページ遷移も美しく!React Router × アニメーション最強パターン

ページ遷移も美しく!React Router × アニメーション最強パターン

Web 開発者の皆さん、ページ遷移でユーザーの心を掴んだ瞬間はありますか?

「あ、このサイト使いやすい!」そんな感動をユーザーに与えるのが、美しいページ遷移アニメーションの力です。今回は、React Router とアニメーションライブラリを組み合わせた最強パターンを、実際のコード例とともに徹底解説いたします。

単調なページ切り替えから卒業し、ユーザー体験を格段に向上させる技術を身につけましょう。

背景

従来のページ遷移の課題

従来の Web アプリケーションでは、ページ間の遷移は瞬間的な切り替えが主流でした。しかし、この「パッ」と切り替わる体験は、ユーザーにとって少し味気ないものです。

スマートフォンアプリでは当たり前になった滑らかな画面遷移が、Web でも求められるようになってきました。特に、以下のような課題が顕在化しています:

#課題影響
1突然のページ切り替えによる違和感ユーザーの離脱率向上
2どこからどこへ移動したかわからないナビゲーション体験の低下
3読み込み中の空白時間ユーザーの待機ストレス

モダン Web アプリケーションでのアニメーション需要

現代の Web アプリケーションでは、デザイナーとの協業が不可欠です。デザイナーが Figma や Adobe XD で美しいプロトタイプを作成しても、実装時に「技術的に難しい」と断念するケースも多いのではないでしょうか。

React Router とアニメーションライブラリの組み合わせを習得することで、デザイナーの想いを 100%実現できるようになります。

React Router の基本概念とアニメーション連携の重要性

React Router は、単なるルーティングツール以上の価値を持っています。コンポーネント間の状態管理、URL と UI の同期、そしてアニメーション制御の基盤として機能します。

しかし、React Router 単体では、以下の制約があります:

typescript// React Router単体での基本的な遷移
import { Routes, Route, Link } from 'react-router-dom';

function App() {
  return (
    <div>
      <nav>
        <Link to='/'>Home</Link>
        <Link to='/about'>About</Link>
      </nav>
      <Routes>
        <Route path='/' element={<Home />} />
        <Route path='/about' element={<About />} />
      </Routes>
    </div>
  );
}

この実装では、ページが瞬時に切り替わるだけで、ユーザーに「流れ」を感じさせることができません。

課題

React Router の標準機能だけでは実現困難な滑らかな遷移

React Router の標準機能は素晴らしいものですが、アニメーション面では限界があります。実際に遭遇する課題をご紹介しましょう。

よくあるエラー 1:アニメーション途中でのアンマウント

typescript// ❌ 問題のあるコード
function AnimatedRoute() {
  const [isVisible, setIsVisible] = useState(true);

  useEffect(() => {
    // アニメーション開始
    setIsVisible(false);
    // 200ms後にアンマウント... でもまだアニメーション中!
    setTimeout(() => {
      navigate('/new-page');
    }, 200);
  }, []);

  return (
    <div
      className={`fade-out ${isVisible ? 'visible' : ''}`}
    >
      Content
    </div>
  );
}

このコードを実行すると、以下のエラーが発生することがあります:

vbnetWarning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.

パフォーマンスとアニメーションのトレードオフ

美しいアニメーションを実現したい気持ちと、パフォーマンスを保ちたい気持ちの間で悩むことはありませんか?

特に、以下のような状況では顕著に現れます:

#状況パフォーマンス影響
1大量の DOM 要素をアニメーション60FPS 維持困難
2複数のアニメーションを同時実行CPU 使用率上昇
3モバイルデバイスでの表示バッテリー消費増大

複雑なルーティング構造でのアニメーション制御

ネストされたルートや動的ルートを含む複雑なアプリケーションでは、アニメーション制御が困難になります。

typescript// 複雑なルーティング構造の例
<Routes>
  <Route path='/' element={<Layout />}>
    <Route index element={<Home />} />
    <Route path='users' element={<Users />}>
      <Route path=':id' element={<UserDetail />} />
      <Route path=':id/edit' element={<UserEdit />} />
    </Route>
    <Route path='products' element={<Products />}>
      <Route
        path=':category'
        element={<ProductsByCategory />}
      />
    </Route>
  </Route>
</Routes>

このような構造で、親子関係を保ちながら適切なアニメーションを実現するのは、標準機能だけでは至難の業です。

解決策

React Router × アニメーションライブラリの選択肢

問題を解決するため、4 つの主要なアプローチをご紹介します。それぞれに特徴があり、プロジェクトの要件に応じて選択できます。

Framer Motion × React Router

特徴: 直感的な API、豊富なアニメーションプリセット、優れたパフォーマンス

typescript// Framer Motionの基本セットアップ
import { motion, AnimatePresence } from 'framer-motion';
import { useLocation } from 'react-router-dom';

function AnimatedRoutes() {
  const location = useLocation();

  return (
    <AnimatePresence mode='wait'>
      <motion.div
        key={location.pathname}
        initial={{ opacity: 0, x: -200 }}
        animate={{ opacity: 1, x: 0 }}
        exit={{ opacity: 0, x: 200 }}
        transition={{ duration: 0.3 }}
      >
        <Routes location={location}>
          <Route path='/' element={<Home />} />
          <Route path='/about' element={<About />} />
        </Routes>
      </motion.div>
    </AnimatePresence>
  );
}

メリット:

  • 学習コストが低い
  • TypeScript 完全対応
  • 豊富なドキュメント

デメリット:

  • バンドルサイズが大きい(約 20KB)
  • 高度なカスタマイズには制限がある

React Transition Group × React Router

特徴: 軽量、細かい制御が可能、長い運用実績

typescript// React Transition Groupの実装例
import {
  CSSTransition,
  TransitionGroup,
} from 'react-transition-group';

function AnimatedRoutes() {
  const location = useLocation();

  return (
    <TransitionGroup>
      <CSSTransition
        key={location.key}
        timeout={300}
        classNames='fade'
      >
        <Routes location={location}>
          <Route path='/' element={<Home />} />
          <Route path='/about' element={<About />} />
        </Routes>
      </CSSTransition>
    </TransitionGroup>
  );
}

対応する CSS:

css.fade-enter {
  opacity: 0;
  transform: translateX(-20px);
}

.fade-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: opacity 300ms, transform 300ms;
}

.fade-exit {
  opacity: 1;
  transform: translateX(0);
}

.fade-exit-active {
  opacity: 0;
  transform: translateX(20px);
  transition: opacity 300ms, transform 300ms;
}

メリット:

  • 軽量(約 5KB)
  • CSS 完全制御
  • 枯れた技術で安定性が高い

デメリット:

  • CSS 記述が冗長
  • 複雑なアニメーションには不向き

React Spring × React Router

特徴: 物理ベースアニメーション、高いカスタマイズ性、自然な動き

typescript// React Springの実装例
import { useTransition, animated } from '@react-spring/web';

function AnimatedRoutes() {
  const location = useLocation();
  const transitions = useTransition(location, {
    from: {
      opacity: 0,
      transform: 'translate3d(100%, 0, 0)',
    },
    enter: {
      opacity: 1,
      transform: 'translate3d(0%, 0, 0)',
    },
    leave: {
      opacity: 0,
      transform: 'translate3d(-50%, 0, 0)',
    },
    config: { mass: 1, tension: 280, friction: 60 },
  });

  return transitions((style, item) => (
    <animated.div style={style}>
      <Routes location={item}>
        <Route path='/' element={<Home />} />
        <Route path='/about' element={<About />} />
      </Routes>
    </animated.div>
  ));
}

メリット:

  • 物理的に自然な動き
  • 高いパフォーマンス
  • 柔軟なカスタマイズ

デメリット:

  • 学習コストが高い
  • 設定が複雑

CSS Modules × React Router

特徴: 軽量、シンプル、CSS-in-JS 不要

typescript// CSS Modulesの実装例
import styles from './PageTransition.module.css';

function AnimatedRoutes() {
  const location = useLocation();
  const [displayLocation, setDisplayLocation] =
    useState(location);
  const [transitionStage, setTransitionStage] =
    useState('fadeIn');

  useEffect(() => {
    if (location !== displayLocation) {
      setTransitionStage('fadeOut');
    }
  }, [location, displayLocation]);

  return (
    <div
      className={`${styles.pageTransition} ${styles[transitionStage]}`}
      onAnimationEnd={() => {
        if (transitionStage === 'fadeOut') {
          setDisplayLocation(location);
          setTransitionStage('fadeIn');
        }
      }}
    >
      <Routes location={displayLocation}>
        <Route path='/' element={<Home />} />
        <Route path='/about' element={<About />} />
      </Routes>
    </div>
  );
}

メリット:

  • 最軽量
  • CSS 完全制御
  • 依存関係なし

デメリット:

  • 実装が複雑
  • 状態管理が必要

各ライブラリの特徴と使い分け

選択に迷った時は、以下の基準で判断することをお勧めします:

条件推奨ライブラリ理由
初学者・プロトタイピングFramer Motion直感的で学習コストが低い
パフォーマンス重視React Spring物理ベースで自然な動き
軽量性重視CSS Modulesバンドルサイズを最小化
安定性重視React Transition Group枯れた技術で実績豊富

具体例

Framer Motion を使った基本実装

まずは最も人気の高い Framer Motion から始めましょう。実際のプロジェクトセットアップから解説いたします。

Step 1: 依存関係のインストール

bashyarn add framer-motion react-router-dom
yarn add -D @types/react-router-dom

Step 2: 基本的なアニメーション実装

typescript// App.tsx
import React from 'react';
import {
  BrowserRouter as Router,
  Routes,
  Route,
} from 'react-router-dom';
import { AnimatePresence } from 'framer-motion';
import AnimatedRoutes from './components/AnimatedRoutes';
import Navigation from './components/Navigation';

function App() {
  return (
    <Router>
      <div className='app'>
        <Navigation />
        <AnimatedRoutes />
      </div>
    </Router>
  );
}

export default App;

Step 3: アニメーションコンポーネントの作成

typescript// components/AnimatedRoutes.tsx
import React from 'react';
import {
  Routes,
  Route,
  useLocation,
} from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import Home from '../pages/Home';
import About from '../pages/About';
import Contact from '../pages/Contact';

// アニメーションの設定を定数として定義
const pageVariants = {
  initial: {
    opacity: 0,
    x: '-100vw',
  },
  in: {
    opacity: 1,
    x: 0,
  },
  out: {
    opacity: 0,
    x: '100vw',
  },
};

const pageTransition = {
  type: 'tween',
  ease: 'anticipate',
  duration: 0.5,
};

function AnimatedRoutes() {
  const location = useLocation();

  return (
    <AnimatePresence mode='wait'>
      <motion.div
        key={location.pathname}
        initial='initial'
        animate='in'
        exit='out'
        variants={pageVariants}
        transition={pageTransition}
        style={{
          position: 'absolute',
          width: '100%',
          minHeight: '100vh',
        }}
      >
        <Routes location={location}>
          <Route path='/' element={<Home />} />
          <Route path='/about' element={<About />} />
          <Route path='/contact' element={<Contact />} />
        </Routes>
      </motion.div>
    </AnimatePresence>
  );
}

export default AnimatedRoutes;

よくあるエラーと解決方法

実装中に以下のエラーが発生することがあります:

sqlError: AnimatePresence can only have one child at a time.
Did you accidentally render multiple elements at once?

このエラーは、AnimatePresenceの子要素が複数存在する場合に発生します。解決方法:

typescript// ❌ 間違った実装
<AnimatePresence>
  <motion.div key={location.pathname}>
    <Routes location={location}>
      <Route path="/" element={<Home />} />
    </Routes>
  </motion.div>
  <div>Other content</div> {/* これが原因 */}
</AnimatePresence>

// ✅ 正しい実装
<AnimatePresence mode="wait">
  <motion.div key={location.pathname}>
    <Routes location={location}>
      <Route path="/" element={<Home />} />
    </Routes>
  </motion.div>
</AnimatePresence>

React Transition Group によるクラシカルな実装

React Transition Group は、CSS 主体のアニメーションを提供します。細かい制御が可能で、既存の CSS アニメーションを活用できます。

Step 1: セットアップ

bashyarn add react-transition-group
yarn add -D @types/react-transition-group

Step 2: 基本実装

typescript// components/CSSTransitionRoutes.tsx
import React from 'react';
import {
  Routes,
  Route,
  useLocation,
} from 'react-router-dom';
import {
  CSSTransition,
  TransitionGroup,
} from 'react-transition-group';
import './PageTransition.css';

function CSSTransitionRoutes() {
  const location = useLocation();

  return (
    <TransitionGroup>
      <CSSTransition
        key={location.key}
        timeout={500}
        classNames='page'
        unmountOnExit
      >
        <div className='page-container'>
          <Routes location={location}>
            <Route path='/' element={<Home />} />
            <Route path='/about' element={<About />} />
            <Route path='/contact' element={<Contact />} />
          </Routes>
        </div>
      </CSSTransition>
    </TransitionGroup>
  );
}

export default CSSTransitionRoutes;

Step 3: CSS アニメーションの定義

css/* PageTransition.css */
.page-container {
  position: absolute;
  width: 100%;
  min-height: 100vh;
  top: 0;
  left: 0;
}

/* 登場時のアニメーション */
.page-enter {
  opacity: 0;
  transform: translateX(100%);
}

.page-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: opacity 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
    transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

/* 退場時のアニメーション */
.page-exit {
  opacity: 1;
  transform: translateX(0);
}

.page-exit-active {
  opacity: 0;
  transform: translateX(-100%);
  transition: opacity 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
    transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

高度なアニメーションパターン

方向を意識したアニメーションを実装してみましょう:

typescript// hooks/usePageDirection.ts
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';

const routeOrder = ['/', '/about', '/contact'];

export function usePageDirection() {
  const location = useLocation();
  const [direction, setDirection] = useState<
    'forward' | 'backward'
  >('forward');
  const [prevPath, setPrevPath] = useState(
    location.pathname
  );

  useEffect(() => {
    const currentIndex = routeOrder.indexOf(
      location.pathname
    );
    const prevIndex = routeOrder.indexOf(prevPath);

    if (currentIndex > prevIndex) {
      setDirection('forward');
    } else if (currentIndex < prevIndex) {
      setDirection('backward');
    }

    setPrevPath(location.pathname);
  }, [location.pathname, prevPath]);

  return direction;
}

この方向を使って、より自然なアニメーションを実現できます:

typescript// components/DirectionalRoutes.tsx
import React from 'react';
import {
  CSSTransition,
  TransitionGroup,
} from 'react-transition-group';
import { usePageDirection } from '../hooks/usePageDirection';

function DirectionalRoutes() {
  const location = useLocation();
  const direction = usePageDirection();

  return (
    <TransitionGroup>
      <CSSTransition
        key={location.key}
        timeout={400}
        classNames={`page-${direction}`}
        unmountOnExit
      >
        <div className='page-container'>
          <Routes location={location}>
            <Route path='/' element={<Home />} />
            <Route path='/about' element={<About />} />
            <Route path='/contact' element={<Contact />} />
          </Routes>
        </div>
      </CSSTransition>
    </TransitionGroup>
  );
}

React Spring での自然なアニメーション

React Spring は、物理法則に基づいた自然なアニメーションを提供します。バネの動きを模倣したアニメーションは、ユーザーに心地よい体験を提供します。

Step 1: セットアップ

bashyarn add @react-spring/web

Step 2: 基本実装

typescript// components/SpringRoutes.tsx
import React from 'react';
import {
  Routes,
  Route,
  useLocation,
} from 'react-router-dom';
import { useTransition, animated } from '@react-spring/web';

function SpringRoutes() {
  const location = useLocation();

  const transitions = useTransition(location, {
    from: {
      opacity: 0,
      transform: 'translate3d(100%, 0, 0) scale(0.8)',
    },
    enter: {
      opacity: 1,
      transform: 'translate3d(0%, 0, 0) scale(1)',
    },
    leave: {
      opacity: 0,
      transform: 'translate3d(-50%, 0, 0) scale(1.2)',
    },
    config: {
      mass: 1,
      tension: 280,
      friction: 60,
    },
    exitBeforeEnter: true,
  });

  return transitions((style, item) => (
    <animated.div
      style={{
        ...style,
        position: 'absolute',
        width: '100%',
        minHeight: '100vh',
      }}
    >
      <Routes location={item}>
        <Route path='/' element={<Home />} />
        <Route path='/about' element={<About />} />
        <Route path='/contact' element={<Contact />} />
      </Routes>
    </animated.div>
  ));
}

export default SpringRoutes;

物理パラメータの調整

React Spring の魅力は、物理パラメータを調整することで様々な「感触」を作れることです:

typescript// 異なる物理設定の例
const springConfigs = {
  // 軽やかで素早い動き
  gentle: { mass: 1, tension: 120, friction: 14 },

  // 重厚で安定した動き
  wobbly: { mass: 1, tension: 180, friction: 12 },

  // 機械的で正確な動き
  stiff: { mass: 1, tension: 210, friction: 20 },

  // ゆっくりと優雅な動き
  slow: { mass: 1, tension: 280, friction: 120 },
};

パフォーマンス最適化テクニック

美しいアニメーションを実現しながら、パフォーマンスを保つための実践的なテクニックをご紹介します。

1. GPU 加速の活用

css/* GPU加速を有効化 */
.page-container {
  transform: translateZ(0);
  will-change: transform, opacity;
}

/* アニメーション完了後にwill-changeを解除 */
.page-enter-done,
.page-exit-done {
  will-change: auto;
}

2. React.memo と useMemo の活用

typescript// components/OptimizedPage.tsx
import React, { memo } from 'react';

const OptimizedPage = memo(
  ({ children }: { children: React.ReactNode }) => {
    return <div className='page-content'>{children}</div>;
  }
);

// 重い計算を含むコンポーネントの最適化
const HeavyComponent = memo(() => {
  const expensiveValue = useMemo(() => {
    return performExpensiveCalculation();
  }, []);

  return <div>{expensiveValue}</div>;
});

3. 遅延読み込みの実装

typescript// components/LazyRoutes.tsx
import React, { Suspense, lazy } from 'react';
import { motion } from 'framer-motion';

// 遅延読み込み対応のコンポーネント
const Home = lazy(() => import('../pages/Home'));
const About = lazy(() => import('../pages/About'));
const Contact = lazy(() => import('../pages/Contact'));

function LazyRoutes() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <AnimatePresence mode='wait'>
        <motion.div
          key={location.pathname}
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.3 }}
        >
          <Routes location={location}>
            <Route path='/' element={<Home />} />
            <Route path='/about' element={<About />} />
            <Route path='/contact' element={<Contact />} />
          </Routes>
        </motion.div>
      </AnimatePresence>
    </Suspense>
  );
}

4. パフォーマンス監視

typescript// hooks/usePerformanceMonitor.ts
import { useEffect } from 'react';

export function usePerformanceMonitor() {
  useEffect(() => {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      entries.forEach((entry) => {
        if (entry.name === 'measure-route-change') {
          console.log(`Route change took: ${entry.duration}ms`);

          // 閾値を超えた場合の警告
          if (entry.duration > 100) {
            console.warn('Route change is taking too long!');
          }
        }
      });
    });

    observer.observe({ entryTypes: ['measure'] });

    return () => observer.disconnect();
  }, []);
}

// 使用例
function App() {
  usePerformanceMonitor();

  const handleRouteChange = () => {
    performance.mark('route-change-start');
    // ルート変更処理
    performance.mark('route-change-end');
    performance.measure('measure-route-change', 'route-change-start', 'route-change-end');
  };

  return (
    // アプリケーションコンテンツ
  );
}

実際のエラーケースと対処法

パフォーマンス最適化時によく遭遇するエラーをご紹介します:

sqlWarning: Each child in a list should have a unique "key" prop.
Check the top-level render call using <TransitionGroup>.

このエラーの解決方法:

typescript// ❌ 問題のあるコード
<TransitionGroup>
  {routes.map((route) => (
    <CSSTransition timeout={300} classNames="fade">
      <Route path={route.path} element={route.element} />
    </CSSTransition>
  ))}
</TransitionGroup>

// ✅ 正しい実装
<TransitionGroup>
  {routes.map((route) => (
    <CSSTransition
      key={route.path}  // key を追加
      timeout={300}
      classNames="fade"
    >
      <Route path={route.path} element={route.element} />
    </CSSTransition>
  ))}
</TransitionGroup>

まとめ

React Router とアニメーションライブラリの組み合わせは、ユーザー体験を劇的に向上させる強力な手法です。

最適なライブラリ選択の指針

プロジェクトの特性に応じて、以下の指針で選択することをお勧めします:

開発チームの経験レベル

  • 初心者チーム: Framer Motion
  • 中級者チーム: React Transition Group
  • 上級者チーム: React Spring

プロジェクトの要件

  • プロトタイピング: Framer Motion
  • プロダクション: React Transition Group
  • 高度なアニメーション: React Spring
  • 軽量性重視: CSS Modules

ベストプラクティス

  1. パフォーマンスを最優先に

    • GPU 加速を活用する
    • 不要なリレンダリングを避ける
    • 適切な遅延読み込みを実装する
  2. ユーザビリティを考慮

    • アニメーション時間は 0.3 秒以内に
    • 一貫性のある動きを保つ
    • アクセシビリティに配慮する
  3. 保守性を重視

    • アニメーション設定を定数化する
    • 再利用可能なコンポーネントを作成する
    • 適切なエラーハンドリングを実装する

最後に

美しいページ遷移アニメーションは、技術的な挑戦であると同時に、ユーザーの心を動かすアートでもあります。

今回ご紹介したテクニックを活用して、ユーザーが「使っていて気持ちいい」と感じる Web アプリケーションを作成してください。小さな工夫の積み重ねが、大きな差を生み出します。

あなたのプロジェクトが、ユーザーにとって忘れられない体験を提供できることを心から願っています。

関連リンク