T-CREATOR

CSS だけじゃ物足りない!React アプリを動かす JS アニメーション 10 選

CSS だけじゃ物足りない!React アプリを動かす JS アニメーション 10 選

CSS を超えた表現力を求めているなら、JavaScript アニメーションこそが次のステップです。React アプリケーションにおいて、CSS だけでは実現できない動的で高度なアニメーション表現を 10 個厳選してご紹介します。

API データのリアルタイム表示からパーティクルエフェクト、物理演算を活用したドラッグ操作まで、ユーザーの心を掴む魅力的なインタラクションを実装する実践的な手法をお伝えしていきますね。

背景

CSS アニメーションの限界と JavaScript の可能性

CSS アニメーションは確かに美しく、パフォーマンスも優秀です。しかし、動的なデータ変化への対応や複雑な条件分岐、ユーザーの操作に応じたリアルタイムな反応となると、どうしても限界が見えてきます。

特に以下のような場面では、JavaScript の柔軟性が必要不可欠になります。

#制約CSS の限界JavaScript の解決策
1データ連動静的な値のみAPI レスポンスと連動
2複雑な条件判定簡単な擬似クラスのみif 文や三項演算子による制御
3非線形アニメーションease 関数の組み合わせのみ数学関数による自由な軌道
4インタラクション制御hover、focus 程度マウス座標やタッチジェスチャ
5タイムライン制御animation-delay の組み合わせ時間軸の自由な制御

モダン React 開発における JavaScript アニメーションの位置づけ

2025 年現在、React の状態管理機能と JavaScript アニメーションを組み合わせることで、これまでにない表現力を獲得できるようになりました。

フックシステムの活用により、アニメーションの状態管理も宣言的に記述でき、従来の命令的な DOM 操作とは一線を画した開発体験を実現できます。

課題

CSS だけでは実現困難な表現とその限界

実際の開発現場では、以下のような要求に対して CSS だけでは対応しきれない場面が頻繁に発生します。

リアルタイムデータ表示の課題

css/* ❌ CSS では不可能:動的な数値変化のアニメーション */
.counter::after {
  content: 'Loading...'; /* 固定値しか表示できない */
  animation: fadeIn 1s ease-in-out;
}

複雑な物理演算の制約

css/* ❌ CSS では不可能:重力や摩擦を考慮した動き */
.ball {
  animation: bounce 2s ease-in-out infinite;
  /* 単純な up-down しか表現できない */
}

よく遭遇するエラーパターン

開発中によく発生するエラーとその原因をご紹介します。

Animation Not Working エラー

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.

このエラーは、コンポーネントがアンマウントされた後もアニメーションが実行され続けることで発生します。

Performance Warning

csharp[Violation] 'requestAnimationFrame' handler took 23ms
Warning: Maximum update depth exceeded.

フレームレートが低下している際に発生する警告で、重いアニメーション処理が原因となります。

TypeScript との型安全性の課題

JavaScript アニメーションを TypeScript で実装する際の型定義の複雑さも課題の一つです。

typescript// ❌ 型エラーが発生しやすいパターン
const animationRef = useRef<HTMLElement>(null);

useEffect(() => {
  // Property 'animate' does not exist on type 'HTMLElement'
  animationRef.current?.animate(
    [
      /* ... */
    ],
    { duration: 1000 }
  );
}, []);

このような課題を解決するために、JavaScript ならではのアニメーション手法を活用した解決策をご紹介していきます。

解決策

JavaScript アニメーション 10 選

1. リアルタイム数値カウンター(API 連携データ表示)

API から取得したライブデータを滑らかにアニメーションしながら表示する手法です。

typescriptimport { useState, useEffect, useRef } from 'react';

interface CounterProps {
  targetValue: number;
  duration?: number;
  formatter?: (value: number) => string;
}

function AnimatedCounter({
  targetValue,
  duration = 2000,
  formatter = (value) => value.toLocaleString(),
}: CounterProps) {
  const [currentValue, setCurrentValue] = useState(0);
  const animationRef = useRef<number>();
  const startTimeRef = useRef<number>();

  useEffect(() => {
    // 前回のアニメーションをキャンセル
    if (animationRef.current) {
      cancelAnimationFrame(animationRef.current);
    }

    startTimeRef.current = performance.now();
    const startValue = currentValue;
    const difference = targetValue - startValue;

    const animate = (currentTime: number) => {
      const elapsed =
        currentTime - (startTimeRef.current || 0);
      const progress = Math.min(elapsed / duration, 1);

      // イージング関数(ease-out)
      const easedProgress = 1 - Math.pow(1 - progress, 3);
      const newValue =
        startValue + difference * easedProgress;

      setCurrentValue(newValue);

      if (progress < 1) {
        animationRef.current =
          requestAnimationFrame(animate);
      }
    };

    animationRef.current = requestAnimationFrame(animate);

    // クリーンアップ
    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
    };
  }, [targetValue, duration]);

  return (
    <div className='animated-counter'>
      <span className='counter-value'>
        {formatter(currentValue)}
      </span>
    </div>
  );
}

2. パーティクル爆発エフェクト(Canvas 活用)

成功時のフィードバックやゲーミフィケーション要素として活用できるパーティクル効果です。

typescriptimport { useRef, useEffect, useCallback } from 'react';

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  maxLife: number;
  color: string;
  size: number;
}

interface ParticleExplosionProps {
  trigger: boolean;
  onComplete?: () => void;
}

