T-CREATOR

Next.js 時代のアニメーション設計ベストプラクティス

Next.js 時代のアニメーション設計ベストプラクティス

Next.js がフロントエンド開発の主流となった今、アニメーション設計の重要性は以前にも増して高まっています。しかし、多くの開発者が「アニメーションを実装したいけど、Next.js ではどうすればいいの?」という疑問を抱えているのではないでしょうか。

実際、Next.js の SSR(サーバーサイドレンダリング)や SSG(静的サイト生成)の特性は、従来の SPA とは異なるアニメーション設計を要求します。この記事では、Next.js の特性を活かしながら、パフォーマンスとユーザー体験を両立するアニメーション設計のベストプラクティスを実践的に解説していきます。

あなたが今抱えているアニメーション実装の悩みが、この記事を読むことで解決されることを願っています。

Next.js アニメーションの基礎知識

Next.js のレンダリング方式とアニメーションの関係

Next.js には複数のレンダリング方式があり、それぞれがアニメーション実装に異なる影響を与えます。

SSR(サーバーサイドレンダリング) サーバーで HTML を生成するため、初期表示は高速ですが、クライアントサイドでのハイドレーションが必要です。

typescript// pages/index.tsx
import { useEffect, useState } from 'react';

export default function HomePage() {
  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    setIsClient(true);
  }, []);

  // クライアントサイドでのみアニメーションを実行
  if (!isClient) {
    return <div>Loading...</div>;
  }

  return <AnimatedComponent />;
}

SSG(静的サイト生成) ビルド時に HTML を生成するため、最も高速ですが、動的なアニメーションには制限があります。

typescript// pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
  // ビルド時にデータを取得
  const post = await getPost(params.slug);
  return {
    props: { post },
    revalidate: 60, // ISR(Incremental Static Regeneration)
  };
}

CSR(クライアントサイドレンダリング) 従来の SPA と同様の動作ですが、SEO に不利な場合があります。

SSR/SSG でのアニメーション実装の注意点

SSR/SSG 環境では、サーバーとクライアントで異なる HTML が生成される可能性があります。これが「ハイドレーションエラー」の原因となります。

typescript// ❌ よくあるエラー例
// Warning: Text content did not match. Server: "Hello" Client: "Hello World"

const [count, setCount] = useState(0);

useEffect(() => {
  // サーバーとクライアントで異なる値になる可能性
  setCount(Math.random());
}, []);

解決策:クライアントサイドでのみ実行

typescript// ✅ 正しい実装例
const [mounted, setMounted] = useState(false);
const [count, setCount] = useState(0);

useEffect(() => {
  setMounted(true);
  setCount(Math.random());
}, []);

if (!mounted) {
  return <div>Loading...</div>;
}

クライアントサイドハイドレーションとの連携

ハイドレーションは、サーバーで生成された HTML に JavaScript の機能を追加するプロセスです。アニメーションはこのプロセス完了後に実行する必要があります。

typescript// components/AnimatedCounter.tsx
import { motion, AnimatePresence } from 'framer-motion';
import { useEffect, useState } from 'react';

