背景
animationendイベントはCSS Animationが正常に完了した時のみ発火する。animation: noneで無効化された場合や、prefers-reduced-motion設定により視覚効果が削減された環境では、イベントは発火せず、処理が永久に停止する。
WCAG 2.1はprefers-reduced-motionメディアクエリに応じた実装を推奨している。多くのリセットCSSは以下のようにアニメーションを無効化する。
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
}
}
問題
modal.classList.add('fade-out');
modal.addEventListener('animationend', () => {
modal.remove();
setState('closed');
});
animationendは発火せず、モーダルは画面に残り続ける。フォーカストラップが有効なままになるため、キーボード操作でページ全体が操作不能になる。
解決方法
基本:タイムアウトによる保険
animationendとタイムアウトを競争させ、いずれか早い方で処理を進める。
function waitAnimation(element, maxMs) {
return new Promise((resolve) => {
let done = false;
let timeoutId = null;
const finish = () => {
if (done) return;
done = true;
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
element.removeEventListener('animationend', finish);
element.removeEventListener('animationcancel', finish);
resolve();
};
element.addEventListener('animationend', finish, { once: true });
element.addEventListener('animationcancel', finish, { once: true });
timeoutId = setTimeout(finish, maxMs);
});
}
modal.classList.add('fade-out');
await waitAnimation(modal, 300);
modal.remove();
ユーザーがアニメーションを無効化していても300ms後には必ず処理が進む。
完全な実装例
animationendに依存せずsetTimeoutのみで完結する実装例。
function createModalController(element) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let state = 'closed';
let previousFocus = null;
const getDuration = () => {
if (mediaQuery.matches) return 0;
const style = getComputedStyle(element);
const duration = parseFloat(style.animationDuration) * 1000;
const delay = parseFloat(style.animationDelay) * 1000;
const totalMs = duration + delay;
return Number.isFinite(totalMs) ? totalMs : 300;
};
const wait = () =>
new Promise((resolve) => {
setTimeout(resolve, getDuration());
});
const open = async () => {
if (state !== 'closed') return;
previousFocus = document.activeElement;
state = 'opening';
element.classList.add('is-entering');
element.removeAttribute('hidden');
await wait();
element.classList.remove('is-entering');
state = 'open';
element.querySelector('button')?.focus();
};
const close = async () => {
if (state !== 'open') return;
state = 'closing';
element.classList.add('is-exiting');
await wait();
element.classList.remove('is-exiting');
element.setAttribute('hidden', '');
state = 'closed';
if (previousFocus) {
previousFocus.focus();
}
};
return { open, close };
}
const modalElement = document.getElementById('modal');
const modal = createModalController(modalElement);
document.getElementById('open-modal')?.addEventListener('click', modal.open);
['confirm', 'cancel'].forEach((id) => {
document.getElementById(id)?.addEventListener('click', modal.close);
});
フォーカスを呼び出し元に戻すことで、キーボード操作の一貫性を保っている。prefers-reduced-motion時は即座に完了する。
<button type="button" id="open-modal">モーダルを開く</button>
<div id="modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" hidden>
<div class="modal__content">
<h2 id="modal-title">確認</h2>
<p>この操作を実行しますか?</p>
<button type="button" id="confirm">実行</button>
<button type="button" id="cancel">キャンセル</button>
</div>
</div>
.modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 1;
}
.modal.is-entering {
animation: fadeIn 300ms ease-out;
}
.modal.is-exiting {
animation: fadeOut 300ms ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
}
@keyframes fadeOut {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.modal.is-entering,
.modal.is-exiting {
animation: none;
}
.modal.is-exiting {
opacity: 0;
}
}
.modal__content {
background: white;
padding: 2rem;
border-radius: 8px;
}
注意点
CSSやリセットで animation: none が適用された場合、animationend は発火しない。setTimeoutによるフォールバックが、アクセシビリティを担保する最終防衛線となる。
テスト・検証方法
prefers-reduced-motion を有効化:Chrome DevTools の「More tools › Rendering」でメディアクエリを切り替え、即時終了するかを確認する。- キーボード操作:Tab キーでフォーカス環を一周させ、Escape で閉じたあと元のボタンへ戻るかをチェックする。
- スクリーンリーダー:NVDA + Firefox で読み上げ順とモーダル退出後のフォーカス戻りを確認する。
まとめ
setTimeoutによるフォールバックは、animationendの不確実性を補う現実的な対策である。
animationend単独に依存せず、setTimeoutでフォールバックを必ず用意するprefers-reduced-motion時は遅延をゼロにし、機能を即座に完了させる- フォーカス管理とARIA属性の更新はアニメーション完了を待たず即座に実行する
参考文献