クリックが効かない!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列のコメントを書いておくと、挿入ミスの早期発見につながる。
コメント