export default function AnimatedCounter() {
  const [isHydrated, setIsHydrated] = useState(false);
  const [count, setCount] = useState(0);

  useEffect(() => {
    setIsHydrated(true);
  }, []);

  const handleClick = () => {
    setCount((prev) => prev + 1);
  };

  return (
    <div>
      <motion.button
        onClick={handleClick}
        whileHover={{ scale: 1.1 }}
        whileTap={{ scale: 0.9 }}
        disabled={!isHydrated}
      >
        Increment
      </motion.button>

      <AnimatePresence mode='wait'>
        {isHydrated && (
          <motion.div
            key={count}
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -20 }}
            transition={{ duration: 0.3 }}
          >
            Count: {count}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

パフォーマンス最適化のベストプラクティス

アニメーション用ライブラリの選定基準

Next.js プロジェクトでは、バンドルサイズとパフォーマンスが重要な選定基準となります。

主要ライブラリの比較

ライブラリバンドルサイズSSR 対応学習コスト用途
Framer Motion~40KB汎用アニメーション
React Spring~30KB物理ベース
Lottie~50KB複雑なアニメーション
CSS Transitions~0KBシンプルなアニメーション

推奨ライブラリ:Framer Motion

bashyarn add framer-motion
typescript// lib/animation.ts
import { Variants } from 'framer-motion';

// 再利用可能なアニメーション変数
export const fadeInUp: Variants = {
  initial: { opacity: 0, y: 60 },
  animate: { opacity: 1, y: 0 },
  exit: { opacity: 0, y: -60 },
};

export const staggerContainer: Variants = {
  animate: {
    transition: {
      staggerChildren: 0.1,
    },
  },
};

バンドルサイズの最適化手法

動的インポートの活用

typescript// components/HeavyAnimation.tsx
import dynamic from 'next/dynamic';

// 重いアニメーションコンポーネントを動的インポート
const HeavyAnimation = dynamic(
  () => import('./HeavyAnimation'),
  {
    loading: () => <div>Loading animation...</div>,
    ssr: false, // クライアントサイドでのみ実行
  }
);

Tree Shaking の活用

typescript// ❌ 全機能をインポート(バンドルサイズ大)
import * as FramerMotion from 'framer-motion';

// ✅ 必要な機能のみインポート(バンドルサイズ小)
import { motion, AnimatePresence } from 'framer-motion';

レンダリングパフォーマンスの向上テクニック

React.memo の活用

typescript// components/AnimatedCard.tsx
import { motion } from 'framer-motion';
import { memo } from 'react';

interface AnimatedCardProps {
  title: string;
  description: string;
  onClick: () => void;
}

const AnimatedCard = memo(
  ({ title, description, onClick }: AnimatedCardProps) => {
    return (
      <motion.div
        whileHover={{ scale: 1.05 }}
        whileTap={{ scale: 0.95 }}
        onClick={onClick}
        className='p-4 border rounded-lg cursor-pointer'
      >
        <h3 className='text-lg font-bold'>{title}</h3>
        <p className='text-gray-600'>{description}</p>
      </motion.div>
    );
  }
);

AnimatedCard.displayName = 'AnimatedCard';

export default AnimatedCard;

useCallback と useMemo の活用

typescript// hooks/useAnimationConfig.ts
import { useMemo, useCallback } from 'react';

export function useAnimationConfig(duration: number = 0.3) {
  const config = useMemo(
    () => ({
      duration,
      ease: [0.4, 0, 0.2, 1], // easeOutQuart
    }),
    [duration]
  );

  const handleAnimationComplete = useCallback(() => {
    console.log('Animation completed');
  }, []);

  return { config, handleAnimationComplete };
}

実装パターン別ベストプラクティス

ページ遷移アニメーションの実装

Next.js の App Router では、ページ遷移アニメーションの実装方法が大きく変わりました。

App Router での実装

typescript// app/layout.tsx
import { AnimatePresence } from 'framer-motion';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <AnimatePresence mode='wait'>
          {children}
        </AnimatePresence>
      </body>
    </html>
  );
}
typescript// app/page.tsx
'use client';

import { motion } from 'framer-motion';

export default function HomePage() {
  return (
    <motion.div
      initial={{ opacity: 0, x: -100 }}
      animate={{ opacity: 1, x: 0 }}
      exit={{ opacity: 0, x: 100 }}
      transition={{ duration: 0.5 }}
    >
      <h1>Welcome to Next.js</h1>
    </motion.div>
  );
}

Pages Router での実装

