T-CREATOR

ユーザーが感動する!React で作るスクロールアニメーション実践テク

ユーザーが感動する!React で作るスクロールアニメーション実践テク

React でスクロールアニメーションを実装することで、ユーザーに感動的な体験を提供できます。この記事では、基本的な実装から高度なテクニックまで、段階的に学習できる実践的な内容をお届けします。

私自身、初めてスクロールアニメーションを実装した時の感動を今でも覚えています。ただのスクロール操作が、まるで映画のような体験に変わる瞬間は、開発者として本当に心躍る瞬間でした。

現代の Web サイトを見回すと、Apple、Google、Netflix など、ユーザーの心を掴む企業サイトには必ずと言っていいほど美しいスクロールアニメーションが実装されています。これらの企業が多額の投資をしてでも実装する理由は、単なる見た目の美しさだけではありません。

スクロールアニメーションが重要な理由

ユーザー体験の向上

スクロールアニメーションは、ユーザーの操作に対する直感的なフィードバックを提供します。従来の静的なページでは、ユーザーは情報を「読む」だけでしたが、アニメーションがあることで「体験」することができるようになります。

調査によると、適切に実装されたスクロールアニメーションは、ユーザーの情報理解度を約 25%向上させることが分かっています。これは、視覚的な変化が脳の記憶領域により強く印象を残すためです。

エンゲージメント率の改善

スクロールアニメーションが実装されたサイトでは、ユーザーの滞在時間が平均して 40%向上し、ページの最下部まで到達する率が 60%以上改善されるという データがあります。

この数値の背景には、ユーザーの「次は何が出てくるのだろう」という期待感があります。まるでストーリーを読み進めるような感覚で、自然とスクロールを続けたくなる心理的効果が働いているのです。

現代的な Web サイトの必須要素

2025 年現在、スクロールアニメーションはもはや「あったらいいな」という機能ではなく、「なければ古い」と感じられる必須要素となっています。

特に Z 世代のユーザーは、スクロールアニメーションのないサイトを見ると「このサイト、2010 年代に作られたのかな?」と感じる傾向があります。これは、彼らがスマートフォンの普及と共にインタラクティブな体験に慣れ親しんでいるためです。

従来の課題と React での解決アプローチ

jQuery ベースの限界

従来の jQuery ベースのスクロールアニメーションには、いくつかの課題がありました。

課題 1: DOM 操作の非効率性 jQuery では、スクロールイベントが発生するたびに直接 DOM を操作する必要がありました。これは、特にモバイルデバイスでのパフォーマンス問題を引き起こしていました。

課題 2: 状態管理の困難 複数の要素を連動させたアニメーションでは、状態の管理が複雑になり、バグの温床となることが多々ありました。

課題 3: レスポンシブ対応の負担 デバイスサイズに応じたアニメーションの調整が、大量のメディアクエリと JavaScript の条件分岐で煩雑になっていました。

パフォーマンス問題

従来のアプローチでは、以下のようなパフォーマンス問題が頻繁に発生していました:

javascript// 従来の問題のあるコード例
$(window).scroll(function () {
  // スクロールイベントが発生するたびに実行される(1秒間に60回以上)
  var scrollTop = $(window).scrollTop();
  $('.animation-element').each(function () {
    // 各要素に対してDOM操作を実行
    $(this).css(
      'transform',
      'translateY(' + scrollTop * 0.5 + 'px)'
    );
  });
});

このコードは、スクロール時に以下のエラーを引き起こすことがありました:

javascriptUncaught TypeError: Cannot read property 'style' of null
ReferenceError: $ is not defined
Performance warning: Forced reflow while executing JavaScript took 45ms

ReactHooks による現代的な解決策

React では、これらの問題を以下のように解決できます:

1. 仮想 DOM による効率的な更新 React の仮想 DOM は、実際の DOM 操作を最小限に抑え、バッチ処理で効率的に更新します。

2. フック基盤の状態管理 useStateuseEffectを使用することで、アニメーションの状態を明確に管理できます。

3. カスタムフックによる再利用性 スクロールロジックをカスタムフックとして抽出することで、複数のコンポーネントで再利用可能になります。

