T-CREATOR

スマホでも滑らか!React × アニメーションのレスポンシブ対応術

スマホでも滑らか!React × アニメーションのレスポンシブ対応術

モバイルデバイスの普及により、Web サイトやアプリケーションは様々な画面サイズで利用されるようになりました。特に React アプリケーションでは、美しいアニメーションを実装する際に、デバイスの性能や画面サイズに応じた最適化が不可欠です。

スマートフォンでは限られた CPU 性能やメモリ、バッテリー消費を考慮しながら、ユーザーに滑らかな体験を提供する必要があります。この記事では、React でレスポンシブなアニメーションを実装するための実践的な手法を詳しく解説していきます。

レスポンシブアニメーションの重要性

現代の Web 開発において、レスポンシブデザインは当たり前の要件となっています。しかし、アニメーションに関しては、単純に画面サイズに合わせるだけでなく、デバイスの性能やユーザーの操作環境まで考慮する必要があります。

デバイス性能の違い

スマートフォンとデスクトップでは、CPU 性能や GPU 性能に大きな差があります。高スペックなデスクトップでは問題なく動作するアニメーションも、低スペックなスマートフォンではカクつきや遅延が発生する可能性があります。

ユーザー体験の向上

適切に最適化されたレスポンシブアニメーションは、ユーザーの操作感を向上させ、アプリケーションの品質を高めます。逆に、最適化されていないアニメーションは、ユーザーにストレスを与え、離脱率の増加につながる可能性があります。

SEO とパフォーマンス

Google などの検索エンジンは、モバイルファーストインデックスを採用しており、モバイルでのパフォーマンスが検索順位に影響します。レスポンシブアニメーションの最適化は、SEO 対策としても重要です。

デバイス別アニメーション最適化の基本

レスポンシブアニメーションを実装する際は、デバイスの特性を理解し、適切な最適化戦略を立てることが重要です。

スマートフォンの制約

スマートフォンでは以下の制約があります:

  • CPU 性能: デスクトップと比較して限定的
  • メモリ容量: 限られた RAM での動作
  • バッテリー消費: 長時間使用を考慮した設計が必要
  • 画面サイズ: 小さな画面での視認性
  • タッチ操作: マウスとは異なる操作感

最適化の基本方針

  1. 軽量化: 不要なアニメーションの削除
  2. 簡素化: 複雑なエフェクトの簡略化
  3. 条件分岐: デバイス性能に応じた調整
  4. プログレッシブエンハンスメント: 基本機能を保ちながら段階的に向上

React でのレスポンシブアニメーション実装手法

React では、様々な手法でレスポンシブアニメーションを実装できます。以下に主要な実装手法を紹介します。

メディアクエリを使った条件分岐

CSS のメディアクエリを活用して、画面サイズに応じたアニメーションの調整を行います。

css/* デスクトップ向けのアニメーション */
.desktop-animation {
  animation: slideIn 0.5s ease-out;
}

/* タブレット向けのアニメーション */
@media (max-width: 768px) {
  .tablet-animation {
    animation: slideIn 0.3s ease-out;
  }
}

/* スマートフォン向けのアニメーション */
@media (max-width: 480px) {
  .mobile-animation {
    animation: fadeIn 0.2s ease-out;
  }
}

React コンポーネントでの実装例:

jsximport React from 'react';
import styled from 'styled-components';

const AnimatedBox = styled.div`
  width: 200px;
  height: 200px;
  background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  border-radius: 8px;

  /* デスクトップ向け */
  animation: slideIn 0.5s ease-out;

  /* タブレット向け */
  @media (max-width: 768px) {
    animation: slideIn 0.3s ease-out;
  }

  /* スマートフォン向け */
  @media (max-width: 480px) {
    animation: fadeIn 0.2s ease-out;
  }

  @keyframes slideIn {
    from {
      transform: translateX(-100%);
      opacity: 0;
    }
    to {
      transform: translateX(0);
      opacity: 1;
    }
  }

  @keyframes fadeIn {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
`;

const ResponsiveAnimation = () => {
  return (
    <div>
      <h2>レスポンシブアニメーション</h2>
      <AnimatedBox />
    </div>
  );
};

export default ResponsiveAnimation;

デバイス検出による動的調整

JavaScript でデバイス情報を検出し、動的にアニメーションの設定を調整する方法です。

jsximport React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';