typescript// pages/_app.tsx
import { AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/router';

export default function MyApp({ Component, pageProps }) {
  const router = useRouter();

  return (
    <AnimatePresence mode='wait' initial={false}>
      <Component {...pageProps} key={router.route} />
    </AnimatePresence>
  );
}

コンポーネントマウント/アンマウントアニメーション

リストアイテムのアニメーション

typescript// components/AnimatedList.tsx
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';

interface Item {
  id: number;
  title: string;
}

export default function AnimatedList() {
  const [items, setItems] = useState<Item[]>([
    { id: 1, title: 'Item 1' },
    { id: 2, title: 'Item 2' },
    { id: 3, title: 'Item 3' },
  ]);

  const addItem = () => {
    const newItem = {
      id: Date.now(),
      title: `Item ${items.length + 1}`,
    };
    setItems([...items, newItem]);
  };

  const removeItem = (id: number) => {
    setItems(items.filter((item) => item.id !== id));
  };

  return (
    <div>
      <button onClick={addItem}>Add Item</button>

      <AnimatePresence>
        {items.map((item) => (
          <motion.div
            key={item.id}
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: 'auto' }}
            exit={{ opacity: 0, height: 0 }}
            transition={{ duration: 0.3 }}
            className='p-4 border-b'
          >
            <span>{item.title}</span>
            <button onClick={() => removeItem(item.id)}>
              Remove
            </button>
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}

スクロールベースアニメーション

Intersection Observer API の活用

typescript// hooks/useScrollAnimation.ts
import { useEffect, useRef, useState } from 'react';

export function useScrollAnimation() {
  const ref = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
        }
      },
      { threshold: 0.1 }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

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

  return { ref, isVisible };
}
typescript// components/ScrollAnimatedSection.tsx
import { motion } from 'framer-motion';
import { useScrollAnimation } from '../hooks/useScrollAnimation';

export default function ScrollAnimatedSection() {
  const { ref, isVisible } = useScrollAnimation();

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, y: 100 }}
      animate={
        isVisible
          ? { opacity: 1, y: 0 }
          : { opacity: 0, y: 100 }
      }
      transition={{ duration: 0.6 }}
      className='min-h-screen flex items-center justify-center'
    >
      <h2 className='text-4xl font-bold'>
        Scroll Animation
      </h2>
    </motion.div>
  );
}

インタラクティブアニメーション

ドラッグ&ドロップアニメーション

typescript// components/DraggableCard.tsx
import {
  motion,
  useMotionValue,
  useTransform,
} from 'framer-motion';
import { useState } from 'react';

export default function DraggableCard() {
  const [isDragging, setIsDragging] = useState(false);
  const x = useMotionValue(0);
  const y = useMotionValue(0);

  const rotateX = useTransform(y, [-100, 100], [30, -30]);
  const rotateY = useTransform(x, [-100, 100], [-30, 30]);

  const handleDragStart = () => setIsDragging(true);
  const handleDragEnd = () => setIsDragging(false);

  return (
    <motion.div
      style={{
        x,
        y,
        rotateX,
        rotateY,
        z: isDragging ? 1000 : 0,
      }}
      drag
      dragElastic={0.1}
      onDragStart={handleDragStart}
      onDragEnd={handleDragEnd}
      className='w-64 h-40 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg cursor-grab active:cursor-grabbing'
    >
      <div className='p-6 text-white'>
        <h3 className='text-xl font-bold'>
          Draggable Card
        </h3>
        <p>Drag me around!</p>
      </div>
    </motion.div>
  );
}

アクセシビリティとユーザビリティ

アニメーション無効設定への対応

ユーザーの中には、アニメーションが苦手な方や、パフォーマンスを重視する方がいます。これらの設定を尊重することは、包括的デザインの基本です。

prefers-reduced-motion の検出

typescript// hooks/useReducedMotion.ts
import { useEffect, useState } from 'react';

export function useReducedMotion() {
  const [prefersReducedMotion, setPrefersReducedMotion] =
    useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    );
    setPrefersReducedMotion(mediaQuery.matches);

    const handleChange = (event: MediaQueryListEvent) => {
      setPrefersReducedMotion(event.matches);
    };

    mediaQuery.addEventListener('change', handleChange);
    return () =>
      mediaQuery.removeEventListener(
        'change',
        handleChange
      );
  }, []);

  return prefersReducedMotion;
}

アニメーション無効時の代替実装

typescript// components/AccessibleAnimatedButton.tsx
import { motion } from 'framer-motion';
import { useReducedMotion } from '../hooks/useReducedMotion';

interface AccessibleAnimatedButtonProps {
  children: React.ReactNode;
  onClick: () => void;
  disabled?: boolean;
}