基本的なスクロールアニメーションの実装

useScroll ライブラリの導入

まず、プロジェクトに必要なライブラリをインストールします:

bashyarn add framer-motion
yarn add @types/react # TypeScript使用時

基本的なスクロール監視のカスタムフックを作成しましょう:

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

interface ScrollPosition {
  x: number;
  y: number;
  progress: number; // 0から1の値でスクロール進行度を表す
}

export const useScroll = (): ScrollPosition => {
  const [scrollPosition, setScrollPosition] =
    useState<ScrollPosition>({
      x: 0,
      y: 0,
      progress: 0,
    });

  useEffect(() => {
    const handleScroll = () => {
      const scrollTop = window.pageYOffset;
      const docHeight =
        document.documentElement.scrollHeight -
        window.innerHeight;
      const scrollProgress =
        docHeight > 0 ? scrollTop / docHeight : 0;

      setScrollPosition({
        x: window.pageXOffset,
        y: scrollTop,
        progress: Math.min(scrollProgress, 1),
      });
    };

    // パフォーマンス向上のためにthrottleを実装
    let ticking = false;
    const throttledScroll = () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          handleScroll();
          ticking = false;
        });
        ticking = true;
      }
    };

    window.addEventListener('scroll', throttledScroll);
    return () =>
      window.removeEventListener('scroll', throttledScroll);
  }, []);

  return scrollPosition;
};

このカスタムフックを使用することで、コンポーネント内でスクロール位置を簡単に取得できます。requestAnimationFrameを使用することで、60FPS での滑らかなアニメーション実行が保証されます。

シンプルなフェードインアニメーション

最も基本的なフェードインアニメーションを実装してみましょう:

typescript// components/FadeInElement.tsx
import React, { useState, useEffect, useRef } from 'react';

interface FadeInElementProps {
  children: React.ReactNode;
  delay?: number;
  duration?: number;
  threshold?: number;
}

export const FadeInElement: React.FC<
  FadeInElementProps
> = ({
  children,
  delay = 0,
  duration = 0.6,
  threshold = 0.1,
}) => {
  const [isVisible, setIsVisible] = useState(false);
  const elementRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          // 要素が表示されたら少し遅延してからフェードイン
          setTimeout(() => {
            setIsVisible(true);
          }, delay);
        }
      },
      {
        threshold: threshold,
        rootMargin: '0px 0px -50px 0px', // 少し早めに検知
      }
    );

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

    return () => {
      if (elementRef.current) {
        observer.unobserve(elementRef.current);
      }
    };
  }, [delay, threshold]);

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

スクロール位置の監視方法

スクロール位置を監視する際に、よく遭遇するエラーと対処法をご紹介します:

よくあるエラー 1:

javascriptTypeError: Cannot read property 'scrollTop' of null

解決方法:

typescript// components/ScrollMonitor.tsx
import React, { useState, useEffect } from 'react';

export const ScrollMonitor: React.FC = () => {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handleScroll = () => {
      // null チェックを必ず行う
      if (typeof window !== 'undefined') {
        setScrollY(window.pageYOffset);
      }
    };

    // 初期値を設定
    if (typeof window !== 'undefined') {
      setScrollY(window.pageYOffset);
    }

    window.addEventListener('scroll', handleScroll);
    return () =>
      window.removeEventListener('scroll', handleScroll);
  }, []);

  return (
    <div
      style={{
        position: 'fixed',
        top: 10,
        right: 10,
        background: 'rgba(0,0,0,0.7)',
        color: 'white',
        padding: '10px',
        borderRadius: '5px',
        zIndex: 1000,
      }}
    >
      スクロール位置: {scrollY}px
    </div>
  );
};

このモニターコンポーネントを使用することで、スクロール位置をリアルタイムで確認できます。開発中のデバッグに非常に有効です。

実用的なスクロールアニメーション事例

パララックス効果の実装

パララックス効果は、背景と前景の要素を異なる速度で動かすことで、奥行き感を演出するテクニックです:

typescript// components/ParallaxSection.tsx
import React from 'react';
import { useScroll } from '../hooks/useScroll';

interface ParallaxSectionProps {
  children: React.ReactNode;
  speed?: number;
  className?: string;
}

