React, Vue.js
投稿日:

“動かすけど軽い” FLIPアニメーション設計 ─ React/Vueで60fpsを維持する実装法

動かすけど軽い FLIPアニメーション設計 ─ ReactVueで60fpsを維持する実装法

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

先日、休日にバイクでいつもと違うルートを走ってみたんです。ナビを見ながら交差点で右折するか左折するか判断するとき、地図上のピンがスッと移動してくれると次のアクションが直感的にわかる。ところが古いナビアプリだと、ピンが一瞬消えて別の場所にパッと現れる。たったそれだけの違いで、走行中の安心感がまるで変わります。

……バイクのナビはスマホホルダーに付けたiPhoneなんですが、振動で画面が揺れるのでアニメーションどころじゃないときもあります。

Web UIでも同じことが言えます。カードの並び替え、フィルタ後のリスト再配置、ドラッグ&ドロップ——要素の位置が変わるUIは多いのに、「パッと消えてパッと出る」実装になっているケースは珍しくありません。動きを付ければ体感は良くなる。しかし安易にアニメーションライブラリを入れると、バンドルサイズが膨らみ、要素数が増えたときにフレーム落ちが起きます。

そこで有効なのがFLIP(First, Last, Invert, Play)というアニメーション設計手法です。外部ライブラリなしで60fpsを維持しながら、要素の位置変化を滑らかに見せられます。今回はFLIPの考え方と、ReactおよびVueでの実装方法を整理します。

FLIPとは何か——4ステップで理解する

FLIPはGoogleのPaul Lewisが提唱した手法で、名前の通り4つのステップで構成されます。考え方自体はシンプルです。

  • First:アニメーション前の要素の位置・サイズをgetBoundingClientRect()で記録する
  • Last:DOMを更新し、アニメーション後の位置・サイズを同様に記録する
  • Invert:LastとFirstの差分を計算し、transformで「元の位置に見えるよう」ずらす
  • Playtransformを0に戻すトランジションを実行し、要素が新しい位置へ滑らかに移動する

ポイントは、topleftではなくtransformだけでアニメーションしている点です。transformはレイアウト再計算を引き起こさず、GPUで合成処理されるため、要素数が多くても描画コストが低く抑えられます。MDNの「CSS アニメーションのパフォーマンス」でも、transformopacityはレイアウトやペイントをトリガーしないプロパティとして推奨されています。

なぜtop/leftアニメーションでは足りないのか

要素の位置を動かすアニメーションで、最もやりがちな失敗がtop/leftプロパティの直接アニメーションです。

私たちの開発現場でも、あるECサイトの商品一覧ページで「お気に入り順に並び替え」機能を実装した際にこの問題に直面しました。200件以上のカードをposition: absolutetop/leftでアニメーションさせたところ、並び替えのたびにフレームレートが15fps前後まで落ち込んだのです。

原因は明確でした。top/leftの変更はブラウザのレンダリングパイプラインで「レイアウト→ペイント→合成」の全工程を毎フレーム再実行させます。要素が200件あれば、毎フレーム200回のレイアウト再計算が走る。Chrome DevToolsのPerformanceパネルで確認すると、Layout工程だけで1フレームあたり40ms以上かかっていました。16ms以内に収めなければ60fpsは維持できません。

FLIPでtransformに切り替えた結果、同じ200件のカードでもフレーム時間は8〜12msに収まり、60fpsを安定して維持できるようになりました。

FLIPの基本実装——Vanilla JavaScript版

フレームワークに依存しない素のJavaScriptで、FLIPの動きを確認しましょう。まずはこの基本形を理解しておくと、ReactやVueへの応用がスムーズです。

// FLIP基本実装
function flipAnimate(element, updateFn) {
  // 1. First: 現在の位置を記録
  const first = element.getBoundingClientRect();

  // 2. Last: DOMを更新(並び替えなど)
  updateFn();
  const last = element.getBoundingClientRect();

  // 3. Invert: 差分をtransformで打ち消す
  const deltaX = first.left - last.left;
  const deltaY = first.top - last.top;

  element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
  element.style.transition = 'none';

  // 4. Play: 次フレームでtransformを解除し、トランジション実行
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      element.style.transition = 'transform 0.3s ease';
      element.style.transform = '';

      // クリーンアップ
      element.addEventListener('transitionend', () => {
        element.style.transition = '';
      }, { once: true });
    });
  });
}

