← ブログ一覧

ジンベエザメがタップで逃げるようになるまで — 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版:タイトルの「前」を通過させる

要件が明確になった。

  1. 左へ泳ぐ
  2. タイトル付近でUターンして反転
  3. タイトルの 前面 を通りながら右へ大きくフェードアウト
  4. 右からひょっこり戻る

ポイントは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)。ここを変えるだけで見た目が大きく変わる。

変更箇所変更前変更後効果
左端到達のoffset0.420.55もっとゆっくり左に進む
反転タイミング0.500.75タイトルにより近づいてから向きを変える
フェードアウト開始0.650.85反転後すぐに消え始めず、少し泳いでから消える
戻りの duration850ms1100ms帰還をもう少しゆっくりに
{ 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分くらいかかった。アニメーションは「動く」と「気持ちいい」の間に大きな隔たりがある。

コメント