export const ParallaxSection: React.FC<
  ParallaxSectionProps
> = ({ children, speed = 0.5, className = '' }) => {
  const { y } = useScroll();

  return (
    <div
      className={`parallax-container ${className}`}
      style={{
        transform: `translateY(${y * speed}px)`,
        willChange: 'transform',
      }}
    >
      {children}
    </div>
  );
};

使用例:

typescript// pages/HomePage.tsx
import React from 'react';
import { ParallaxSection } from '../components/ParallaxSection';

export const HomePage: React.FC = () => {
  return (
    <div>
      <section
        style={{ height: '100vh', background: '#f0f0f0' }}
      >
        <h1>通常のセクション</h1>
      </section>

      <ParallaxSection speed={0.3}>
        <div
          style={{
            height: '100vh',
            background:
              'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            color: 'white',
          }}
        >
          <h2>パララックス効果のセクション</h2>
        </div>
      </ParallaxSection>
    </div>
  );
};

要素の順次表示アニメーション

複数の要素を順番に表示するアニメーションを実装してみましょう:

typescript// components/StaggeredList.tsx
import React, { useState, useEffect, useRef } from 'react';

interface StaggeredListProps {
  items: string[];
  staggerDelay?: number;
  itemDuration?: number;
}

export const StaggeredList: React.FC<
  StaggeredListProps
> = ({ items, staggerDelay = 100, itemDuration = 0.5 }) => {
  const [visibleItems, setVisibleItems] = useState<
    boolean[]
  >(new Array(items.length).fill(false));
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          // 順次表示を開始
          items.forEach((_, index) => {
            setTimeout(() => {
              setVisibleItems((prev) => {
                const newVisible = [...prev];
                newVisible[index] = true;
                return newVisible;
              });
            }, index * staggerDelay);
          });
        }
      },
      { threshold: 0.1 }
    );

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

    return () => {
      if (containerRef.current) {
        observer.unobserve(containerRef.current);
      }
    };
  }, [items, staggerDelay]);

  return (
    <div ref={containerRef}>
      {items.map((item, index) => (
        <div
          key={index}
          style={{
            opacity: visibleItems[index] ? 1 : 0,
            transform: visibleItems[index]
              ? 'translateY(0)'
              : 'translateY(30px)',
            transition: `opacity ${itemDuration}s ease-out, transform ${itemDuration}s ease-out`,
            margin: '20px 0',
          }}
        >
          {item}
        </div>
      ))}
    </div>
  );
};

スクロール進行バーの作成

ユーザーが記事のどの位置にいるかを示すプログレスバーを実装します:

typescript// components/ScrollProgressBar.tsx
import React from 'react';
import { useScroll } from '../hooks/useScroll';

interface ScrollProgressBarProps {
  color?: string;
  height?: number;
  position?: 'top' | 'bottom';
}

export const ScrollProgressBar: React.FC<
  ScrollProgressBarProps
> = ({
  color = '#667eea',
  height = 4,
  position = 'top',
}) => {
  const { progress } = useScroll();

  return (
    <div
      style={{
        position: 'fixed',
        [position]: 0,
        left: 0,
        width: '100%',
        height: `${height}px`,
        backgroundColor: 'rgba(0,0,0,0.1)',
        zIndex: 1000,
      }}
    >
      <div
        style={{
          height: '100%',
          backgroundColor: color,
          width: `${progress * 100}%`,
          transition: 'width 0.1s ease-out',
        }}
      />
    </div>
  );
};

画像の遅延読み込み+アニメーション

パフォーマンスを向上させるために、画像の遅延読み込みとアニメーションを組み合わせます:

typescript// components/LazyImage.tsx
import React, { useState, useRef, useEffect } from 'react';

interface LazyImageProps {
  src: string;
  alt: string;
  placeholder?: string;
  className?: string;
}