const useDeviceDetection = () => {
  const [deviceType, setDeviceType] = useState('desktop');

  useEffect(() => {
    const detectDevice = () => {
      const userAgent = navigator.userAgent;
      const screenWidth = window.innerWidth;

      if (
        /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
          userAgent
        )
      ) {
        if (screenWidth <= 480) {
          setDeviceType('mobile');
        } else if (screenWidth <= 768) {
          setDeviceType('tablet');
        } else {
          setDeviceType('mobile-large');
        }
      } else {
        setDeviceType('desktop');
      }
    };

    detectDevice();
    window.addEventListener('resize', detectDevice);

    return () =>
      window.removeEventListener('resize', detectDevice);
  }, []);

  return deviceType;
};

const ResponsiveMotionBox = () => {
  const deviceType = useDeviceDetection();

  // デバイスに応じたアニメーション設定
  const getAnimationConfig = () => {
    switch (deviceType) {
      case 'mobile':
        return {
          initial: { opacity: 0, scale: 0.8 },
          animate: { opacity: 1, scale: 1 },
          transition: { duration: 0.2, ease: 'easeOut' },
        };
      case 'tablet':
        return {
          initial: { opacity: 0, y: 20 },
          animate: { opacity: 1, y: 0 },
          transition: { duration: 0.3, ease: 'easeOut' },
        };
      default:
        return {
          initial: { opacity: 0, x: -50 },
          animate: { opacity: 1, x: 0 },
          transition: { duration: 0.5, ease: 'easeOut' },
        };
    }
  };

  return (
    <motion.div
      style={{
        width: 200,
        height: 200,
        backgroundColor: '#4ecdc4',
        borderRadius: 8,
      }}
      {...getAnimationConfig()}
    >
      <p>デバイス: {deviceType}</p>
    </motion.div>
  );
};

export default ResponsiveMotionBox;

パフォーマンスを考慮した軽量化テクニック

モバイルデバイスでのパフォーマンスを向上させるためのテクニックを紹介します。

jsximport React, { useState, useEffect } from 'react';
import { motion, useReducedMotion } from 'framer-motion';

const PerformanceOptimizedAnimation = () => {
  const prefersReducedMotion = useReducedMotion();
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    // Intersection Observerを使用して要素が画面に入った時のみアニメーション実行
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
        }
      },
      { threshold: 0.1 }
    );

    const element = document.getElementById(
      'animated-element'
    );
    if (element) {
      observer.observe(element);
    }

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

  // アニメーション軽減設定が有効な場合はアニメーションを無効化
  if (prefersReducedMotion) {
    return (
      <div
        id='animated-element'
        style={{ padding: 20, backgroundColor: '#f0f0f0' }}
      >
        <h3>アニメーション軽減モード</h3>
        <p>
          アクセシビリティのため、アニメーションを無効化しています。
        </p>
      </div>
    );
  }

  return (
    <motion.div
      id='animated-element'
      initial={{ opacity: 0, y: 50 }}
      animate={
        isVisible
          ? { opacity: 1, y: 0 }
          : { opacity: 0, y: 50 }
      }
      transition={{
        duration: 0.3,
        ease: 'easeOut',
        // モバイルではアニメーション時間を短縮
        ...(window.innerWidth <= 768 && { duration: 0.2 }),
      }}
      style={{
        padding: 20,
        backgroundColor: '#4ecdc4',
        borderRadius: 8,
        color: 'white',
      }}
    >
      <h3>パフォーマンス最適化済みアニメーション</h3>
      <p>
        画面に入った時のみアニメーションが実行されます。
      </p>
    </motion.div>
  );
};

export default PerformanceOptimizedAnimation;

実践的なレスポンシブアニメーション事例

実際のプロジェクトで使用できるレスポンシブアニメーションの事例を紹介します。

スムーズなスクロールアニメーション

スクロールに応じて要素が表示されるアニメーションの実装例です。

jsximport React, { useEffect, useRef } from 'react';
import {
  motion,
  useInView,
  useScroll,
  useTransform,
} from 'framer-motion';