function ParticleExplosion({
  trigger,
  onComplete,
}: ParticleExplosionProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const particlesRef = useRef<Particle[]>([]);
  const animationRef = useRef<number>();

  const createParticles = useCallback(
    (centerX: number, centerY: number) => {
      const particles: Particle[] = [];
      const colors = [
        '#ff6b6b',
        '#4ecdc4',
        '#45b7d1',
        '#feca57',
        '#ff9ff3',
      ];

      for (let i = 0; i < 50; i++) {
        const angle = (Math.PI * 2 * i) / 50;
        const velocity = 2 + Math.random() * 4;

        particles.push({
          x: centerX,
          y: centerY,
          vx: Math.cos(angle) * velocity,
          vy: Math.sin(angle) * velocity,
          life: 60,
          maxLife: 60,
          color:
            colors[
              Math.floor(Math.random() * colors.length)
            ],
          size: 2 + Math.random() * 3,
        });
      }

      return particles;
    },
    []
  );

  const animate = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    ctx.clearRect(0, 0, canvas.width, canvas.height);

    particlesRef.current = particlesRef.current.filter(
      (particle) => {
        // 物理演算
        particle.x += particle.vx;
        particle.y += particle.vy;
        particle.vy += 0.1; // 重力
        particle.vx *= 0.99; // 空気抵抗
        particle.life--;

        // 描画
        const alpha = particle.life / particle.maxLife;
        ctx.globalAlpha = alpha;
        ctx.fillStyle = particle.color;
        ctx.beginPath();
        ctx.arc(
          particle.x,
          particle.y,
          particle.size,
          0,
          Math.PI * 2
        );
        ctx.fill();

        return particle.life > 0;
      }
    );

    if (particlesRef.current.length > 0) {
      animationRef.current = requestAnimationFrame(animate);
    } else {
      onComplete?.();
    }
  }, [onComplete]);

  useEffect(() => {
    if (trigger) {
      const canvas = canvasRef.current;
      if (!canvas) return;

      // パーティクル生成
      particlesRef.current = createParticles(
        canvas.width / 2,
        canvas.height / 2
      );

      // アニメーション開始
      animationRef.current = requestAnimationFrame(animate);
    }

    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
    };
  }, [trigger, createParticles, animate]);

  return (
    <canvas
      ref={canvasRef}
      width={400}
      height={300}
      className='particle-canvas'
      style={{
        position: 'absolute',
        pointerEvents: 'none',
        zIndex: 1000,
      }}
    />
  );
}

3. インフィニットスクロール連動アニメーション

スクロール位置に応じて要素が段階的にアニメーションする実装です。

typescriptimport {
  useState,
  useEffect,
  useRef,
  useCallback,
} from 'react';

interface ScrollRevealProps {
  children: React.ReactNode;
  className?: string;
  threshold?: number;
  rootMargin?: string;
}

function ScrollReveal({
  children,
  className = '',
  threshold = 0.1,
  rootMargin = '0px',
}: ScrollRevealProps) {
  const [isVisible, setIsVisible] = useState(false);
  const elementRef = useRef<HTMLDivElement>(null);

  const handleIntersection = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const [entry] = entries;

      if (entry.isIntersecting) {
        setIsVisible(true);
        // 一度表示されたら監視を停止(オプション)
        observer.disconnect();
      }
    },
    []
  );

  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      handleIntersection,
      {
        threshold,
        rootMargin,
      }
    );

    observer.observe(element);

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

  return (
    <div
      ref={elementRef}
      className={`scroll-reveal ${className} ${
        isVisible ? 'visible' : ''
      }`}
      style={{
        opacity: isVisible ? 1 : 0,
        transform: isVisible
          ? 'translateY(0)'
          : 'translateY(50px)',
        transition:
          'opacity 0.6s ease-out, transform 0.6s ease-out',
      }}
    >
      {children}
    </div>
  );
}

// 使用例:無限スクロールリスト
function InfiniteScrollList() {
  const [items, setItems] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);

  const loadMoreItems = useCallback(async () => {
    setLoading(true);

    try {
      // API呼び出しをシミュレート
      await new Promise((resolve) =>
        setTimeout(resolve, 1000)
      );
      const newItems = Array.from(
        { length: 10 },
        (_, i) => `Item ${items.length + i + 1}`
      );
      setItems((prev) => [...prev, ...newItems]);
    } catch (error) {
      console.error('Failed to load items:', error);
    } finally {
      setLoading(false);
    }
  }, [items.length]);

  return (
    <div className='infinite-scroll-container'>
      {items.map((item, index) => (
        <ScrollReveal key={index} className='list-item'>
          <div className='item-content'>{item}</div>
        </ScrollReveal>
      ))}

      {!loading && (
        <ScrollReveal threshold={1}>
          <button
            onClick={loadMoreItems}
            className='load-more-button'
          >
            Load More
          </button>
        </ScrollReveal>
      )}

      {loading && (
        <div className='loading-spinner'>Loading...</div>
      )}
    </div>
  );
}

4. ドラッグ&ドロップ with 物理演算

物理的な慣性と反発を考慮したリアルなドラッグ操作を実装します。

typescriptimport {
  useState,
  useRef,
  useEffect,
  useCallback,
} from 'react';

interface Position {
  x: number;
  y: number;
}

interface Velocity {
  x: number;
  y: number;
}

interface DraggablePhysicsProps {
  children: React.ReactNode;
  onPositionChange?: (position: Position) => void;
}