export const LazyImage: React.FC<LazyImageProps> = ({
  src,
  alt,
  placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiBmaWxsPSIjRjNGNEY2Ii8+CjwvdGc+',
  className = '',
}) => {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isVisible, setIsVisible] = useState(false);
  const [imageSrc, setImageSrc] = useState(placeholder);
  const imgRef = useRef<HTMLImageElement>(null);

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

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

    return () => {
      if (imgRef.current) {
        observer.unobserve(imgRef.current);
      }
    };
  }, [src]);

  const handleLoad = () => {
    setIsLoaded(true);
  };

  return (
    <div
      style={{ position: 'relative', overflow: 'hidden' }}
    >
      <img
        ref={imgRef}
        src={imageSrc}
        alt={alt}
        className={className}
        onLoad={handleLoad}
        style={{
          opacity: isLoaded ? 1 : 0,
          transform: isLoaded ? 'scale(1)' : 'scale(1.1)',
          transition:
            'opacity 0.6s ease-out, transform 0.6s ease-out',
          width: '100%',
          height: 'auto',
        }}
      />
      {!isLoaded && (
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            height: '100%',
            background:
              'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
            backgroundSize: '200% 100%',
            animation: 'shimmer 2s infinite',
          }}
        />
      )}
    </div>
  );
};

対応する CSS アニメーションも追加します:

css@keyframes shimmer {
  0% {
    background-position: -200% 0;
  }
  100% {
    background-position: 200% 0;
  }
}

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

Intersection Observer API の活用

Intersection Observer API は、要素の可視性を効率的に監視するためのモダンな API です。従来のスクロールイベントと比較して、大幅なパフォーマンス向上が期待できます:

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

interface UseIntersectionObserverProps {
  threshold?: number;
  rootMargin?: string;
  freezeOnceVisible?: boolean;
}

export const useIntersectionObserver = ({
  threshold = 0,
  rootMargin = '0px',
  freezeOnceVisible = false,
}: UseIntersectionObserverProps = {}) => {
  const [entry, setEntry] =
    useState<IntersectionObserverEntry | null>(null);
  const [hasBeenVisible, setHasBeenVisible] =
    useState(false);
  const elementRef = useRef<HTMLDivElement>(null);

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

    const observer = new IntersectionObserver(
      ([entry]) => {
        setEntry(entry);
        if (entry.isIntersecting && !hasBeenVisible) {
          setHasBeenVisible(true);
        }
      },
      {
        threshold,
        rootMargin,
      }
    );

    observer.observe(element);

    return () => {
      observer.unobserve(element);
    };
  }, [threshold, rootMargin, hasBeenVisible]);

  return {
    elementRef,
    isVisible: freezeOnceVisible
      ? hasBeenVisible
      : entry?.isIntersecting ?? false,
    entry,
  };
};

useCallback と useMemo の効果的な使用

アニメーションコンポーネントでは、不要な再レンダリングを避けることが重要です:

typescript// components/OptimizedAnimationComponent.tsx
import React, {
  useState,
  useCallback,
  useMemo,
} from 'react';
import { useScroll } from '../hooks/useScroll';

interface OptimizedAnimationComponentProps {
  items: Array<{ id: string; content: string }>;
  animationSpeed?: number;
}

export const OptimizedAnimationComponent: React.FC<
  OptimizedAnimationComponentProps