const ScrollAnimation = () => {
  const ref = useRef(null);
  const isInView = useInView(ref, {
    once: true,
    margin: '-100px',
  });
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start end', 'end start'],
  });

  // スクロール進捗に応じたアニメーション
  const y = useTransform(
    scrollYProgress,
    [0, 1],
    [100, -100]
  );
  const opacity = useTransform(
    scrollYProgress,
    [0, 0.5, 1],
    [0, 1, 0]
  );

  return (
    <div style={{ height: '200vh', padding: '20px' }}>
      <motion.div
        ref={ref}
        style={{
          width: '100%',
          height: '300px',
          backgroundColor: '#ff6b6b',
          borderRadius: '12px',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
          fontSize: '24px',
          y,
          opacity,
        }}
        initial={{ scale: 0.8 }}
        animate={isInView ? { scale: 1 } : { scale: 0.8 }}
        transition={{
          duration: 0.5,
          ease: 'easeOut',
          // モバイルではアニメーションを軽量化
          ...(window.innerWidth <= 768 && {
            duration: 0.3,
          }),
        }}
      >
        スクロールアニメーション
      </motion.div>
    </div>
  );
};

export default ScrollAnimation;

タッチ操作に最適化したインタラクション

モバイルデバイスのタッチ操作に最適化されたインタラクションの実装例です。

jsximport React, { useState } from 'react';
import { motion, PanInfo } from 'framer-motion';

const TouchOptimizedCard = () => {
  const [isExpanded, setIsExpanded] = useState(false);

  const handleTap = () => {
    setIsExpanded(!isExpanded);
  };

  const handleDragEnd = (event: any, info: PanInfo) => {
    // スワイプ操作の検出
    if (info.offset.x > 100) {
      console.log('右スワイプ');
    } else if (info.offset.x < -100) {
      console.log('左スワイプ');
    }
  };

  return (
    <motion.div
      style={{
        width: isExpanded ? '90vw' : '300px',
        height: isExpanded ? '400px' : '200px',
        backgroundColor: '#4ecdc4',
        borderRadius: '16px',
        cursor: 'pointer',
        touchAction: 'pan-y', // 縦スクロールを許可
        userSelect: 'none', // テキスト選択を無効化
      }}
      whileTap={{ scale: 0.95 }}
      whileHover={{ scale: 1.05 }}
      drag='x'
      dragConstraints={{ left: -100, right: 100 }}
      dragElastic={0.2}
      onDragEnd={handleDragEnd}
      onTap={handleTap}
      transition={{
        duration: 0.2,
        ease: 'easeOut',
      }}
    >
      <div style={{ padding: '20px', color: 'white' }}>
        <h3>タッチ最適化カード</h3>
        <p>タップで拡大・縮小、ドラッグでスワイプ操作</p>
        {isExpanded && (
          <motion.div
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ delay: 0.1 }}
          >
            <p>拡張されたコンテンツ</p>
          </motion.div>
        )}
      </div>
    </motion.div>
  );
};

export default TouchOptimizedCard;

画面サイズに応じたアニメーション調整

画面サイズに応じてアニメーションの動作を動的に調整する実装例です。

jsximport React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';

const ResponsiveGrid = () => {
  const [screenSize, setScreenSize] = useState('desktop');
  const [items, setItems] = useState([
    { id: 1, title: 'アイテム1' },
    { id: 2, title: 'アイテム2' },
    { id: 3, title: 'アイテム3' },
    { id: 4, title: 'アイテム4' },
    { id: 5, title: 'アイテム5' },
    { id: 6, title: 'アイテム6' },
  ]);

  useEffect(() => {
    const updateScreenSize = () => {
      const width = window.innerWidth;
      if (width <= 480) {
        setScreenSize('mobile');
      } else if (width <= 768) {
        setScreenSize('tablet');
      } else {
        setScreenSize('desktop');
      }
    };

    updateScreenSize();
    window.addEventListener('resize', updateScreenSize);

    return () =>
      window.removeEventListener(
        'resize',
        updateScreenSize
      );
  }, []);

  const getGridConfig = () => {
    switch (screenSize) {
      case 'mobile':
        return {
          columns: 1,
          gap: 10,
          animationDuration: 0.2,
        };
      case 'tablet':
        return {
          columns: 2,
          gap: 15,
          animationDuration: 0.3,
        };
      default:
        return {
          columns: 3,
          gap: 20,
          animationDuration: 0.4,
        };
    }
  };

  const config = getGridConfig();

  return (
    <div>
      <h2>レスポンシブグリッド ({screenSize})</h2>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: `repeat(${config.columns}, 1fr)`,
          gap: `${config.gap}px`,
          padding: '20px',
        }}
      >
        <AnimatePresence>
          {items.map((item, index) => (
            <motion.div
              key={item.id}
              initial={{ opacity: 0, y: 50 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -50 }}
              transition={{
                duration: config.animationDuration,
                delay: index * 0.1,
                ease: 'easeOut',
              }}
              style={{
                backgroundColor: '#ff6b6b',
                padding: '20px',
                borderRadius: '8px',
                color: 'white',
                textAlign: 'center',
              }}
            >
              <h3>{item.title}</h3>
              <p>画面サイズ: {screenSize}</p>
            </motion.div>
          ))}
        </AnimatePresence>
      </div>
    </div>
  );
};

