FLIPアニメ並べ替え

FLIP(First-Last-Invert-Play)技法で並べ替え時の位置差分だけをGPUアニメ。シャッフル・ソート・カードクリックで滑らかにリオーダーするリスト遷移です。

#javascript#animation#flip#layout

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk: ダッシュボードのタスクカードを FLIP で滑らかに並べ替え -->
<div class="fd-app">
  <div class="fd-bar">
    <div class="fd-bar-l">
      <span class="fd-logo">▰ FlowDesk</span>
      <span class="fd-title">マイタスク</span>
    </div>
    <div class="fd-controls">
      <button class="fd-btn" data-action="priority">優先度順</button>
      <button class="fd-btn" data-action="shuffle">⇄ 再配置</button>
    </div>
  </div>

  <ul class="fd-list" aria-label="並べ替え可能なタスク">
    <li class="fd-item" data-key="1" data-due="今日">
      <span class="fd-flag fd-flag--hi">高</span>
      <span class="fd-task">請求書テンプレートの修正</span>
      <span class="fd-due">今日</span>
    </li>
    <li class="fd-item" data-key="3" data-due="明日">
      <span class="fd-flag fd-flag--mid">中</span>
      <span class="fd-task">新規顧客のオンボーディング</span>
      <span class="fd-due">明日</span>
    </li>
    <li class="fd-item" data-key="2" data-due="今日">
      <span class="fd-flag fd-flag--hi">高</span>
      <span class="fd-task">月次レポートの提出</span>
      <span class="fd-due">今日</span>
    </li>
    <li class="fd-item" data-key="5" data-due="来週">
      <span class="fd-flag fd-flag--low">低</span>
      <span class="fd-task">ヘルプ記事のレビュー</span>
      <span class="fd-due">来週</span>
    </li>
    <li class="fd-item" data-key="4" data-due="今週">
      <span class="fd-flag fd-flag--mid">中</span>
      <span class="fd-task">API連携のテスト</span>
      <span class="fd-due">今週</span>
    </li>
  </ul>

  <p class="fd-tip">カードをクリックで最上部へ。位置の差分だけをGPUで補間(FLIP)。</p>
</div>
CSS
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 400px;
  display: grid;
  place-items: center;
  font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", system-ui, sans-serif;
  color: #e8edff;
  background:
    radial-gradient(700px 360px at 85% -10%, #1c2c54 0%, transparent 60%),
    #0f1b34;
}

.fd-app {
  width: min(560px, 94vw);
  background: #16244a;
  border-radius: 18px;
  padding: 18px;
  border: 1px solid rgba(79, 124, 255, .18);
  box-shadow: 0 22px 50px rgba(0, 0, 0, .4);
}