function DraggablePhysics({
  children,
  onPositionChange,
}: DraggablePhysicsProps) {
  const [position, setPosition] = useState<Position>({
    x: 0,
    y: 0,
  });
  const [isDragging, setIsDragging] = useState(false);
  const velocityRef = useRef<Velocity>({ x: 0, y: 0 });
  const lastPositionRef = useRef<Position>({ x: 0, y: 0 });
  const animationRef = useRef<number>();
  const elementRef = useRef<HTMLDivElement>(null);

  // 物理演算のパラメータ
  const friction = 0.85;
  const bounceStrength = 0.6;

  const updatePhysics = useCallback(() => {
    if (isDragging) return;

    const element = elementRef.current;
    if (!element) return;

    const rect = element.getBoundingClientRect();
    const parentRect =
      element.parentElement?.getBoundingClientRect();

    if (!parentRect) return;

    setPosition((prev) => {
      let newX = prev.x + velocityRef.current.x;
      let newY = prev.y + velocityRef.current.y;

      // 境界判定と反発
      const maxX = parentRect.width - rect.width;
      const maxY = parentRect.height - rect.height;

      if (newX < 0 || newX > maxX) {
        velocityRef.current.x *= -bounceStrength;
        newX = Math.max(0, Math.min(maxX, newX));
      }

      if (newY < 0 || newY > maxY) {
        velocityRef.current.y *= -bounceStrength;
        newY = Math.max(0, Math.min(maxY, newY));
      }

      // 摩擦の適用
      velocityRef.current.x *= friction;
      velocityRef.current.y *= friction;

      // 速度が十分小さくなったら停止
      if (
        Math.abs(velocityRef.current.x) < 0.1 &&
        Math.abs(velocityRef.current.y) < 0.1
      ) {
        velocityRef.current.x = 0;
        velocityRef.current.y = 0;
        return prev;
      }

      const newPosition = { x: newX, y: newY };
      onPositionChange?.(newPosition);
      return newPosition;
    });

    if (
      velocityRef.current.x !== 0 ||
      velocityRef.current.y !== 0
    ) {
      animationRef.current =
        requestAnimationFrame(updatePhysics);
    }
  }, [
    isDragging,
    onPositionChange,
    friction,
    bounceStrength,
  ]);

  const handleMouseDown = useCallback(
    (e: React.MouseEvent) => {
      setIsDragging(true);
      lastPositionRef.current = {
        x: e.clientX,
        y: e.clientY,
      };

      // 物理演算を停止
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
      velocityRef.current = { x: 0, y: 0 };
    },
    []
  );

  const handleMouseMove = useCallback(
    (e: MouseEvent) => {
      if (!isDragging) return;

      const deltaX = e.clientX - lastPositionRef.current.x;
      const deltaY = e.clientY - lastPositionRef.current.y;

      // 速度を記録(慣性計算用)
      velocityRef.current.x = deltaX * 0.1;
      velocityRef.current.y = deltaY * 0.1;

      setPosition((prev) => {
        const newPosition = {
          x: prev.x + deltaX,
          y: prev.y + deltaY,
        };
        onPositionChange?.(newPosition);
        return newPosition;
      });

      lastPositionRef.current = {
        x: e.clientX,
        y: e.clientY,
      };
    },
    [isDragging, onPositionChange]
  );

  const handleMouseUp = useCallback(() => {
    setIsDragging(false);
    // 慣性による物理演算を開始
    animationRef.current =
      requestAnimationFrame(updatePhysics);
  }, [updatePhysics]);

  useEffect(() => {
    if (isDragging) {
      document.addEventListener(
        'mousemove',
        handleMouseMove
      );
      document.addEventListener('mouseup', handleMouseUp);
    }

    return () => {
      document.removeEventListener(
        'mousemove',
        handleMouseMove
      );
      document.removeEventListener(
        'mouseup',
        handleMouseUp
      );
    };
  }, [isDragging, handleMouseMove, handleMouseUp]);

  return (
    <div
      ref={elementRef}
      style={{
        position: 'absolute',
        left: position.x,
        top: position.y,
        cursor: isDragging ? 'grabbing' : 'grab',
        userSelect: 'none',
        transition: isDragging
          ? 'none'
          : 'transform 0.1s ease-out',
      }}
      onMouseDown={handleMouseDown}
    >
      {children}
    </div>
  );
}

5. 状態変化に連動するモーフィング

React の状態変化に応じて要素の形状が滑らかに変化するアニメーションです。

typescriptimport { useState, useEffect, useMemo } from 'react';

interface MorphingButtonProps {
  states: {
    default: { text: string; color: string; width: number };
    loading: { text: string; color: string; width: number };
    success: { text: string; color: string; width: number };
    error: { text: string; color: string; width: number };
  };
  currentState: keyof MorphingButtonProps['states'];
  onClick?: () => void;
}

function MorphingButton({
  states,
  currentState,
  onClick,
}: MorphingButtonProps) {
  const [displayText, setDisplayText] = useState(
    states[currentState].text
  );
  const [animatingText, setAnimatingText] = useState(false);

  const currentConfig = states[currentState];

  // テキスト変更のアニメーション
  useEffect(() => {
    if (displayText !== currentConfig.text) {
      setAnimatingText(true);

      // フェードアウト → テキスト変更 → フェードイン
      const timer = setTimeout(() => {
        setDisplayText(currentConfig.text);
        setAnimatingText(false);
      }, 150);

      return () => clearTimeout(timer);
    }
  }, [currentConfig.text, displayText]);

  const buttonStyle = useMemo(
    () => ({
      width: currentConfig.width,
      backgroundColor: currentConfig.color,
      color: 'white',
      border: 'none',
      borderRadius: '25px',
      padding: '12px 24px',
      fontSize: '16px',
      fontWeight: 'bold',
      cursor:
        currentState === 'loading'
          ? 'not-allowed'
          : 'pointer',
      transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
      position: 'relative' as const,
      overflow: 'hidden' as const,
      opacity: animatingText ? 0.7 : 1,
    }),
    [currentConfig, currentState, animatingText]
  );

  return (
    <button
      style={buttonStyle}
      onClick={
        currentState !== 'loading' ? onClick : undefined
      }
      disabled={currentState === 'loading'}
    >
      {currentState === 'loading' && (
        <div
          style={{
            position: 'absolute',
            left: '50%',
            top: '50%',
            transform: 'translate(-50%, -50%)',
            width: '20px',
            height: '20px',
            border: '2px solid transparent',
            borderTop: '2px solid white',
            borderRadius: '50%',
            animation: 'spin 1s linear infinite',
          }}
        />
      )}
      <span
        style={{
          opacity: currentState === 'loading' ? 0 : 1,
          transition: 'opacity 0.2s ease-in-out',
        }}
      >
        {displayText}
      </span>
    </button>
  );
}