export default ResponsiveGrid;

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

レスポンシブアニメーションを実装する際のパフォーマンス最適化のポイントを紹介します。

アニメーションの軽量化

jsximport React, { useState, useEffect } from 'react';
import {
  motion,
  useMotionValue,
  useTransform,
} from 'framer-motion';

const OptimizedAnimation = () => {
  const [isLowPerformance, setIsLowPerformance] =
    useState(false);

  useEffect(() => {
    // デバイス性能の検出
    const checkPerformance = () => {
      const start = performance.now();
      let result = 0;
      for (let i = 0; i < 1000000; i++) {
        result += Math.random();
      }
      const end = performance.now();

      // 処理時間が長い場合は低性能デバイスと判定
      if (end - start > 50) {
        setIsLowPerformance(true);
      }
    };

    checkPerformance();
  }, []);

  const x = useMotionValue(0);
  const opacity = useTransform(x, [0, 100], [1, 0]);

  return (
    <motion.div
      style={{
        width: '200px',
        height: '200px',
        backgroundColor: '#4ecdc4',
        borderRadius: '8px',
        x,
        opacity,
      }}
      whileHover={{
        scale: isLowPerformance ? 1.02 : 1.1,
        transition: {
          duration: isLowPerformance ? 0.1 : 0.2,
        },
      }}
      drag='x'
      dragConstraints={{ left: 0, right: 100 }}
    >
      <div style={{ padding: '20px', color: 'white' }}>
        <h3>最適化済みアニメーション</h3>
        <p>性能: {isLowPerformance ? '低' : '高'}</p>
      </div>
    </motion.div>
  );
};

export default OptimizedAnimation;

メモリ使用量の最適化

jsximport React, {
  useState,
  useEffect,
  useCallback,
} from 'react';
import { motion, AnimatePresence } from 'framer-motion';

const MemoryOptimizedList = () => {
  const [visibleItems, setVisibleItems] = useState([]);
  const [currentPage, setCurrentPage] = useState(0);

  const itemsPerPage = 10;
  const allItems = Array.from({ length: 100 }, (_, i) => ({
    id: i,
    title: `アイテム ${i + 1}`,
    content: `これはアイテム ${i + 1} の内容です。`,
  }));

  useEffect(() => {
    const start = currentPage * itemsPerPage;
    const end = start + itemsPerPage;
    setVisibleItems(allItems.slice(start, end));
  }, [currentPage]);

  const handleNext = useCallback(() => {
    setCurrentPage((prev) =>
      Math.min(
        prev + 1,
        Math.floor(allItems.length / itemsPerPage) - 1
      )
    );
  }, []);

  const handlePrev = useCallback(() => {
    setCurrentPage((prev) => Math.max(prev - 1, 0));
  }, []);

  return (
    <div>
      <h2>メモリ最適化リスト</h2>
      <div
        style={{
          display: 'flex',
          gap: '10px',
          marginBottom: '20px',
        }}
      >
        <button
          onClick={handlePrev}
          disabled={currentPage === 0}
        >
          前へ
        </button>
        <span>ページ {currentPage + 1}</span>
        <button
          onClick={handleNext}
          disabled={
            currentPage >=
            Math.floor(allItems.length / itemsPerPage) - 1
          }
        >
          次へ
        </button>
      </div>

      <AnimatePresence mode='wait'>
        <motion.div
          key={currentPage}
          initial={{ opacity: 0, x: 20 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -20 }}
          transition={{ duration: 0.3 }}
        >
          {visibleItems.map((item) => (
            <motion.div
              key={item.id}
              initial={{ opacity: 0, y: 20 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -20 }}
              transition={{ duration: 0.2 }}
              style={{
                padding: '15px',
                margin: '10px 0',
                backgroundColor: '#f8f9fa',
                borderRadius: '8px',
                border: '1px solid #e9ecef',
              }}
            >
              <h3>{item.title}</h3>
              <p>{item.content}</p>
            </motion.div>
          ))}
        </motion.div>
      </AnimatePresence>
    </div>
  );
};