requestAnimationFrameを2重にネストしているのは、ブラウザがInvertの状態を確実に描画してからPlayに移行するためです。1重だとブラウザの最適化によりInvertとPlayが同フレームで処理され、アニメーションが見えないことがあります。

複数要素を一括でFLIPさせる

実際のプロダクトではカード一覧のように複数要素を同時に動かします。各要素のrectを一括取得してからDOMを更新し、一括でInvert→Playする流れです。

function flipAnimateAll(container, updateFn) {
  const children = Array.from(container.children);

  // First: 全要素の位置を記録
  const firstRects = new Map();
  children.forEach(child => {
    firstRects.set(child, child.getBoundingClientRect());
  });

  // Last: DOM更新
  updateFn();

  // Invert + Play
  children.forEach(child => {
    const first = firstRects.get(child);
    if (!first) return;
    const last = child.getBoundingClientRect();
    const deltaX = first.left - last.left;
    const deltaY = first.top - last.top;

    if (deltaX === 0 && deltaY === 0) return;

    child.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
    child.style.transition = 'none';
    child.style.willChange = 'transform';
  });

  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      children.forEach(child => {
        child.style.transition = 'transform 0.3s ease';
        child.style.transform = '';
        child.addEventListener('transitionend', () => {
          child.style.transition = '';
          child.style.willChange = '';
        }, { once: true });
      });
    });
  });
}

React実装:useLayoutEffect + key管理

ReactでFLIPを実装する場合、useLayoutEffectが要になります。このフックはDOMの更新後、ブラウザが画面を描画する前に実行されるため、InvertのタイミングをFLIPの要件どおりに制御できます。

以下はカード並び替えの実装例です。なお、本記事のJSXコード例はReact 18以降を前提としています。

// hooks/useFlip.ts
import { useRef, useLayoutEffect, useCallback } from 'react';

type Rect = { left: number; top: number };

export function useFlip(deps: unknown[]) {
  const rectsRef = useRef<Map<string, Rect>>(new Map());
  const containerRef = useRef<HTMLDivElement>(null);

  const capturePositions = useCallback(() => {
    if (!containerRef.current) return;
    const map = new Map<string, Rect>();
    Array.from(containerRef.current.children).forEach((child) => {
      const key = (child as HTMLElement).dataset.flipKey;
      if (key) {
        const rect = child.getBoundingClientRect();
        map.set(key, { left: rect.left, top: rect.top });
      }
    });
    rectsRef.current = map;
  }, []);

  useLayoutEffect(() => {
    if (!containerRef.current) return;
    const prevRects = rectsRef.current;

    Array.from(containerRef.current.children).forEach((child) => {
      const el = child as HTMLElement;
      const key = el.dataset.flipKey;
      if (!key) return;

      const prev = prevRects.get(key);
      if (!prev) return;

      const curr = el.getBoundingClientRect();
      const deltaX = prev.left - curr.left;
      const deltaY = prev.top - curr.top;

      if (deltaX === 0 && deltaY === 0) return;

      el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
      el.style.transition = 'none';

      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          el.style.transition = 'transform 0.3s ease';
          el.style.transform = '';
          el.addEventListener('transitionend', () => {
            el.style.transition = '';
          }, { once: true });
        });
      });
    });
  }, deps);

  return { containerRef, capturePositions };
}

このカスタムフックを使ったコンポーネントは次のようになります。

// components/SortableCards.tsx
import { useState } from 'react';
import { useFlip } from '../hooks/useFlip';

type Card = { id: string; title: string; order: number };

export function SortableCards({ initialCards }: { initialCards: Card[] }) {
  const [cards, setCards] = useState(initialCards);
  const { containerRef, capturePositions } = useFlip([cards]);

  const shuffleCards = () => {
    capturePositions();
    setCards(prev => [...prev].sort(() => Math.random() - 0.5));
  };

  const sortByOrder = () => {
    capturePositions();
    setCards(prev => [...prev].sort((a, b) => a.order - b.order));
  };

  return (
    <div>
      <button onClick={shuffleCards}>シャッフル</button>
      <button onClick={sortByOrder}>順番に並べる</button>
      <div
        ref={containerRef}
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(3, 1fr)',
          gap: '16px',
          contain: 'layout',
        }}
      >
        {cards.map(card => (
          <div
            key={card.id}
            data-flip-key={card.id}
            style={{
              padding: '24px',
              background: '#f3f4f6',
              borderRadius: '8px',
            }}
          >
            {card.title}
          </div>
        ))}
      </div>
    </div>
  );
}

