零弐壱蜂

[JavaScript] アニメーション終了検知に`animationend`を使うとアクセシビリティ的に良くない

背景

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属性の更新はアニメーション完了を待たず即座に実行する

参考文献