// 使用例
function MorphingButtonDemo() {
  const [buttonState, setButtonState] = useState<
    'default' | 'loading' | 'success' | 'error'
  >('default');

  const handleClick = async () => {
    setButtonState('loading');

    try {
      // API呼び出しをシミュレート
      await new Promise((resolve, reject) => {
        setTimeout(() => {
          Math.random() > 0.5
            ? resolve(true)
            : reject(new Error('Failed'));
        }, 2000);
      });

      setButtonState('success');
      setTimeout(() => setButtonState('default'), 2000);
    } catch (error) {
      setButtonState('error');
      setTimeout(() => setButtonState('default'), 2000);
    }
  };

  return (
    <div style={{ padding: '20px' }}>
      <MorphingButton
        states={{
          default: {
            text: 'Submit',
            color: '#3b82f6',
            width: 120,
          },
          loading: {
            text: 'Processing...',
            color: '#6b7280',
            width: 150,
          },
          success: {
            text: 'Success!',
            color: '#10b981',
            width: 130,
          },
          error: {
            text: 'Try Again',
            color: '#ef4444',
            width: 140,
          },
        }}
        currentState={buttonState}
        onClick={handleClick}
      />
    </div>
  );
}

6. マウス追従インタラクション

マウスカーソルの動きに反応して、要素が自然に追従するインタラクションです。

typescriptimport {
  useState,
  useRef,
  useEffect,
  useCallback,
} from 'react';

interface MousePosition {
  x: number;
  y: number;
}

interface MagneticElementProps {
  children: React.ReactNode;
  magnetism?: number;
  distance?: number;
  easing?: number;
}

function MagneticElement({
  children,
  magnetism = 0.3,
  distance = 100,
  easing = 0.15,
}: MagneticElementProps) {
  const [mousePosition, setMousePosition] =
    useState<MousePosition>({ x: 0, y: 0 });
  const [elementPosition, setElementPosition] =
    useState<MousePosition>({ x: 0, y: 0 });
  const elementRef = useRef<HTMLDivElement>(null);
  const animationRef = useRef<number>();

  const handleMouseMove = useCallback(
    (e: MouseEvent) => {
      const element = elementRef.current;
      if (!element) return;

      const rect = element.getBoundingClientRect();
      const centerX = rect.left + rect.width / 2;
      const centerY = rect.top + rect.height / 2;

      const deltaX = e.clientX - centerX;
      const deltaY = e.clientY - centerY;
      const distanceToMouse = Math.sqrt(
        deltaX * deltaX + deltaY * deltaY
      );

      if (distanceToMouse < distance) {
        // 磁力の強さを距離に応じて調整
        const force =
          (distance - distanceToMouse) / distance;
        setMousePosition({
          x: deltaX * magnetism * force,
          y: deltaY * magnetism * force,
        });
      } else {
        setMousePosition({ x: 0, y: 0 });
      }
    },
    [magnetism, distance]
  );

  const animateElement = useCallback(() => {
    setElementPosition((prev) => ({
      x: prev.x + (mousePosition.x - prev.x) * easing,
      y: prev.y + (mousePosition.y - prev.y) * easing,
    }));

    animationRef.current =
      requestAnimationFrame(animateElement);
  }, [mousePosition, easing]);

  useEffect(() => {
    document.addEventListener('mousemove', handleMouseMove);
    animationRef.current =
      requestAnimationFrame(animateElement);

    return () => {
      document.removeEventListener(
        'mousemove',
        handleMouseMove
      );
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
    };
  }, [handleMouseMove, animateElement]);

  return (
    <div
      ref={elementRef}
      style={{
        transform: `translate(${elementPosition.x}px, ${elementPosition.y}px)`,
        transition: 'none',
      }}
    >
      {children}
    </div>
  );
}

// カーソル追従エフェクト
function CursorFollower() {
  const [cursorPosition, setCursorPosition] =
    useState<MousePosition>({ x: 0, y: 0 });
  const [trails, setTrails] = useState<
    Array<MousePosition & { id: number }>
  >([]);

  useEffect(() => {
    let trailId = 0;

    const handleMouseMove = (e: MouseEvent) => {
      const newPosition = { x: e.clientX, y: e.clientY };
      setCursorPosition(newPosition);

      // トレイル効果
      setTrails((prev) => [
        ...prev.slice(-10), // 最新10個のトレイルを保持
        { ...newPosition, id: trailId++ },
      ]);
    };

    document.addEventListener('mousemove', handleMouseMove);
    return () =>
      document.removeEventListener(
        'mousemove',
        handleMouseMove
      );
  }, []);

  return (
    <>
      {trails.map((trail, index) => (
        <div
          key={trail.id}
          style={{
            position: 'fixed',
            left: trail.x,
            top: trail.y,
            width: '10px',
            height: '10px',
            backgroundColor: '#3b82f6',
            borderRadius: '50%',
            opacity: ((index + 1) / trails.length) * 0.5,
            transform: 'translate(-50%, -50%)',
            pointerEvents: 'none',
            zIndex: 9999,
            transition: 'opacity 0.2s ease-out',
          }}
        />
      ))}
    </>
  );
}

7. タイムライン制御アニメーション

複数の要素を時間軸で制御する高度なアニメーションシステムです。

typescriptimport {
  useState,
  useRef,
  useEffect,
  useCallback,
} from 'react';

interface AnimationKeyframe {
  time: number;
  properties: Record<string, any>;
  easing?: string;
}

interface TimelineItem {
  id: string;
  element: React.RefObject<HTMLElement>;
  keyframes: AnimationKeyframe[];
}

class AnimationTimeline {
  private items: TimelineItem[] = [];
  private startTime: number = 0;
  private isPlaying: boolean = false;
  private animationFrame: number = 0;
  private duration: number = 0;

  addItem(item: TimelineItem) {
    this.items.push(item);
    // 全体の duration を計算
    const maxTime = Math.max(
      ...item.keyframes.map((k) => k.time)
    );
    this.duration = Math.max(this.duration, maxTime);
  }

  play() {
    if (this.isPlaying) return;

    this.isPlaying = true;
    this.startTime = performance.now();
    this.animate();
  }

  pause() {
    this.isPlaying = false;
    if (this.animationFrame) {
      cancelAnimationFrame(this.animationFrame);
    }
  }