Reactで注意すべき点はkeyの管理です。keyが変わるとReactはDOM要素を破棄して再生成するため、FLIPに必要な「同じDOM要素の位置比較」ができなくなります。並び替え前後で同一のIDをkeyに渡すことで、Reactが要素を再利用し、FLIPが正しく動作します。

Vue実装:TransitionGroupの最適化

VueにはFLIPを内部的に使った<TransitionGroup>コンポーネントが標準で用意されています。move-classを指定するだけで、要素の並び替えアニメーションを実現できます。Vue公式ドキュメントの「TransitionGroup」セクションに詳しい解説があります。

<!-- components/SortableCards.vue -->
<script setup lang="ts">
import { ref } from 'vue';

type Card = { id: string; title: string; order: number };

const props = defineProps<{ initialCards: Card[] }>();
const cards = ref([...props.initialCards]);

function shuffleCards() {
  cards.value = [...cards.value].sort(() => Math.random() - 0.5);
}

function sortByOrder() {
  cards.value = [...cards.value].sort((a, b) => a.order - b.order);
}
</script>

<template>
  <div>
    <button @click="shuffleCards">シャッフル</button>
    <button @click="sortByOrder">順番に並べる</button>
    <TransitionGroup
      name="flip-list"
      tag="div"
      class="card-grid"
    >
      <div
        v-for="card in cards"
        :key="card.id"
        class="card"
      >
        {{ card.title }}
      </div>
    </TransitionGroup>
  </div>
</template>

<style scoped>
.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  contain: layout;
}

.card {
  padding: 24px;
  background: #f3f4f6;
  border-radius: 8px;
}

.flip-list-move {
  transition: transform 0.3s ease;
}

.flip-list-leave-active {
  position: absolute;
  opacity: 0;
}
</style>

Vueの場合、.flip-list-moveクラスを定義するだけでFLIPアニメーションが有効になります。内部的にはVueがgetBoundingClientRect()で差分を計算し、transformでアニメーションを実行しています。Reactに比べて記述量が少なく、フレームワーク側がFLIPのライフサイクルを管理してくれるのは大きな利点です。

パフォーマンスを維持するための3つの対策

FLIPを導入するだけでtransformベースのアニメーションにはなりますが、実プロダクトで安定した60fpsを保つにはもう少し配慮が必要です。

will-changeとcontainでレイアウト影響を局所化する

will-change: transformはブラウザに対して「この要素はtransformが変わる予定がある」と事前に伝えるプロパティです。ブラウザはこの要素を独立したレイヤーに昇格させ、合成処理を最適化します。

.card {
  will-change: transform;
}

.card-grid {
  contain: layout;
}

contain: layoutは、コンテナ内のレイアウト変更が外側に波及しないことをブラウザに宣言します。カード一覧の親要素に指定しておくと、並び替え時のレイアウト再計算の範囲がコンテナ内に限定されます。

ただし、will-changeを常時指定するとメモリを余分に消費します。アニメーション開始前にセットし、transitionendで解除するのが理想です。要素数が50件未満であれば常時指定でも問題ないケースが多いですが、数百件規模では動的な付け外しを検討してください。

アニメーション終了後のクリーンアップ

FLIPで設定したtransitionwill-changeをアニメーション終了後に放置すると、次の並び替え操作で意図しない動きが発生する原因になります。

element.addEventListener('transitionend', (e) => {
  // transform以外のプロパティのtransitionendを無視
  if (e.propertyName !== 'transform') return;
  element.style.transition = '';
  element.style.willChange = '';
}, { once: true });

{ once: true }を付けてリスナーの自動解除を行い、メモリリークも防ぎます。

画像・フォント読み込み中のrectズレを防ぐ

FLIPの大きな落とし穴の一つが、getBoundingClientRect()を呼んだタイミングで画像やWebフォントが読み込み途中だったケースです。画像の高さが確定していない状態でrectを取得すると、正しい位置が記録されず、アニメーション時にカードが飛び跳ねるようなガタつきが起きます。

対策として有効なのは以下の2つです。

  • imgタグにwidthheightを明示し、CSSのaspect-ratioでレイアウトシフトを防ぐ
  • Webフォントにはfont-display: swapを指定し、テキストのリフローを予測可能にする

私たちのプロジェクトでは、商品画像にアスペクト比を固定した結果、FLIP中のガタつきが完全に解消しました。

prefers-reduced-motionへの対応