export default MemoryOptimizedList;

テストとデバッグ手法

レスポンシブアニメーションのテストとデバッグの方法を紹介します。

デバイステスト

jsximport React, { useState, useEffect } from 'react';

const DeviceTest = () => {
  const [deviceInfo, setDeviceInfo] = useState({});

  useEffect(() => {
    const getDeviceInfo = () => {
      const info = {
        userAgent: navigator.userAgent,
        screenWidth: window.screen.width,
        screenHeight: window.screen.height,
        windowWidth: window.innerWidth,
        windowHeight: window.innerHeight,
        pixelRatio: window.devicePixelRatio,
        connection: navigator.connection
          ? {
              effectiveType:
                navigator.connection.effectiveType,
              downlink: navigator.connection.downlink,
              rtt: navigator.connection.rtt,
            }
          : 'Not available',
        memory: navigator.deviceMemory || 'Not available',
        cores:
          navigator.hardwareConcurrency || 'Not available',
      };

      setDeviceInfo(info);
    };

    getDeviceInfo();
    window.addEventListener('resize', getDeviceInfo);

    return () =>
      window.removeEventListener('resize', getDeviceInfo);
  }, []);

  return (
    <div
      style={{
        padding: '20px',
        backgroundColor: '#f8f9fa',
      }}
    >
      <h2>デバイス情報</h2>
      <pre
        style={{
          backgroundColor: '#fff',
          padding: '15px',
          borderRadius: '8px',
          overflow: 'auto',
          fontSize: '12px',
        }}
      >
        {JSON.stringify(deviceInfo, null, 2)}
      </pre>
    </div>
  );
};

export default DeviceTest;

パフォーマンス監視

jsximport React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';

const PerformanceMonitor = () => {
  const [fps, setFps] = useState(0);
  const [frameTime, setFrameTime] = useState(0);
  const frameCount = useRef(0);
  const lastTime = useRef(performance.now());

  useEffect(() => {
    const measurePerformance = () => {
      frameCount.current++;
      const currentTime = performance.now();

      if (currentTime - lastTime.current >= 1000) {
        const currentFps = Math.round(
          (frameCount.current * 1000) /
            (currentTime - lastTime.current)
        );
        const currentFrameTime =
          (currentTime - lastTime.current) /
          frameCount.current;

        setFps(currentFps);
        setFrameTime(currentFrameTime);

        frameCount.current = 0;
        lastTime.current = currentTime;
      }

      requestAnimationFrame(measurePerformance);
    };

    requestAnimationFrame(measurePerformance);
  }, []);

  return (
    <div
      style={{
        position: 'fixed',
        top: '10px',
        right: '10px',
        backgroundColor: 'rgba(0,0,0,0.8)',
        color: 'white',
        padding: '10px',
        borderRadius: '8px',
        fontSize: '12px',
        zIndex: 1000,
      }}
    >
      <div>FPS: {fps}</div>
      <div>Frame Time: {frameTime.toFixed(2)}ms</div>
      <div
        style={{
          color:
            fps < 30
              ? '#ff6b6b'
              : fps < 50
              ? '#ffd93d'
              : '#6bcf7f',
        }}
      >
        Status:{' '}
        {fps < 30 ? 'Poor' : fps < 50 ? 'Fair' : 'Good'}
      </div>
    </div>
  );
};

export default PerformanceMonitor;

まとめ

React でレスポンシブアニメーションを実装する際は、デバイスの性能とユーザー体験の両方を考慮することが重要です。

メディアクエリを使った条件分岐、デバイス検出による動的調整、パフォーマンスを考慮した軽量化テクニックを組み合わせることで、様々なデバイスで滑らかなアニメーションを実現できます。

特にモバイルデバイスでは、CPU 性能やメモリ容量の制約を理解し、適切な最適化を行うことで、ユーザーに快適な体験を提供できます。

実装時は、パフォーマンス監視ツールを活用し、実際のデバイスでの動作確認を怠らないようにしましょう。また、アクセシビリティにも配慮し、アニメーション軽減設定を尊重することも忘れずに行ってください。

レスポンシブアニメーションの実装は、技術的な挑戦でもありますが、適切に実装することで、ユーザー体験を大きく向上させることができます。

関連リンク