  private animate = () => {
    if (!this.isPlaying) return;

    const currentTime = performance.now() - this.startTime;
    const progress = Math.min(
      currentTime / 1000,
      this.duration
    ); // 秒単位

    this.items.forEach((item) => {
      this.updateElement(item, progress);
    });

    if (progress < this.duration) {
      this.animationFrame = requestAnimationFrame(
        this.animate
      );
    } else {
      this.isPlaying = false;
    }
  };

  private updateElement(
    item: TimelineItem,
    currentTime: number
  ) {
    const element = item.element.current;
    if (!element) return;

    // 現在時刻に該当するキーフレームを見つける
    const keyframes = item.keyframes.sort(
      (a, b) => a.time - b.time
    );
    let fromKeyframe = keyframes[0];
    let toKeyframe = keyframes[keyframes.length - 1];

    for (let i = 0; i < keyframes.length - 1; i++) {
      if (
        currentTime >= keyframes[i].time &&
        currentTime <= keyframes[i + 1].time
      ) {
        fromKeyframe = keyframes[i];
        toKeyframe = keyframes[i + 1];
        break;
      }
    }

    // 補間計算
    const duration = toKeyframe.time - fromKeyframe.time;
    const elapsed = currentTime - fromKeyframe.time;
    const progress =
      duration > 0 ? Math.min(elapsed / duration, 1) : 1;

    // プロパティの補間
    const interpolatedProperties: Record<string, any> = {};

    Object.keys(toKeyframe.properties).forEach((prop) => {
      const fromValue = fromKeyframe.properties[prop] || 0;
      const toValue = toKeyframe.properties[prop];

      if (
        typeof fromValue === 'number' &&
        typeof toValue === 'number'
      ) {
        interpolatedProperties[prop] =
          fromValue + (toValue - fromValue) * progress;
      } else {
        interpolatedProperties[prop] =
          progress >= 1 ? toValue : fromValue;
      }
    });

    // スタイル適用
    this.applyStyles(element, interpolatedProperties);
  }

  private applyStyles(
    element: HTMLElement,
    properties: Record<string, any>
  ) {
    Object.entries(properties).forEach(([prop, value]) => {
      switch (prop) {
        case 'x':
        case 'y':
          const x = properties.x || 0;
          const y = properties.y || 0;
          element.style.transform = `translate(${x}px, ${y}px)`;
          break;
        case 'scale':
          element.style.transform = `scale(${value})`;
          break;
        case 'opacity':
          element.style.opacity = value.toString();
          break;
        case 'backgroundColor':
          element.style.backgroundColor = value;
          break;
        default:
          (element.style as any)[prop] = value;
      }
    });
  }
}

function TimelineDemo() {
  const timeline = useRef(new AnimationTimeline());
  const box1Ref = useRef<HTMLDivElement>(null);
  const box2Ref = useRef<HTMLDivElement>(null);
  const box3Ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // タイムライン設定
    timeline.current.addItem({
      id: 'box1',
      element: box1Ref,
      keyframes: [
        { time: 0, properties: { x: 0, opacity: 1 } },
        { time: 1, properties: { x: 200, opacity: 0.5 } },
        { time: 2, properties: { x: 0, opacity: 1 } },
      ],
    });

    timeline.current.addItem({
      id: 'box2',
      element: box2Ref,
      keyframes: [
        {
          time: 0.5,
          properties: {
            scale: 1,
            backgroundColor: '#3b82f6',
          },
        },
        {
          time: 1.5,
          properties: {
            scale: 1.5,
            backgroundColor: '#ef4444',
          },
        },
        {
          time: 2.5,
          properties: {
            scale: 1,
            backgroundColor: '#10b981',
          },
        },
      ],
    });

    timeline.current.addItem({
      id: 'box3',
      element: box3Ref,
      keyframes: [
        { time: 1, properties: { y: 0, opacity: 0 } },
        { time: 2, properties: { y: -100, opacity: 1 } },
        { time: 3, properties: { y: 0, opacity: 0.5 } },
      ],
    });
  }, []);

  return (
    <div
      style={{
        padding: '50px',
        position: 'relative',
        height: '300px',
      }}
    >
      <button onClick={() => timeline.current.play()}>
        Play Timeline
      </button>
      <button onClick={() => timeline.current.pause()}>
        Pause Timeline
      </button>

      <div
        ref={box1Ref}
        style={{
          position: 'absolute',
          top: '100px',
          left: '50px',
          width: '50px',
          height: '50px',
          backgroundColor: '#3b82f6',
          borderRadius: '8px',
        }}
      />

      <div
        ref={box2Ref}
        style={{
          position: 'absolute',
          top: '100px',
          left: '150px',
          width: '50px',
          height: '50px',
          backgroundColor: '#3b82f6',
          borderRadius: '50%',
        }}
      />

      <div
        ref={box3Ref}
        style={{
          position: 'absolute',
          top: '100px',
          left: '250px',
          width: '50px',
          height: '50px',
          backgroundColor: '#10b981',
          borderRadius: '8px',
        }}
      />
    </div>
  );
}

8. データ可視化チャートアニメーション

データの変化を視覚的に美しく表現するチャートアニメーションです。

typescriptimport { useState, useEffect, useRef } from 'react';

interface ChartData {
  label: string;
  value: number;
  color: string;
}

interface AnimatedBarChartProps {
  data: ChartData[];
  width?: number;
  height?: number;
  animationDuration?: number;
}

