ジンベエザメがタップで逃げるようになるまで — Web Animations API 3回改修記
タップしたら左に泳いで消える、のはずが「なんか微妙」から始まった3回のアニメーション改修。Web Animations APIのキーフレームと格闘した話
登場人物
- リナ社長 … 高校生なのに会社経営するやり手ギャル。テックにも強くてAI活用が得意。
- タクヤ … 入社3年目の男性社員。真面目で少しだけコードが書ける。
「ジンベエザメ、タップしたら逃げてほしい」
リナ社長「タクヤ、トップページのジンベエザメさ、クリックしたら泳いで消えるようにしてほしいんだけど」
タクヤ「……どこに消えるんですか?」
リナ社長「左。左に泳いで画面の外に消えて、ちょっとしたらひょっこり戻ってくる感じ。かわいいじゃん」
タクヤはCSSアニメーションを書こうとしたが、リナ社長に止められた。
リナ社長「待って、どうせ途中で await したくなるから Web Animations API で書いて。element.animate() でキーフレーム渡せるやつ」
第1版:とりあえず左に消す
まず動く版を作った。クリックしたら左へ泳ぎ去り、2秒後に戻ってくる。
const shark = document.querySelector('.shark-img') as HTMLElement | null;
let sharkAnimating = false;
shark?.addEventListener('click', async () => {
if (sharkAnimating) return; // 二重発火防止
sharkAnimating = true;
shark.style.cursor = 'default';
// 泳ぎ去り(左へ)
const away = shark.animate([
{ transform: 'translateX(0) scaleX(1)', opacity: '1' },
{ transform: 'translateX(-40px) scaleX(-1)', opacity: '1', offset: 0.1 },
{ transform: 'translateX(-620px) scaleX(-1)', opacity: '0.35', offset: 0.78 },
{ transform: 'translateX(calc(-100vw - 500px)) scaleX(-1)', opacity: '0' },
], { duration: 1600, easing: 'ease-in', fill: 'forwards' });
await away.finished;
await new Promise<void>(r => setTimeout(r, 2200));
// 泳ぎ戻り(左から帰還)
const back = shark.animate([
{ transform: 'translateX(calc(-100vw - 500px)) scaleX(-1)', opacity: '0' },
{ transform: 'translateX(0) scaleX(1)', opacity: '1' },
], { duration: 1100, easing: 'ease-out', fill: 'forwards' });
await back.finished;
away.cancel();
back.cancel();
sharkAnimating = false;
shark.style.cursor = '';
});
動いた。でもリナ社長は微妙な顔をした。
リナ社長「左に消えて左から戻ってくるんじゃ、なんかただいまって感じじゃん。もっとサメっぽく、タイトルの前面を突き抜けてほしい」
第2版:タイトルの「前」を通過させる
要件が明確になった。
- 左へ泳ぐ
- タイトル付近でUターンして反転
- タイトルの 前面 を通りながら右へ大きくフェードアウト
- 右からひょっこり戻る
ポイントは2つ。z-index でタイトルより前に出すこと、そして**scale() で大きくなりながら消える**こと。
shark.style.zIndex = '10'; // タイトルの前面に出る
const mainAnim = shark.animate([
// 左へスライド
{ transform: 'translateX(0) scaleX(1) scale(1)', opacity: '1', offset: 0 },
{ transform: 'translateX(-160px) scaleX(1) scale(1)', opacity: '1', offset: 0.22 },
{ transform: 'translateX(-320px) scaleX(1) scale(1)', opacity: '1', offset: 0.42 },
// タイトル付近で反転(scaleX(-1) に切り替え)
{ transform: 'translateX(-340px) scaleX(-1) scale(1)', opacity: '1', offset: 0.50 },
// 右へ大きくなりながらフェードアウト
{ transform: 'translateX(-80px) scaleX(-1) scale(1.3)', opacity: '0.9', offset: 0.65 },
{ transform: 'translateX(200px) scaleX(-1) scale(1.7)', opacity: '0.45', offset: 0.82 },
{ transform: 'translateX(calc(100vw + 200px)) scaleX(-1) scale(2.2)', opacity: '0', offset: 1 },
], { duration: 2400, easing: 'ease-in-out', fill: 'forwards' });
await mainAnim.finished;
await new Promise<void>(r => setTimeout(r, 300));
// 右から戻る
const returnAnim = shark.animate([
{ transform: 'translateX(calc(100vw + 200px)) scaleX(1) scale(1.2)', opacity: '0' },
{ transform: 'translateX(0) scaleX(1) scale(1)', opacity: '1' },
], { duration: 850, easing: 'ease-out', fill: 'forwards' });
position: relative を .shark-img に追加しないと z-index が効かなかった。CSS側も忘れずに。
.shark-img {
position: relative; /* ← これがないとz-indexが無視される */
cursor: pointer;
user-select: none;
}
リナ社長「いい感じ!でも反転がちょっと早い。もうちょっとタイトルに近づいてから向き変えてほしい」
第3版:キーフレームの offset を微調整
offset はキーフレームのタイミング比率(0〜1)。ここを変えるだけで見た目が大きく変わる。
| 変更箇所 | 変更前 | 変更後 | 効果 |
|---|---|---|---|
| 左端到達のoffset | 0.42 | 0.55 | もっとゆっくり左に進む |
| 反転タイミング | 0.50 | 0.75 | タイトルにより近づいてから向きを変える |
| フェードアウト開始 | 0.65 | 0.85 | 反転後すぐに消え始めず、少し泳いでから消える |
| 戻りの duration | 850ms | 1100ms | 帰還をもう少しゆっくりに |
{ transform: 'translateX(-320px) scaleX(1) scale(1)', opacity: '1', offset: 0.55 }, // 0.42→0.55
{ transform: 'translateX(-340px) scaleX(-1) scale(1)', opacity: '1', offset: 0.75 }, // 0.50→0.75
{ transform: 'translateX(-80px) scaleX(-1) scale(1.3)', opacity: '0.9', offset: 0.85 }, // 0.65→0.85
{ transform: 'translateX(200px) scaleX(-1) scale(1.7)', opacity: '0.45', offset: 0.93 }, // 0.82→0.93
戻りの duration も 850ms → 1100ms に伸ばしてゆったり帰還させた。
リナ社長「これ!ちゃんとサメが泳いでる感じがする。よし採用」
振り返り:ハマりポイントまとめ
z-index が効かない問題
position: static(デフォルト)の要素は z-index を無視する。position: relative を追加して解決。
scaleX(-1) の切り替えタイミング
offset: 0.50 で一瞬だけ反転するようにしたが、補間でぐにゃっと変形して見えた。直前のキーフレームと同じ位置に scaleX(1) → scaleX(-1) を並べることで瞬間反転になった。
animation.cancel() を忘れると次の発火がずれる
fill: 'forwards' で最終フレームを保持したままにするため、アニメーション完了後に .cancel() を呼ばないとスタイルが上書きされたまま残る。await finished の後は必ず cancel() してからフラグをリセットする。
await returnAnim.finished;
mainAnim.cancel(); // ← 忘れると次のクリックでズレる
returnAnim.cancel();
sharkAnimating = false;
Web Animations API を使った理由
CSS @keyframes + animation でも同じことはできる。でも今回のように「去り → 待機 → 帰還」を async/await で順番に書けるのが Web Animations API の強み。animation.finished は Promise を返すので、アニメーション間の待機が直感的に書ける。
CSSアニメーションだと animationend イベントをリスナーで拾いながら状態管理する必要があって煩雑になる。JavaScriptで制御する前提なら Web Animations API の方が素直。
最終的なコードの全体像
const shark = document.querySelector('.shark-img') as HTMLElement | null;
let sharkAnimating = false;
shark?.addEventListener('click', async () => {
if (sharkAnimating) return;
sharkAnimating = true;
shark.style.cursor = 'default';
shark.style.zIndex = '10';
const mainAnim = shark.animate([
{ transform: 'translateX(0) scaleX(1) scale(1)', opacity: '1', offset: 0 },
{ transform: 'translateX(-160px) scaleX(1) scale(1)', opacity: '1', offset: 0.22 },
{ transform: 'translateX(-320px) scaleX(1) scale(1)', opacity: '1', offset: 0.55 },
{ transform: 'translateX(-340px) scaleX(-1) scale(1)', opacity: '1', offset: 0.75 },
{ transform: 'translateX(-80px) scaleX(-1) scale(1.3)', opacity: '0.9', offset: 0.85 },
{ transform: 'translateX(200px) scaleX(-1) scale(1.7)', opacity: '0.45', offset: 0.93 },
{ transform: 'translateX(calc(100vw + 200px)) scaleX(-1) scale(2.2)', opacity: '0', offset: 1 },
], { duration: 2400, easing: 'ease-in-out', fill: 'forwards' });
await mainAnim.finished;
await new Promise<void>(r => setTimeout(r, 300));
const returnAnim = shark.animate([
{ transform: 'translateX(calc(100vw + 200px)) scaleX(1) scale(1.2)', opacity: '0' },
{ transform: 'translateX(50px) scaleX(1) scale(1.04)', opacity: '0.85', offset: 0.65 },
{ transform: 'translateX(8px) scaleX(1) scale(1.01)', opacity: '1', offset: 0.88 },
{ transform: 'translateX(0) scaleX(1) scale(1)', opacity: '1' },
], { duration: 1100, easing: 'ease-out', fill: 'forwards' });
await returnAnim.finished;
mainAnim.cancel();
returnAnim.cancel();
shark.style.zIndex = '';
sharkAnimating = false;
shark.style.cursor = '';
});
3回の改修で合計15分くらいかかった。アニメーションは「動く」と「気持ちいい」の間に大きな隔たりがある。
コメント