← ブログ一覧

クリックが効かない!offset降順で死んだ話

ジンベエザメのアニメが突然無反応に。Web Animations APIのoffsetが0.55→0.90→0.85と降順になると例外が飛んでクリックハンドラが無音で死ぬ。jinbei-labの実例・実エラー・1行修正を解説。


登場人物

  • リナ社長 … 高校生なのに会社経営するやり手ギャル。テックにも強くてAI活用が得意。
  • タクヤ … 入社3年目の男性社員。真面目で少しだけコードが書ける。

今回は、クリックが効かなくなった犯人「offset降順」を突き止めるデバッグ回。


タクヤ「リナ社長、トップページのジンベエザメ、クリックしても全然動かなくなってるんですけど……」

リナ社長「え、バグった?あんなに3回も直したのに?」

タクヤ「ええ、3回改修記で完璧になったはずで、昨日まで動いてたんですよ。なのに今日クリックしたら無反応で」

リナ社長「とりあえずコンソール開いてみ。絶対なんか出てるから」


コンソールに出ていたエラー

コンソールを開いてジンベエザメをクリックすると、毎回こんなエラーが飛んでいた。

TypeError: Failed to execute 'animate' on 'Element':
Offsets must be monotonically non-decreasing.

タクヤ「“monotonically non-decreasing”って……何語ですか」

リナ社長「数学用語で”単調非減少”。要するに、キーフレームの offset の値が途中で下がっちゃダメって意味。0.55 → 0.90 → 0.85 みたいに一回でも逆行してると、animate() が即死する」

タクヤ「……3版で反転キーフレームを追加したとき、前後の値をちゃんと確認してなかったかも」

リナ社長「それが原因じゃん。見てみよ」


何が起きていたか——問題のキーフレーム配列

第3版で完成させたキーフレーム配列の中身はこうなっていた(問題の箇所を抜粋):

const mainAnim = shark.animate([
  { transform: 'translateX(0)',     opacity: '1', offset: 0    },
  { transform: 'translateX(-160px)', opacity: '1', offset: 0.22 },
  { transform: 'translateX(-320px)', opacity: '1', offset: 0.55 },
  // ↓ タイトル付近で反転するために第3版で追加したキーフレーム
  { transform: 'translateX(-340px) scaleX(-1)', opacity: '1', offset: 0.90 },
  // ↓ 右へ大きくなりながらフェードアウト(第2版から存在)
  { 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))', opacity: '0', offset: 1  },
], { duration: 2400, easing: 'ease-in-out', fill: 'forwards' });

offsetの列を並べると 0 → 0.22 → 0.55 → 0.90 → 0.85 → 0.93 → 1

0.90 → 0.85 のところで一度だけ下がっている。第3版で「反転キーフレームをタイトル付近に挿入しよう」と0.90を追加したとき、直後にある既存キーフレーム(0.85)との前後関係を確認しなかったのが原因だ。

タクヤ「配列の途中に追加するときって、前後のoffset値を全部確認しないといけないんですね」

リナ社長「そうそう。キーフレームが増えてくると見落としやすくなるから、一覧でoffsetだけ眺める癖をつけたほうがいい」


修正——1行で直った

修正は単純で、0.90を0.75に変えるだけ。

// ❌ Before:0.90 が直後の 0.85 を超えており、単調性が壊れている
{ transform: 'translateX(-340px) scaleX(-1)', opacity: '1', offset: 0.90 },

// ✅ After:0.75 に下げ、0.55 → 0.75 → 0.85 → 0.93 → 1 と昇順に
{ transform: 'translateX(-340px) scaleX(-1)', opacity: '1', offset: 0.75 },

修正後のoffset列は 0 → 0.22 → 0.55 → 0.75 → 0.85 → 0.93 → 1 と完全な昇順になり、エラーは消えてジンベエザメが復活した。

タクヤ「アニメの見た目は変わりましたか?反転のタイミングが少し早くなったかも、くらいですか」

リナ社長「うん、0.90→0.75で反転が少し早くなるけど、Uターンの動き自体は変わらないから見た目の差はほぼわからない。そもそも動いてなかったわけだし」


なぜ「無音停止」になるのか——本当の怖さ

このバグで一番やっかいなのは、アニメが音もなく止まることだ。

element.animate() はキーフレームをパースした瞬間にoffsetの単調性を検証する。違反があれば TypeError を投げて即座に失敗する。try-catch で囲んでいなければ、クリックハンドラがそこで終了する。

shark.addEventListener('click', async () => {
  if (sharkAnimating) return;
  sharkAnimating = true;  // ← フラグを true にした

  const mainAnim = shark.animate([...], { duration: 2400 });
  // ↑ ここで TypeError → ハンドラがここで死ぬ

  await mainAnim.finished;   // ← 到達しない
  sharkAnimating = false;    // ← 到達しない。フラグが戻らない
});

sharkAnimating フラグが true のまま残るため、2回目以降のクリックは最初の if で全部はじかれる。最初の1クリックだけコンソールエラー、その後は完全無反応、という不思議な状態になる。コンソールを開いていなければ「アニメが消えた」ことにすら気づけない。

リナ社長「だからWeb Animations APIのコードを触ったあとは、必ずコンソール開いた状態でクリックして確認する習慣をつけてほしい。エラーが出てたらすぐわかる」

タクヤtry-catch で囲んどけば少なくともフラグは戻りますね」

リナ社長「そうそう。エラーは出ちゃうけど、二回目以降もクリックできるようになる。デバッグのときは try-catch 入れるか、コンソール監視するかどっちか」


offsetを管理する実践的なやり方

キーフレームが7個以上になると、offsetの整合性は人間の目で追うのがしんどくなる。コード中にoffsetの全列をコメントで書いておくと、挿入・削除のミスを防ぎやすい。

// offset: 0 → 0.22 → 0.55 → 0.75 → 0.85 → 0.93 → 1
const frames: Keyframe[] = [
  { transform: '...', offset: 0    },  // 起点
  { transform: '...', offset: 0.22 },  // 左へ1段階
  { transform: '...', offset: 0.55 },  // 左へ2段階
  { transform: '...', offset: 0.75 },  // タイトル付近でUターン
  { transform: '...', offset: 0.85 },  // 右へ大きくフェード
  { transform: '...', offset: 0.93 },  // さらに右へ
  { transform: '...', offset: 1    },  // 画面外
];

配列の上にoffset列を一行書いておくと、途中に挿入するときに「どこに何の値を入れるか」が一目瞭然になる。数値が逆転しないかも視覚的に確認しやすい。

キーフレーム管理のリスク対策
offsetを途中に追加して前後逆転配列の上にoffset列のコメントを書く
エラーが無音で出て気づかない変更後は必ずコンソール開いてクリック確認
フラグが戻らず2回目以降が無反応クリックハンドラ全体を try-catch で囲む

タクヤ「コメントで一覧にするだけでかなりミスが防げそうですね」

リナ社長「そうそう。アニメ系のコードって再生してみないと正しいかわからないじゃん。だから変更の影響を手元でわかりやすくしとくのが大事」


持ち帰り

Web Animations APIの offset は昇順厳守。 キーフレームを追加・変更したら、全offsetが単調非減少になっているか確認する。違反すると animate()TypeError を投げてその場で死ぬ。try-catch がなければフラグも戻らず、2回目以降のクリックも完全無反応になる。配列の直上にoffset列のコメントを書いておくと、挿入ミスの早期発見につながる。

コメント