export default function AccessibleAnimatedButton({
  children,
  onClick,
  disabled = false,
}: AccessibleAnimatedButtonProps) {
  const prefersReducedMotion = useReducedMotion();

  const buttonVariants = prefersReducedMotion
    ? {
        // アニメーション無効時は即座に状態変化
        hover: {},
        tap: {},
      }
    : {
        // 通常時はアニメーション付き
        hover: { scale: 1.05 },
        tap: { scale: 0.95 },
      };

  return (
    <motion.button
      variants={buttonVariants}
      whileHover='hover'
      whileTap='tap'
      onClick={onClick}
      disabled={disabled}
      className='px-6 py-3 bg-blue-500 text-white rounded-lg disabled:opacity-50'
    >
      {children}
    </motion.button>
  );
}

モーションセーフの実装

CSS での実装

css/* styles/globals.css */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

JavaScript での実装

typescript// utils/animationUtils.ts
export function getSafeAnimationDuration(
  duration: number
): number {
  if (typeof window !== 'undefined') {
    const mediaQuery = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    );
    return mediaQuery.matches ? 0 : duration;
  }
  return duration;
}

export function getSafeAnimationConfig(baseConfig: any) {
  const duration = getSafeAnimationDuration(
    baseConfig.duration || 0.3
  );

  return {
    ...baseConfig,
    duration,
    // アニメーション無効時は即座に完了
    ...(duration === 0 && {
      type: 'tween',
      ease: 'linear',
    }),
  };
}

ユーザー設定の尊重

設定の永続化

typescript// hooks/useAnimationSettings.ts
import { useState, useEffect } from 'react';

interface AnimationSettings {
  enabled: boolean;
  duration: number;
  intensity: 'low' | 'medium' | 'high';
}

export function useAnimationSettings() {
  const [settings, setSettings] =
    useState<AnimationSettings>({
      enabled: true,
      duration: 0.3,
      intensity: 'medium',
    });

  useEffect(() => {
    // ローカルストレージから設定を読み込み
    const saved = localStorage.getItem('animationSettings');
    if (saved) {
      setSettings(JSON.parse(saved));
    }
  }, []);

  const updateSettings = (
    newSettings: Partial<AnimationSettings>
  ) => {
    const updated = { ...settings, ...newSettings };
    setSettings(updated);
    localStorage.setItem(
      'animationSettings',
      JSON.stringify(updated)
    );
  };

  return { settings, updateSettings };
}

実装例とコードサンプル

Framer Motion を使った実装例

モーダルアニメーション

typescript// components/AnimatedModal.tsx
import { motion, AnimatePresence } from 'framer-motion';
import { useReducedMotion } from '../hooks/useReducedMotion';

interface AnimatedModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}