モーション嫌うユーザーへの配慮は、アクセシビリティの観点で必須です。前庭障害のある方にとって、画面上の動きは吐き気やめまいの原因になります。W3CのWCAG 2.1「2.3.3 Animation from Interactions」(Level AAA)でも、ユーザーがアニメーションを無効化できる手段を提供することが規定されています。Level AAAは最高レベルの基準ですが、prefers-reduced-motionへの対応は実装コストが低いため、積極的に取り入れたいところです。

CSSとJavaScriptの両方で対応しておきましょう。

/* CSSでトランジションを無効化 */
@media (prefers-reduced-motion: reduce) {
  .flip-list-move,
  .card {
    transition: none;
  }
}
// JavaScriptでFLIPそのものをスキップ
function shouldAnimate(): boolean {
  return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

// useFlipフック内で使用
if (!shouldAnimate()) {
  return;
}

prefers-reduced-motion: reduceが有効な環境では、FLIPの処理自体をスキップしてDOMの即時更新に切り替えます。アニメーションなしでも機能は同じなので、ユーザー体験を損ないません。

品質を保つためのチェック項目

FLIPを導入した後、リリース前に確認しておくべきポイントを整理します。パフォーマンスだけでなく、アクセシビリティやエッジケースも見落とさないことが重要です。

パフォーマンスKPI

Chrome DevToolsのPerformanceパネルで以下の数値を確認してください。

指標目標値確認方法
フレーム時間16ms以内Performance → Frames
レイアウト再計算回数Playフェーズ中は0回Performance → Bottom-Up → Layout
CPU使用率アニメーション中50%以下Performance → Summary

アクセシビリティとエッジケース

パフォーマンスの数値が良くても、以下の確認を怠るとユーザーからの問い合わせにつながります。

  • フォーカス中の要素が移動しても、キーボード操作が継続できるか——transformはフォーカス位置に影響しないため通常は問題ありませんが、position: absoluteを併用している場合はフォーカスが飛ぶことがあります
  • スクリーンリーダーで読み上げ順序が破綻しないか——FLIPはDOMの順序自体を変えるため、視覚的な並び順とDOM順序は一致します。ただしaria-liveリージョン内で頻繁に並び替えると読み上げが混乱するため、aria-relevant="additions removals"の設定を検討してください
  • 画像やフォントの読み込み中でもアニメーションがガタつかないか——前述のaspect-ratio固定とfont-display: swapで対処します

FLIP導入の判断基準

ここまでFLIPの実装方法を見てきましたが、すべてのアニメーションにFLIPが必要なわけではありません。導入を検討すべきケースと、不要なケースを整理します。

状況FLIPが有効別の方法が適切
要素の並び替え・再配置
カードソート、フィルタ後のリスト更新
ドラッグ&ドロップの並び替え
単純なフェードイン・フェードアウトCSSのopacityで十分
ホバー時の拡大・回転CSS transitionで十分
スクロール連動アニメーションScroll-driven Animations APIが適切

判断のポイントは「要素の位置がレイアウト上で変わるかどうか」です。位置が変わるならFLIP、見た目の装飾だけならCSSの単純なトランジションで対応する。この切り分けを明確にしておくと、実装方針の議論がスムーズになります。

まとめ

FLIPは「要素の位置変化を滑らかに見せたいが、パフォーマンスは落としたくない」という要件に対する、シンプルかつ効果的な解法です。First→Last→Invert→Playの4ステップで、transformだけを使ったGPU合成ベースのアニメーションを実現します。

実装の選択肢としては、ReactではuseLayoutEffectとkey管理の組み合わせ、VueではTransitionGroupmove-classが定番です。どちらもFLIPの原理を理解していれば、外部ライブラリなしで導入できます。

導入時に押さえておきたい点を3つに絞ると、以下のとおりです。

  • top/leftではなくtransformでアニメーションし、レイアウト再計算を回避する
  • will-changecontainでレイアウト影響を局所化し、アニメーション後にクリーンアップする
  • prefers-reduced-motionでモーションを嫌うユーザーへの配慮を忘れない

私たちのプロジェクトでは、ECサイトの200件以上のカード並び替えで、フレーム時間が40ms超から8〜12msに改善し、安定した60fpsを維持できるようになりました。バンドルサイズを増やさずに体感速度を上げられるのは、ディレクターやPMの方にとっても提案しやすい選択肢だと思います。

株式会社ファストコーディングでは、こうしたアニメーション設計やフロントエンドのパフォーマンス改善に日々取り組んでいます。「並び替えUIがカクつく」「アニメーションを入れたいがパフォーマンスが心配」といったお悩みがあれば、お問い合わせフォームからお気軽にご相談ください。