FLIPアニメ並べ替え
FLIP(First-Last-Invert-Play)技法で並べ替え時の位置差分だけをGPUアニメ。シャッフル・ソート・カードクリックで滑らかにリオーダーするリスト遷移です。
ライブデモ
使用例(お題: 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。