.fd-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
  gap: 10px;
}
.fd-bar-l { display: flex; align-items: baseline; gap: 12px; }
.fd-logo { font-size: 15px; font-weight: 800; color: #4f7cff; letter-spacing: .02em; }
.fd-title { font-size: 13px; color: #9db0d8; }

.fd-controls { display: flex; gap: 8px; }
.fd-btn {
  border: 1px solid rgba(79, 124, 255, .35);
  background: rgba(79, 124, 255, .12);
  color: #cdd9ff;
  padding: 7px 13px;
  border-radius: 9px;
  cursor: pointer;
  font-size: 12px;
  font-weight: 600;
  transition: background .2s ease, border-color .2s ease;
}
.fd-btn:hover { background: rgba(79, 124, 255, .25); border-color: #4f7cff; }
.fd-btn:focus-visible { outline: 2px solid #4f7cff; outline-offset: 2px; }

.fd-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 9px;
}

.fd-item {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 12px;
  padding: 13px 15px;
  border-radius: 12px;
  background: #1e2e5a;
  border: 1px solid rgba(255, 255, 255, .05);
  cursor: pointer;
  transition: background .2s ease, transform .12s ease;
  will-change: transform;
}
.fd-item:hover { background: #243668; transform: translateX(2px); }

.fd-flag {
  font-size: 11px;
  font-weight: 800;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border-radius: 8px;
}
.fd-flag--hi { background: rgba(255, 99, 132, .2); color: #ff90a5; }
.fd-flag--mid { background: rgba(79, 124, 255, .22); color: #8fb0ff; }
.fd-flag--low { background: rgba(120, 200, 160, .18); color: #88e0b0; }

.fd-task { font-size: 14px; color: #eaf0ff; }
.fd-due { font-size: 11px; color: #8ea3ce; white-space: nowrap; }

.fd-tip { margin: 14px 2px 0; font-size: 12px; color: #7d8fbb; }
JavaScript
// FlowDesk タスクカードを FLIP(First-Last-Invert-Play)で滑らかに並べ替え
(() => {
  const list = document.querySelector('.fd-list');
  if (!list) return; // null安全
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // First: 各カードの現在位置を記録
  const recordPositions = (items) => {
    const map = new Map();
    items.forEach((el) => map.set(el, el.getBoundingClientRect()));
    return map;
  };

  // Last→Invert→Play: 旧位置との差分から逆移動→0へ
  const animate = (firstRects) => {
    [...list.children].forEach((el) => {
      const first = firstRects.get(el);
      if (!first) return;
      const last = el.getBoundingClientRect();
      const dy = first.top - last.top;
      if (!dy || reduce) return;
      el.animate(
        [{ transform: `translateY(${dy}px)` }, { transform: 'translateY(0)' }],
        { duration: 400, easing: 'cubic-bezier(.2,.8,.2,1)' }
      );
    });
  };

  // DOM順を変えるだけのヘルパー
  const reorder = (mutate) => {
    const items = [...list.children];
    const first = recordPositions(items);
    mutate(items);
    animate(first);
  };

  // 優先度順(data-key 昇順 = 高い優先度が上)
  const byPriority = () => reorder((items) => {
    items
      .sort((a, b) => Number(a.dataset.key) - Number(b.dataset.key))
      .forEach((el) => list.appendChild(el));
  });

  // 再配置(Fisher–Yates シャッフル)
  const shuffle = () => reorder((items) => {
    for (let i = items.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [items[i], items[j]] = [items[j], items[i]];
    }
    items.forEach((el) => list.appendChild(el));
  });

  // ボタン操作
  document.querySelector('.fd-controls')?.addEventListener('click', (e) => {
    const btn = e.target.closest('.fd-btn');
    if (!btn) return;
    if (btn.dataset.action === 'priority') byPriority();
    if (btn.dataset.action === 'shuffle') shuffle();
  });

  // カードクリックで最上部へ
  list.addEventListener('click', (e) => {
    const item = e.target.closest('.fd-item');
    if (!item) return;
    reorder(() => list.prepend(item));
  });
})();

コード

HTML
<!-- FLIPアニメ: 並べ替え時に First/Last の差分を transform で滑らかに補間 -->
<div class="flip-stage">
  <div class="flip-bar">
    <h2 class="flip-h">FLIP Shuffle</h2>
    <div class="flip-controls">
      <button class="flip-btn" data-action="shuffle">🔀 シャッフル</button>
      <button class="flip-btn" data-action="sort">↕ ソート</button>
    </div>
  </div>

  <ul class="flip-list" aria-label="並べ替え可能なカード">
    <li class="flip-item" data-key="A" style="--g:linear-gradient(135deg,#f6d365,#fda085)"><span class="flip-num">1</span>Alpha</li>
    <li class="flip-item" data-key="B" style="--g:linear-gradient(135deg,#a1c4fd,#c2e9fb)"><span class="flip-num">2</span>Bravo</li>
    <li class="flip-item" data-key="C" style="--g:linear-gradient(135deg,#84fab0,#8fd3f4)"><span class="flip-num">3</span>Charlie</li>
    <li class="flip-item" data-key="D" style="--g:linear-gradient(135deg,#fbc2eb,#a6c1ee)"><span class="flip-num">4</span>Delta</li>
    <li class="flip-item" data-key="E" style="--g:linear-gradient(135deg,#fccb90,#d57eeb)"><span class="flip-num">5</span>Echo</li>
    <li class="flip-item" data-key="F" style="--g:linear-gradient(135deg,#43e97b,#38f9d7)"><span class="flip-num">6</span>Foxtrot</li>
  </ul>

  <p class="flip-tip">カードや上のボタンで並べ替え。位置の差分だけをGPUアニメ(FLIP)。</p>
</div>
CSS
* { box-sizing: border-box; }

:root {
  --bg: #11131c;
  --text: #f2f4ff;
  --muted: #9aa1bd;
}

body {
  margin: 0;
  min-height: 360px;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  color: var(--text);
  background:
    radial-gradient(700px 320px at 100% 0%, #1f2540 0%, transparent 60%),
    radial-gradient(600px 300px at 0% 100%, #16303a 0%, transparent 55%),
    var(--bg);
}

.flip-stage { width: min(680px, 92vw); padding: 20px; }

.flip-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
  gap: 12px;
  flex-wrap: wrap;
}
.flip-h { margin: 0; font-size: 18px; letter-spacing: .04em; }
.flip-controls { display: flex; gap: 8px; }

.flip-btn {
  border: 1px solid #34384f;
  background: #1c2030;
  color: var(--text);
  padding: 8px 14px;
  border-radius: 999px;
  font-size: 13px;
  cursor: pointer;
  transition: background .2s ease, transform .15s ease, border-color .2s ease;
}
.flip-btn:hover { background: #262b40; border-color: #4a5072; transform: translateY(-1px); }
.flip-btn:active { transform: translateY(0); }
.flip-btn:focus-visible { outline: 2px solid #7c9cff; outline-offset: 2px; }

.flip-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.flip-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 18px 16px;
  border-radius: 14px;
  font-weight: 600;
  color: #1a1c2a;
  background: var(--g, #888);
  cursor: pointer;
  user-select: none;
  box-shadow: 0 8px 20px rgba(0,0,0,.3);
  will-change: transform;
}
.flip-item:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }

.flip-num {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: rgba(255,255,255,.55);
  font-size: 12px;
  font-weight: 700;
}

.flip-tip { margin: 16px 2px 0; font-size: 12px; color: var(--muted); }

@media (max-width: 520px) {
  .flip-list { grid-template-columns: repeat(2, 1fr); }
}

@media (prefers-reduced-motion: reduce) {
  .flip-item { will-change: auto; }
}
JavaScript
// FLIP(First-Last-Invert-Play)で並べ替えを滑らかにアニメーション
(() => {
  const list = document.querySelector('.flip-list');
  if (!list) return; // null安全
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // First: 現在の各カードの矩形を記録
  const recordPositions = (items) => {
    const map = new Map();
    items.forEach((el) => map.set(el, el.getBoundingClientRect()));
    return map;
  };

  // Last→Invert→Play: 並べ替え後に旧位置との差分から逆移動→0へ
  const animate = (firstRects) => {
    const items = [...list.children];
    items.forEach((el) => {
      const first = firstRects.get(el);
      if (!first) return;
      const last = el.getBoundingClientRect();
      const dx = first.left - last.left;
      const dy = first.top - last.top;
      if (!dx && !dy) return;
      if (reduce) return; // モーション低減時は即時反映

      el.animate(
        [
          { transform: `translate(${dx}px, ${dy}px)` },
          { transform: 'translate(0, 0)' }
        ],
        { duration: 420, easing: 'cubic-bezier(.2,.8,.2,1)' }
      );
    });
  };

  // 並べ替えを実行するヘルパー(DOM順を変えるだけ)
  const reorder = (mutate) => {
    const items = [...list.children];
    const first = recordPositions(items);
    mutate(items);
    animate(first);
  };

  // シャッフル(Fisher–Yates)
  const shuffle = () => reorder((items) => {
    for (let i = items.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [items[i], items[j]] = [items[j], items[i]];
    }
    items.forEach((el) => list.appendChild(el));
  });

  // data-key の昇順にソート
  const sort = () => reorder((items) => {
    items
      .sort((a, b) => a.dataset.key.localeCompare(b.dataset.key))
      .forEach((el) => list.appendChild(el));
  });

  // ボタン操作
  document.querySelector('.flip-controls')?.addEventListener('click', (e) => {
    const btn = e.target.closest('.flip-btn');
    if (!btn) return;
    if (btn.dataset.action === 'shuffle') shuffle();
    if (btn.dataset.action === 'sort') sort();
  });

  // カードクリックで先頭へ移動(クリックした要素が前に出る)
  list.addEventListener('click', (e) => {
    const item = e.target.closest('.flip-item');
    if (!item) return;
    reorder(() => list.prepend(item));
  });
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「FLIPアニメ並べ替え」の効果を追加してください。

# 追加してほしい効果
FLIPアニメ並べ替え(ページ遷移 / View Transitions)
FLIP(First-Last-Invert-Play)技法で並べ替え時の位置差分だけをGPUアニメ。シャッフル・ソート・カードクリックで滑らかにリオーダーするリスト遷移です。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- FLIPアニメ: 並べ替え時に First/Last の差分を transform で滑らかに補間 -->
<div class="flip-stage">
  <div class="flip-bar">
    <h2 class="flip-h">FLIP Shuffle</h2>
    <div class="flip-controls">
      <button class="flip-btn" data-action="shuffle">🔀 シャッフル</button>
      <button class="flip-btn" data-action="sort">↕ ソート</button>
    </div>
  </div>

  <ul class="flip-list" aria-label="並べ替え可能なカード">
    <li class="flip-item" data-key="A" style="--g:linear-gradient(135deg,#f6d365,#fda085)"><span class="flip-num">1</span>Alpha</li>
    <li class="flip-item" data-key="B" style="--g:linear-gradient(135deg,#a1c4fd,#c2e9fb)"><span class="flip-num">2</span>Bravo</li>
    <li class="flip-item" data-key="C" style="--g:linear-gradient(135deg,#84fab0,#8fd3f4)"><span class="flip-num">3</span>Charlie</li>
    <li class="flip-item" data-key="D" style="--g:linear-gradient(135deg,#fbc2eb,#a6c1ee)"><span class="flip-num">4</span>Delta</li>
    <li class="flip-item" data-key="E" style="--g:linear-gradient(135deg,#fccb90,#d57eeb)"><span class="flip-num">5</span>Echo</li>
    <li class="flip-item" data-key="F" style="--g:linear-gradient(135deg,#43e97b,#38f9d7)"><span class="flip-num">6</span>Foxtrot</li>
  </ul>

  <p class="flip-tip">カードや上のボタンで並べ替え。位置の差分だけをGPUアニメ(FLIP)。</p>
</div>

【CSS】
* { box-sizing: border-box; }

:root {
  --bg: #11131c;
  --text: #f2f4ff;
  --muted: #9aa1bd;
}

body {
  margin: 0;
  min-height: 360px;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  color: var(--text);
  background:
    radial-gradient(700px 320px at 100% 0%, #1f2540 0%, transparent 60%),
    radial-gradient(600px 300px at 0% 100%, #16303a 0%, transparent 55%),
    var(--bg);
}

.flip-stage { width: min(680px, 92vw); padding: 20px; }

.flip-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
  gap: 12px;
  flex-wrap: wrap;
}
.flip-h { margin: 0; font-size: 18px; letter-spacing: .04em; }
.flip-controls { display: flex; gap: 8px; }

.flip-btn {
  border: 1px solid #34384f;
  background: #1c2030;
  color: var(--text);
  padding: 8px 14px;
  border-radius: 999px;
  font-size: 13px;
  cursor: pointer;
  transition: background .2s ease, transform .15s ease, border-color .2s ease;
}
.flip-btn:hover { background: #262b40; border-color: #4a5072; transform: translateY(-1px); }
.flip-btn:active { transform: translateY(0); }
.flip-btn:focus-visible { outline: 2px solid #7c9cff; outline-offset: 2px; }

.flip-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.flip-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 18px 16px;
  border-radius: 14px;
  font-weight: 600;
  color: #1a1c2a;
  background: var(--g, #888);
  cursor: pointer;
  user-select: none;
  box-shadow: 0 8px 20px rgba(0,0,0,.3);
  will-change: transform;
}
.flip-item:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }

.flip-num {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: rgba(255,255,255,.55);
  font-size: 12px;
  font-weight: 700;
}

.flip-tip { margin: 16px 2px 0; font-size: 12px; color: var(--muted); }

@media (max-width: 520px) {
  .flip-list { grid-template-columns: repeat(2, 1fr); }
}

@media (prefers-reduced-motion: reduce) {
  .flip-item { will-change: auto; }
}

【JavaScript】
// FLIP(First-Last-Invert-Play)で並べ替えを滑らかにアニメーション
(() => {
  const list = document.querySelector('.flip-list');
  if (!list) return; // null安全
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // First: 現在の各カードの矩形を記録
  const recordPositions = (items) => {
    const map = new Map();
    items.forEach((el) => map.set(el, el.getBoundingClientRect()));
    return map;
  };

  // Last→Invert→Play: 並べ替え後に旧位置との差分から逆移動→0へ
  const animate = (firstRects) => {
    const items = [...list.children];
    items.forEach((el) => {
      const first = firstRects.get(el);
      if (!first) return;
      const last = el.getBoundingClientRect();
      const dx = first.left - last.left;
      const dy = first.top - last.top;
      if (!dx && !dy) return;
      if (reduce) return; // モーション低減時は即時反映

      el.animate(
        [
          { transform: `translate(${dx}px, ${dy}px)` },
          { transform: 'translate(0, 0)' }
        ],
        { duration: 420, easing: 'cubic-bezier(.2,.8,.2,1)' }
      );
    });
  };

  // 並べ替えを実行するヘルパー(DOM順を変えるだけ)
  const reorder = (mutate) => {
    const items = [...list.children];
    const first = recordPositions(items);
    mutate(items);
    animate(first);
  };

  // シャッフル(Fisher–Yates)
  const shuffle = () => reorder((items) => {
    for (let i = items.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [items[i], items[j]] = [items[j], items[i]];
    }
    items.forEach((el) => list.appendChild(el));
  });

  // data-key の昇順にソート
  const sort = () => reorder((items) => {
    items
      .sort((a, b) => a.dataset.key.localeCompare(b.dataset.key))
      .forEach((el) => list.appendChild(el));
  });

  // ボタン操作
  document.querySelector('.flip-controls')?.addEventListener('click', (e) => {
    const btn = e.target.closest('.flip-btn');
    if (!btn) return;
    if (btn.dataset.action === 'shuffle') shuffle();
    if (btn.dataset.action === 'sort') sort();
  });

  // カードクリックで先頭へ移動(クリックした要素が前に出る)
  list.addEventListener('click', (e) => {
    const item = e.target.closest('.flip-item');
    if (!item) return;
    reorder(() => list.prepend(item));
  });
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。