function AnimatedBarChart({
  data,
  width = 400,
  height = 300,
  animationDuration = 1500,
}: AnimatedBarChartProps) {
  const [animatedData, setAnimatedData] = useState<
    ChartData[]
  >([]);
  const animationRef = useRef<number>();
  const startTimeRef = useRef<number>();

  useEffect(() => {
    if (animationRef.current) {
      cancelAnimationFrame(animationRef.current);
    }

    startTimeRef.current = performance.now();
    const maxValue = Math.max(...data.map((d) => d.value));

    const animate = (currentTime: number) => {
      const elapsed =
        currentTime - (startTimeRef.current || 0);
      const progress = Math.min(
        elapsed / animationDuration,
        1
      );

      // イージング関数
      const easedProgress = 1 - Math.pow(1 - progress, 3);

      const newData = data.map((item) => ({
        ...item,
        value: item.value * easedProgress,
      }));

      setAnimatedData(newData);

      if (progress < 1) {
        animationRef.current =
          requestAnimationFrame(animate);
      }
    };

    animationRef.current = requestAnimationFrame(animate);

    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
    };
  }, [data, animationDuration]);

  const maxValue = Math.max(...data.map((d) => d.value));
  const barWidth = (width - 100) / data.length - 10;

  return (
    <div style={{ position: 'relative', width, height }}>
      <svg width={width} height={height}>
        {animatedData.map((item, index) => {
          const barHeight =
            (item.value / maxValue) * (height - 60);
          const x = 50 + index * (barWidth + 10);
          const y = height - barHeight - 30;

          return (
            <g key={item.label}>
              <rect
                x={x}
                y={y}
                width={barWidth}
                height={barHeight}
                fill={item.color}
                rx={4}
                style={{
                  filter:
                    'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
                }}
              />
              <text
                x={x + barWidth / 2}
                y={height - 10}
                textAnchor='middle'
                fontSize='12'
                fill='#666'
              >
                {item.label}
              </text>
              <text
                x={x + barWidth / 2}
                y={y - 5}
                textAnchor='middle'
                fontSize='10'
                fill='#333'
                fontWeight='bold'
              >
                {Math.round(item.value)}
              </text>
            </g>
          );
        })}
      </svg>
    </div>
  );
}

// 使用例:リアルタイムデータチャート
function RealTimeChart() {
  const [chartData, setChartData] = useState<ChartData[]>([
    { label: 'Jan', value: 0, color: '#3b82f6' },
    { label: 'Feb', value: 0, color: '#10b981' },
    { label: 'Mar', value: 0, color: '#f59e0b' },
    { label: 'Apr', value: 0, color: '#ef4444' },
  ]);

  const updateData = () => {
    setChartData((prev) =>
      prev.map((item) => ({
        ...item,
        value: Math.floor(Math.random() * 100) + 10,
      }))
    );
  };

  return (
    <div style={{ padding: '20px' }}>
      <button
        onClick={updateData}
        style={{ marginBottom: '20px' }}
      >
        Update Data
      </button>
      <AnimatedBarChart data={chartData} />
    </div>
  );
}

9. ジェスチャー認識アニメーション

タッチジェスチャーやマウス操作パターンを認識してアニメーションを発火させます。

typescriptimport {
  useState,
  useRef,
  useCallback,
  useEffect,
} from 'react';

interface GesturePoint {
  x: number;
  y: number;
  timestamp: number;
}

interface SwipeGestureProps {
  onSwipeLeft?: () => void;
  onSwipeRight?: () => void;
  onSwipeUp?: () => void;
  onSwipeDown?: () => void;
  threshold?: number;
  children: React.ReactNode;
}

function SwipeGestureDetector({
  onSwipeLeft,
  onSwipeRight,
  onSwipeUp,
  onSwipeDown,
  threshold = 50,
  children,
}: SwipeGestureProps) {
  const [touchStart, setTouchStart] =
    useState<GesturePoint | null>(null);
  const [currentTransform, setCurrentTransform] = useState({
    x: 0,
    y: 0,
  });
  const [isAnimating, setIsAnimating] = useState(false);
  const elementRef = useRef<HTMLDivElement>(null);

  const handleTouchStart = useCallback(
    (e: React.TouchEvent) => {
      const touch = e.touches[0];
      setTouchStart({
        x: touch.clientX,
        y: touch.clientY,
        timestamp: Date.now(),
      });
    },
    []
  );

  const handleTouchMove = useCallback(
    (e: React.TouchEvent) => {
      if (!touchStart || isAnimating) return;

      const touch = e.touches[0];
      const deltaX = touch.clientX - touchStart.x;
      const deltaY = touch.clientY - touchStart.y;

      // リアルタイムで要素を移動
      setCurrentTransform({
        x: deltaX * 0.5,
        y: deltaY * 0.5,
      });

      e.preventDefault();
    },
    [touchStart, isAnimating]
  );

  const handleTouchEnd = useCallback(
    (e: React.TouchEvent) => {
      if (!touchStart) return;

      const touch = e.changedTouches[0];
      const deltaX = touch.clientX - touchStart.x;
      const deltaY = touch.clientY - touchStart.y;
      const deltaTime = Date.now() - touchStart.timestamp;

      // 速度を考慮した判定
      const velocityX = Math.abs(deltaX) / deltaTime;
      const velocityY = Math.abs(deltaY) / deltaTime;

      if (Math.abs(deltaX) > threshold && velocityX > 0.5) {
        setIsAnimating(true);

        if (deltaX > 0) {
          onSwipeRight?.();
          // 右スワイプアニメーション
          setCurrentTransform({ x: 300, y: 0 });
        } else {
          onSwipeLeft?.();
          // 左スワイプアニメーション
          setCurrentTransform({ x: -300, y: 0 });
        }

        // アニメーション終了後にリセット
        setTimeout(() => {
          setCurrentTransform({ x: 0, y: 0 });
          setIsAnimating(false);
        }, 300);
      } else if (
        Math.abs(deltaY) > threshold &&
        velocityY > 0.5
      ) {
        setIsAnimating(true);

        if (deltaY > 0) {
          onSwipeDown?.();
          setCurrentTransform({ x: 0, y: 200 });
        } else {
          onSwipeUp?.();
          setCurrentTransform({ x: 0, y: -200 });
        }

        setTimeout(() => {
          setCurrentTransform({ x: 0, y: 0 });
          setIsAnimating(false);
        }, 300);
      } else {
        // スワイプが不十分な場合は元に戻す
        setCurrentTransform({ x: 0, y: 0 });
      }

      setTouchStart(null);
    },
    [
      touchStart,
      threshold,
      onSwipeLeft,
      onSwipeRight,
      onSwipeUp,
      onSwipeDown,
    ]
  );

  return (
    <div
      ref={elementRef}
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
      style={{
        transform: `translate(${currentTransform.x}px, ${currentTransform.y}px)`,
        transition: isAnimating
          ? 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
          : 'none',
        touchAction: 'none',
        userSelect: 'none',
      }}
    >
      {children}
    </div>
  );
}