export default function AnimatedModal({
  isOpen,
  onClose,
  children,
}: AnimatedModalProps) {
  const prefersReducedMotion = useReducedMotion();

  const backdropVariants = {
    hidden: { opacity: 0 },
    visible: { opacity: 1 },
  };

  const modalVariants = prefersReducedMotion
    ? {
        hidden: { opacity: 0 },
        visible: { opacity: 1 },
      }
    : {
        hidden: { opacity: 0, scale: 0.8, y: 50 },
        visible: { opacity: 1, scale: 1, y: 0 },
      };

  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'
          variants={backdropVariants}
          initial='hidden'
          animate='visible'
          exit='hidden'
          onClick={onClose}
        >
          <motion.div
            className='bg-white rounded-lg p-6 max-w-md w-full mx-4'
            variants={modalVariants}
            initial='hidden'
            animate='visible'
            exit='hidden'
            onClick={(e) => e.stopPropagation()}
          >
            {children}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

CSS-in-JS でのアニメーション実装

styled-components での実装

typescript// components/StyledAnimatedCard.tsx
import styled, { keyframes } from 'styled-components';
import { useState } from 'react';

const fadeIn = keyframes`
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
`;

const StyledCard = styled.div<{ isVisible: boolean }>`
  padding: 1.5rem;
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  background: white;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  cursor: pointer;
  transition: all 0.3s ease;

  animation: ${({ isVisible }) =>
      isVisible ? fadeIn : 'none'} 0.6s ease-out;

  &:hover {
    transform: translateY(-4px);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  }

  @media (prefers-reduced-motion: reduce) {
    animation: none;
    transition: none;

    &:hover {
      transform: none;
    }
  }
`;

export default function StyledAnimatedCard() {
  const [isVisible, setIsVisible] = useState(false);

  return (
    <StyledCard
      isVisible={isVisible}
      onMouseEnter={() => setIsVisible(true)}
    >
      <h3 className='text-lg font-bold mb-2'>
        Styled Card
      </h3>
      <p className='text-gray-600'>
        This card uses styled-components for animations.
      </p>
    </StyledCard>
  );
}

カスタムフックの活用

アニメーション状態管理フック

typescript// hooks/useAnimationState.ts
import { useState, useCallback } from 'react';

type AnimationState =
  | 'idle'
  | 'loading'
  | 'success'
  | 'error';

export function useAnimationState() {
  const [state, setState] =
    useState<AnimationState>('idle');
  const [isAnimating, setIsAnimating] = useState(false);

  const startAnimation = useCallback(() => {
    setIsAnimating(true);
    setState('loading');
  }, []);

  const completeAnimation = useCallback(
    (newState: 'success' | 'error') => {
      setState(newState);
      setIsAnimating(false);

      // 3秒後にidle状態に戻る
      setTimeout(() => {
        setState('idle');
      }, 3000);
    },
    []
  );

  return {
    state,
    isAnimating,
    startAnimation,
    completeAnimation,
  };
}

使用例

typescript// components/AnimatedButton.tsx
import { motion } from 'framer-motion';
import { useAnimationState } from '../hooks/useAnimationState';

export default function AnimatedButton() {
  const {
    state,
    isAnimating,
    startAnimation,
    completeAnimation,
  } = useAnimationState();

  const handleClick = async () => {
    startAnimation();

    try {
      // 非同期処理をシミュレート
      await new Promise((resolve) =>
        setTimeout(resolve, 2000)
      );
      completeAnimation('success');
    } catch (error) {
      completeAnimation('error');
    }
  };

  const buttonVariants = {
    idle: { scale: 1 },
    loading: { scale: 0.95 },
    success: { scale: 1.1, backgroundColor: '#10b981' },
    error: { scale: 1.1, backgroundColor: '#ef4444' },
  };

  return (
    <motion.button
      variants={buttonVariants}
      animate={state}
      onClick={handleClick}
      disabled={isAnimating}
      className='px-6 py-3 bg-blue-500 text-white rounded-lg disabled:opacity-50'
    >
      {state === 'loading' && 'Loading...'}
      {state === 'success' && 'Success!'}
      {state === 'error' && 'Error!'}
      {state === 'idle' && 'Click me'}
    </motion.button>
  );
}

デバッグとトラブルシューティング

よくある問題と解決策

1. ハイドレーションエラー

typescript// ❌ エラーの原因
// Warning: Expected server HTML to contain a matching <div> in <div>

const [isVisible, setIsVisible] = useState(false);

useEffect(() => {
  setIsVisible(true);
}, []);

return <div>{isVisible && <AnimatedComponent />}</div>;
typescript// ✅ 解決策
const [mounted, setMounted] = useState(false);

useEffect(() => {
  setMounted(true);
}, []);

if (!mounted) {
  return <div>Loading...</div>;
}

return (
  <div>
    <AnimatedComponent />
  </div>
);

2. メモリリーク

typescript// ❌ メモリリークの原因
useEffect(() => {
  const interval = setInterval(() => {
    // アニメーション更新処理
  }, 16);

  // クリーンアップ関数がない
}, []);
typescript// ✅ 解決策
useEffect(() => {
  const interval = setInterval(() => {
    // アニメーション更新処理
  }, 16);

  return () => {
    clearInterval(interval);
  };
}, []);

3. パフォーマンス問題

typescript// ❌ パフォーマンス問題の原因
const [items, setItems] = useState([]);

// 毎回新しいオブジェクトを作成
const animationConfig = {
  duration: 0.3,
  ease: [0.4, 0, 0.2, 1],
};

return (
  <div>
    {items.map((item) => (
      <motion.div
        key={item.id}
        animate={{ opacity: 1 }}
        transition={animationConfig} // 毎回新しいオブジェクト
      >
        {item.name}
      </motion.div>
    ))}
  </div>
);
typescript// ✅ 解決策
const [items, setItems] = useState([]);

// メモ化された設定
const animationConfig = useMemo(
  () => ({
    duration: 0.3,
    ease: [0.4, 0, 0.2, 1],
  }),
  []
);

return (
  <div>
    {items.map((item) => (
      <motion.div
        key={item.id}
        animate={{ opacity: 1 }}
        transition={animationConfig}
      >
        {item.name}
      </motion.div>
    ))}
  </div>
);

パフォーマンス監視ツールの活用

React DevTools Profiler

typescript// components/PerformanceMonitor.tsx
import { Profiler } from 'react';

function onRenderCallback(
  id: string,
  phase: string,
  actualDuration: number,
  baseDuration: number,
  startTime: number,
  commitTime: number
) {
  console.log({
    id,
    phase,
    actualDuration,
    baseDuration,
    startTime,
    commitTime,
  });
}

export default function PerformanceMonitor({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Profiler id='AnimationApp' onRender={onRenderCallback}>
      {children}
    </Profiler>
  );
}

カスタムパフォーマンスフック

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

export function usePerformanceMonitor(
  componentName: string
) {
  const renderCount = useRef(0);
  const lastRenderTime = useRef(performance.now());

  useEffect(() => {
    renderCount.current += 1;
    const currentTime = performance.now();
    const timeSinceLastRender =
      currentTime - lastRenderTime.current;

    console.log(
      `${componentName} render #${
        renderCount.current
      }: ${timeSinceLastRender.toFixed(2)}ms`
    );

    lastRenderTime.current = currentTime;
  });

  return {
    renderCount: renderCount.current,
  };
}

ブラウザ互換性の確保

Feature Detection

typescript// utils/browserSupport.ts
export function checkBrowserSupport() {
  const support = {
    webAnimations: 'animate' in Element.prototype,
    intersectionObserver: 'IntersectionObserver' in window,
    requestAnimationFrame:
      'requestAnimationFrame' in window,
    cssTransforms:
      'transform' in document.documentElement.style,
  };

  return support;
}

export function getFallbackAnimation() {
  const support = checkBrowserSupport();

  if (!support.webAnimations) {
    // CSS Transitionsを使用
    return 'css-transitions';
  }

  if (!support.intersectionObserver) {
    // Scrollイベントを使用
    return 'scroll-events';
  }

  return 'modern';
}

Polyfill の活用

typescript// pages/_app.tsx
import { useEffect } from 'react';

export default function MyApp({ Component, pageProps }) {
  useEffect(() => {
    // Intersection Observer Polyfill
    if (!('IntersectionObserver' in window)) {
      import('intersection-observer').then(() => {
        console.log(
          'Intersection Observer polyfill loaded'
        );
      });
    }
  }, []);

  return <Component {...pageProps} />;
}

まとめ

Next.js 時代のアニメーション設計は、従来の SPA とは異なるアプローチが必要です。SSR/SSG の特性を理解し、ハイドレーションエラーを回避しながら、パフォーマンスとユーザー体験を両立することが重要です。

この記事で紹介したベストプラクティスを実践することで、以下のような効果が期待できます:

  • パフォーマンスの向上: バンドルサイズの最適化とレンダリング効率の改善
  • ユーザビリティの向上: アクセシビリティ対応とユーザー設定の尊重
  • 保守性の向上: 再利用可能なコンポーネントとカスタムフックの活用
  • デバッグ効率の向上: 適切なエラーハンドリングとパフォーマンス監視

アニメーションは、ユーザー体験を劇的に向上させる強力なツールです。しかし、その実装には慎重な設計と配慮が必要です。Next.js の特性を活かしながら、ユーザーにとって心地よいアニメーションを実装していきましょう。

最後に、アニメーション実装において最も重要なのは「ユーザーを第一に考える」ことです。美しいアニメーションも、ユーザーが不快に感じるものであれば意味がありません。常にユーザーの立場に立ち、適切なアニメーション設計を心がけてください。

関連リンク