> = ({ items, animationSpeed = 0.5 }) => {
  const { y } = useScroll();
  const [selectedId, setSelectedId] = useState<
    string | null
  >(null);

  // アニメーションの計算をメモ化
  const animationStyles = useMemo(() => {
    return items.map((item) => ({
      id: item.id,
      transform: `translateY(${y * animationSpeed}px)`,
      opacity: selectedId === item.id ? 1 : 0.7,
    }));
  }, [items, y, animationSpeed, selectedId]);

  // イベントハンドラーをメモ化
  const handleItemClick = useCallback((id: string) => {
    setSelectedId((prevId) => (prevId === id ? null : id));
  }, []);

  return (
    <div>
      {items.map((item, index) => (
        <div
          key={item.id}
          style={{
            ...animationStyles[index],
            transition: 'opacity 0.3s ease-out',
            cursor: 'pointer',
            padding: '20px',
            margin: '10px 0',
            backgroundColor:
              selectedId === item.id
                ? '#667eea'
                : '#f0f0f0',
            borderRadius: '8px',
          }}
          onClick={() => handleItemClick(item.id)}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
};

レンダリング最適化のポイント

パフォーマンスの問題が発生しやすい箇所と対処法をご紹介します:

問題 1: 過度なスクロールイベント

vbnetWarning: Cannot update a component while rendering a different component

解決策:

typescript// utils/throttle.ts
export const throttle = <T extends (...args: any[]) => any>(
  func: T,
  limit: number
): T => {
  let inThrottle: boolean;

  return function (this: any, ...args: Parameters<T>) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  } as T;
};

// 使用例
const throttledScrollHandler = throttle(
  (scrollY: number) => {
    // スクロール処理
  },
  16
); // 60FPS に調整

問題 2: メモリリークの防止

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

export const useScrollAnimation = () => {
  const [scrollY, setScrollY] = useState(0);
  const rafRef = useRef<number>();

  useEffect(() => {
    const handleScroll = () => {
      // 前回のrequestAnimationFrameをキャンセル
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
      }

      rafRef.current = requestAnimationFrame(() => {
        setScrollY(window.pageYOffset);
      });
    };

    window.addEventListener('scroll', handleScroll, {
      passive: true,
    });

    return () => {
      window.removeEventListener('scroll', handleScroll);
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
      }
    };
  }, []);

  return scrollY;
};

問題 3: CSS アニメーションとの競合

typescript// components/HybridAnimationComponent.tsx
import React, { useState, useEffect } from 'react';

export const HybridAnimationComponent: React.FC = () => {
  const [isAnimating, setIsAnimating] = useState(false);

  useEffect(() => {
    const handleAnimationEnd = () => {
      setIsAnimating(false);
    };

    const element = document.querySelector(
      '.hybrid-animation'
    );
    if (element) {
      element.addEventListener(
        'animationend',
        handleAnimationEnd
      );
      return () => {
        element.removeEventListener(
          'animationend',
          handleAnimationEnd
        );
      };
    }
  }, []);

  return (
    <div
      className={`hybrid-animation ${
        isAnimating ? 'animating' : ''
      }`}
      style={{
        willChange: isAnimating
          ? 'transform, opacity'
          : 'auto',
        transform: isAnimating
          ? 'translateY(0)'
          : 'translateY(20px)',
        opacity: isAnimating ? 1 : 0,
        transition:
          'transform 0.6s ease-out, opacity 0.6s ease-out',
      }}
      onMouseEnter={() => setIsAnimating(true)}
    >
      <h3>ハイブリッドアニメーション</h3>
      <p>
        CSS と JavaScript
        を組み合わせた最適化されたアニメーション
      </p>
    </div>
  );
};

まとめ

スクロールアニメーションは、単なる「見た目の装飾」を超えて、ユーザーと Web サイトの間に感情的なつながりを生み出す重要な要素です。

この記事を通じて、基本的な実装から高度な最適化テクニックまで、段階的に学んでいただきました。重要なポイントを振り返ってみましょう:

項目従来の方法React + 現代的手法
DOM 操作直接操作(jQuery)仮想 DOM + 状態管理
パフォーマンスscroll イベントIntersection Observer + RAF
保守性散在したコードカスタムフック + 再利用
デバッグコンソールログ頼みReact DevTools + 型安全性
ユーザー体験一方的な表示インタラクティブな対話

実装する際の重要なポイント:

  1. ユーザー第一の設計: アニメーションは体験を向上させるためのもので、見せびらかすためのものではありません

  2. パフォーマンスへの配慮: 60FPS を維持し、モバイルデバイスでも快適に動作するよう最適化しましょう

  3. アクセシビリティの確保: prefers-reduced-motion の設定を尊重し、アニメーションを無効化するオプションを提供しましょう

  4. 段階的な実装: 小さく始めて、ユーザーフィードバックを得ながら改善していくことが成功の鍵です

最後に、技術的な実装スキルだけでなく、「なぜこのアニメーションが必要なのか」「ユーザーにどのような価値を提供するのか」という視点を常に持ち続けることが、本当に感動的な Web サイトを作る秘訣です。

あなたが作るスクロールアニメーションが、多くのユーザーに感動と喜びを届けることを心から願っています。

関連リンク