// 長押しジェスチャー検出
function LongPressGesture({
  onLongPress,
  duration = 800,
  children,
}: {
  onLongPress: () => void;
  duration?: number;
  children: React.ReactNode;
}) {
  const [isPressed, setIsPressed] = useState(false);
  const [progress, setProgress] = useState(0);
  const timerRef = useRef<number>();
  const progressRef = useRef<number>();

  const startPress = useCallback(() => {
    setIsPressed(true);
    setProgress(0);

    const startTime = Date.now();

    const updateProgress = () => {
      const elapsed = Date.now() - startTime;
      const newProgress = Math.min(elapsed / duration, 1);
      setProgress(newProgress);

      if (newProgress < 1) {
        progressRef.current =
          requestAnimationFrame(updateProgress);
      } else {
        onLongPress();
        setIsPressed(false);
        setProgress(0);
      }
    };

    progressRef.current =
      requestAnimationFrame(updateProgress);
  }, [duration, onLongPress]);

  const cancelPress = useCallback(() => {
    setIsPressed(false);
    setProgress(0);
    if (progressRef.current) {
      cancelAnimationFrame(progressRef.current);
    }
  }, []);

  return (
    <div
      onMouseDown={startPress}
      onMouseUp={cancelPress}
      onMouseLeave={cancelPress}
      onTouchStart={startPress}
      onTouchEnd={cancelPress}
      style={{
        position: 'relative',
        cursor: 'pointer',
        transform: isPressed ? 'scale(0.95)' : 'scale(1)',
        transition: 'transform 0.1s ease-out',
      }}
    >
      {children}
      {isPressed && (
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            background: `radial-gradient(circle, rgba(59, 130, 246, 0.3) ${
              progress * 100
            }%, transparent ${progress * 100}%)`,
            borderRadius: 'inherit',
            pointerEvents: 'none',
          }}
        />
      )}
    </div>
  );
}

10. リアルタイム通信連動エフェクト

WebSocket や Server-Sent Events を活用したリアルタイム通信と連動するアニメーションです。

typescriptimport { useState, useEffect, useRef, useCallback } from 'react';

interface NotificationData {
  id: string;
  type: 'success' | 'warning' | 'error' | 'info';
  message: string;
  timestamp: number;
}

interface RealTimeNotificationProps {
  websocketUrl?: string;
}

function RealTimeNotificationSystem({ websocketUrl }: RealTimeNotificationProps) {
  const [notifications, setNotifications] = useState<NotificationData[]>([]);
  const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'connecting'>('disconnected');
  const wsRef = useRef<WebSocket | null>(null);

  const addNotification = useCallback((notification: NotificationData) => {
    setNotifications(prev => [...prev, notification]);

    // 自動削除
    setTimeout(() => {
      setNotifications(prev => prev.filter(n => n.id !== notification.id));
    }, 5000);
  }, []);

  const connectWebSocket = useCallback(() => {
    if (!websocketUrl) return;

    setConnectionStatus('connecting');

    try {
      wsRef.current = new WebSocket(websocketUrl);

      wsRef.current.onopen = () => {
        setConnectionStatus('connected');
        console.log('WebSocket connected');
      };

      wsRef.current.onmessage = (event) => {
        try {
          const data = JSON.parse(event.data);
          addNotification({
            id: `notification-${Date.now()}`,
            type: data.type || 'info',
            message: data.message,
            timestamp: Date.now()
          });
        } catch (error) {
          console.error('Failed to parse WebSocket message:', error);
        }
      };

      wsRef.current.onclose = () => {
        setConnectionStatus('disconnected');
        console.log('WebSocket disconnected');

        // 自動再接続
        setTimeout(connectWebSocket, 3000);
      };

      wsRef.current.onerror = (error) => {
        console.error('WebSocket error:', error);
        setConnectionStatus('disconnected');
      };
    } catch (error) {
      console.error('Failed to connect WebSocket:', error);
      setConnectionStatus('disconnected');
    }
  }, [websocketUrl, addNotification]);

  useEffect(() => {
    connectWebSocket();

    return () => {
      if (wsRef.current) {
        wsRef.current.close();
      }
    };
  }, [connectWebSocket]);

  // デモ用の模擬通知生成
  const generateMockNotification = () => {
    const types: NotificationData['type'][] = ['success', 'warning', 'error', 'info'];
    const messages = [
      'New user registered',
      'Payment processed successfully',
      'Server maintenance scheduled',
      'New message received'
    ];

    addNotification({
      id: `mock-${Date.now()}`,
      type: types[Math.floor(Math.random() * types.length)],
      message: messages[Math.floor(Math.random() * messages.length)],
      timestamp: Date.now()
    });
  };

  return (
    <div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 1000 }}>
      {/* 接続状態インジケーター */}
      <div style={{
        display: 'flex',
        alignItems: 'center',
        marginBottom: '10px',
        padding: '8px 12px',
        backgroundColor: connectionStatus === 'connected' ? '#10b981' : '#ef4444',
        color: 'white',
        borderRadius: '20px',
        fontSize: '12px',
        fontWeight: 'bold'
      }}>
        <div style={{
          width: '8px',
          height: '8px',
          borderRadius: '50%',
          backgroundColor: 'white',
          marginRight: '8px',
          animation: connectionStatus === 'connecting' ? 'pulse 1s infinite' : 'none'
        }} />
        {connectionStatus.toUpperCase()}
      </div>

      {/* デモボタン */}
      <button
        onClick={generateMockNotification}
        style={{
          marginBottom: '10px',
          padding: '8px 16px',
          backgroundColor: '#3b82f6',
          color: 'white',
          border: 'none',
          borderRadius: '6px',
          cursor: 'pointer'
        }}
      >
        Test Notification
      </button>

      {/* 通知リスト */}
      <div style={{ maxWidth: '300px' }}>
        {notifications.map((notification, index) => (
          <NotificationItem
            key={notification.id}
            notification={notification}
            index={index}
          />
        ))}
      </div>
    </div>
  );
}

