React + GSAPで4方向の無限スクロールギャラリーを60fpsで動かす

アニメーションで60fpsを維持することの難しさ

Webアニメーションには「動けばいい」レベルと「滑らかに動く」レベルの間に、大きな壁がある。ユーザーが意識する前に感じとる不快感——それがフレームドロップだ。特にスクロールギャラリーのようにコンテンツが連続して動くケースでは、60fpsを維持できるかどうかが体験の品質を決める。

今回のプロジェクトは、4方向(上下左右)に無限スクロールするギャラリーを React と GSAP で実装することだった。単純に CSS animation で組むと、大量の DOM 要素が動くときにレイアウトスラッシングが起きて性能が落ちる。GSAPはそこを解決するために生まれたようなライブラリだ。

プロジェクト構成

src/
├── components/
│   ├── InfiniteGallery.jsx     # メインコンポーネント
│   ├── GalleryTrack.jsx        # 1方向のスクロールトラック
│   └── GalleryItem.jsx         # 各画像カード
├── hooks/
│   └── useInfiniteScroll.js    # GSAPアニメーションの制御
└── utils/
    └── duplicateItems.js       # 無限ループ用配列複製

依存パッケージは gsapreact だけ。GSAP はバンドルサイズが気になるところだが、コアのみ使用すれば約30KBに抑えられる。

無限スクロールのアルゴリズム

「無限に流れる」動きを作る最もシンプルな方法は、配列を複製してつなげることだ。

// utils/duplicateItems.js
export function duplicateItems(items, count = 3) {
  const result = [];
  for (let i = 0; i < count; i++) {
    result.push(...items);
  }
  return result;
}

元の配列を3倍に複製し、中央部分をループさせる。GSAPで端に達したら瞬時に中央に戻す——これをユーザーに気づかれないようにやるのがポイントだ。

GalleryTrack コンポーネントの設計

1本のトラック(行)を担当するコンポーネントを作る。

// components/GalleryTrack.jsx
import { useRef, useEffect } from 'react';
import { gsap } from 'gsap';
import GalleryItem from './GalleryItem';
import { duplicateItems } from '../utils/duplicateItems';

function GalleryTrack({ items, direction = 'left', speed = 1 }) {
  const trackRef = useRef(null);
  const tweenRef = useRef(null);

  const duplicated = duplicateItems(items, 3);

  useEffect(() => {
    const track = trackRef.current;
    if (!track) return;

    // アイテム1セット分の幅を計算
    const itemWidth = track.children[0]?.offsetWidth + 16; // gap含む
    const singleSetWidth = items.length * itemWidth;

    const xTarget = direction === 'left'
      ? `-=${singleSetWidth}`
      : `+=${singleSetWidth}`;

    tweenRef.current = gsap.to(track, {
      x: xTarget,
      duration: singleSetWidth / (100 * speed),
      ease: 'none',
      repeat: -1,
      modifiers: {
        x: gsap.utils.unitize(function (x) {
          return parseFloat(x) % singleSetWidth;
        }),
      },
    });

    return () => {
      tweenRef.current?.kill();
    };
  }, [items, direction, speed]);

  return (
    
{duplicated.map((item, index) => ( ))}
); } export default GalleryTrack;

gsap.utils.unitizemodifiers.x を組み合わせることで、座標が一定範囲を超えたら折り返す動きを実現している。これがシームレスループの核心だ。

4方向のレイアウト

InfiniteGallery コンポーネントで4方向を配置する。

// components/InfiniteGallery.jsx
import GalleryTrack from './GalleryTrack';

function InfiniteGallery({ images }) {
  // 4行に分割
  const chunk = Math.ceil(images.length / 4);
  const rows = [
    images.slice(0, chunk),
    images.slice(chunk, chunk * 2),
    images.slice(chunk * 2, chunk * 3),
    images.slice(chunk * 3),
  ];

  const configs = [
    { direction: 'left',  speed: 1.0 },
    { direction: 'right', speed: 0.8 },
    { direction: 'left',  speed: 1.2 },
    { direction: 'right', speed: 0.9 },
  ];

  return (
    
{rows.map((row, i) => ( ))}
); } export default InfiniteGallery;

速度をわずかにずらすことで、単調に見えず、立体感が生まれる。デザイナーからの要望で速度差を設けたのだが、見栄えが格段に良くなった。

ホバーで一時停止する

ギャラリーをただ流すだけでなく、ホバーしたときにアニメーションを一時停止する機能を加えた。

// hooks/useInfiniteScroll.js
import { useCallback } from 'react';

export function useTrackPause(tweenRef) {
  const handleMouseEnter = useCallback(() => {
    tweenRef.current?.pause();
  }, [tweenRef]);

  const handleMouseLeave = useCallback(() => {
    tweenRef.current?.play();
  }, [tweenRef]);

  return { handleMouseEnter, handleMouseLeave };
}

GSAP の tween.pause()tween.play() はすぐ使える。React の useRef でアニメーションインスタンスを保持しておき、イベントハンドラから操作する。

// GalleryTrack.jsx に追加
const { handleMouseEnter, handleMouseLeave } = useTrackPause(tweenRef);

return (
  
...
);

パフォーマンスのチューニング

実装当初、Lighthouse のパフォーマンススコアが65点ほどだった。原因を調べると3つあった。

1. will-change の適用

GSAPが transform を使って動かしているため、GPU レイヤーへの昇格を明示的に指定する。

.gallery-track {
  will-change: transform;
}

ただし will-change: transform をすべての要素に付けるとメモリ消費が増える。アニメーション中の要素だけに絞ることが重要だ。

2. 画像の遅延読み込み

function GalleryItem({ item }) {
  return (
    
{item.alt}
); }

loading="lazy"decoding="async" で初期読み込みを分散させる。widthheight を明示することで CLS(レイアウトシフト)も防げる。

3. ResizeObserver でトラック幅を再計算

ウィンドウリサイズ時に itemWidth が変わるため、アニメーションを再起動する必要がある。

useEffect(() => {
  const resizeObserver = new ResizeObserver(() => {
    tweenRef.current?.kill();
    // アニメーション再初期化
    initAnimation();
  });

  resizeObserver.observe(trackRef.current);

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

これらの修正後、Lighthouse スコアは92点まで改善した。

GSAPを使うことで得られたもの

CSSアニメーションとの最大の違いは、JavaScript から精密に制御できることだ。

  • `pause()` / `play()` / `reverse()` が即座に効く
  • `timeScale()` でアニメーション速度をリアルタイム変更できる
  • タイムラインで複数アニメーションを連鎖させられる
  • 今回は使わなかったが、GSAP の ScrollTrigger プラグインを使えば、ユーザーのスクロール量と連動させることもできる。「スクロールに合わせて画像が流れる速度が変わる」といった演出だ。

    学んだこと

    このプロジェクトで一番時間をかけたのは、シームレスループの座標計算だった。要素幅が可変になると計算が複雑になる。gsap.utils.unitize のドキュメントをかなり読み込んだ。

    60fpsを維持するための3原則は「transform で動かす」「will-change でGPUに委ねる」「不要な再レンダリングを防ぐ」だと実感した。React の場合、useRef でアニメーションインスタンスを管理することで、状態変化による再レンダリングを避けられる。

    ギャラリー系UIは一見シンプルに見えて、気持ちよく動かすには細かい調整の積み重ねが必要だと改めて思った。