function NotificationItem({
  notification,
  index
}: {
  notification: NotificationData;
  index: number;
}) {
  const [isVisible, setIsVisible] = useState(false);
  const [isRemoving, setIsRemoving] = useState(false);

  useEffect(() => {
    // エントランスアニメーション
    const timer = setTimeout(() => setIsVisible(true), index * 100);
    return () => clearTimeout(timer);
  }, [index]);

  useEffect(() => {
    // 自動削除アニメーション
    const timer = setTimeout(() => {
      setIsRemoving(true);
    }, 4500);

    return () => clearTimeout(timer);
  }, []);

  const getNotificationStyle = () => {
    const baseStyle = {
      position: 'relative' as const,
      padding: '12px 16px',
      marginBottom: '8px',
      borderRadius: '8px',
      color: 'white',
      fontSize: '14px',
      transform: isVisible ? 'translateX(0)' : 'translateX(100%)',
      opacity: isRemoving ? 0 : (isVisible ? 1 : 0),
      transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
      overflow: 'hidden' as const,
      boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
    };

    const colors = {
      success: '#10b981',
      warning: '#f59e0b',
      error: '#ef4444',
      info: '#3b82f6'
    };

    return {
      ...baseStyle,
      backgroundColor: colors[notification.type]
    };
  };

  return (
    <div style={getNotificationStyle()}>
      <div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
        {notification.type.toUpperCase()}
      </div>
      <div>{notification.message}</div>

      {/* プログレスバー */}
      <div
        style={{
          position: 'absolute',
          bottom: 0,
          left: 0,
          height: '3px',
          backgroundColor: 'rgba(255, 255, 255, 0.3)',
          animation: 'shrink 5s linear forwards'
        }}
      />
    </div>
  );
}

// グローバルCSS(実際のプロジェクトでは別ファイルに)
const globalStyles = `
  @keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
  }

  @keyframes shrink {
    from { width: 100%; }
    to { width: 0%; }
  }

  @keyframes spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
  }
`;

## よく遭遇するエラーとその解決法

### Memory Leak エラー

Warning: 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. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

scss
**解決法:**

```typescript
useEffect(() => {
  const animation = requestAnimationFrame(animate);

  return () => {
    cancelAnimationFrame(animation); // 必ずクリーンアップ
  };
}, []);

Performance Warning

csharp[Violation] 'requestAnimationFrame' handler took 35ms
Warning: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.

解決法:

typescript// ❌ 悪い例:毎フレーム重い計算
useFrame(() => {
  const expensiveCalculation = heavyFunction(); // 重い処理
  setState(expensiveCalculation);
});

// ✅ 良い例:計算結果をメモ化
const memoizedValue = useMemo(
  () => heavyFunction(),
  [dependency]
);
useFrame(() => {
  setState(memoizedValue);
});

まとめ

JavaScript アニメーションは CSS では実現できない高度な表現力を持っており、React アプリケーションのユーザーエクスペリエンスを飛躍的に向上させることができます。

使い分けの指針

#アニメーション種類適用場面パフォーマンス実装難易度
1リアルタイム数値カウンターダッシュボード、統計表示⭐⭐⭐⭐⭐
2パーティクル爆発エフェクト成功フィードバック、ゲーム要素⭐⭐⭐⭐⭐
3インフィニットスクロール連動長いリスト、記事ページ⭐⭐⭐⭐⭐
4ドラッグ&ドロップ with 物理演算インタラクティブな操作⭐⭐⭐⭐⭐⭐
5状態変化に連動するモーフィングボタン、フォーム要素⭐⭐⭐⭐⭐
6マウス追従インタラクションヒーローセクション、プロダクト⭐⭐⭐⭐⭐
7タイムライン制御アニメーション複雑な演出、ストーリーテリング⭐⭐⭐⭐⭐⭐⭐
8データ可視化チャートアニメーション分析画面、レポート⭐⭐⭐⭐⭐⭐
9ジェスチャー認識アニメーションモバイルアプリ、タッチ UI⭐⭐⭐⭐⭐⭐⭐
10リアルタイム通信連動エフェクト通知システム、ライブ機能⭐⭐⭐⭐⭐⭐

ベストプラクティス

パフォーマンス最適化

  1. requestAnimationFrame の適切な使用

    • setInterval より requestAnimationFrame を使用
    • 不要なアニメーションは cancelAnimationFrame でキャンセル
  2. メモリリーク対策

    • useEffect のクリーンアップ関数で必ずリソース解放
    • イベントリスナーの適切な削除
  3. DOM 操作の最小化

    • transform プロパティを優先使用
    • layout を発生させるプロパティの変更を避ける

TypeScript 活用

typescript// 型安全なアニメーション設定
interface AnimationConfig {
  duration: number;
  easing: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
  delay?: number;
}

function createAnimation<T extends HTMLElement>(
  element: T,
  config: AnimationConfig
): Promise<void> {
  return new Promise((resolve) => {
    // アニメーション実装
  });
}

デバッグとテスト

typescript// アニメーション完了を検知するカスタムフック
function useAnimationEnd(ref: RefObject<HTMLElement>) {
  const [isAnimating, setIsAnimating] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleAnimationEnd = () => setIsAnimating(false);
    element.addEventListener(
      'animationend',
      handleAnimationEnd
    );

    return () => {
      element.removeEventListener(
        'animationend',
        handleAnimationEnd
      );
    };
  }, [ref]);

  return { isAnimating, setIsAnimating };
}

2025 年現在、Web ブラウザーのパフォーマンスは大幅に向上し、JavaScript アニメーションの可能性は無限大に広がっています。今回ご紹介した 10 の手法を組み合わせることで、ユーザーの期待を超える魅力的な Web アプリケーションを構築できるでしょう。

ぜひ実際のプロジェクトでこれらの技術を試してみて、React アプリケーションの表現力を向上させてくださいね。

関連リンク