零弐壱蜂

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

概要

アニメーション終了を検知する際、animationend イベントを使用することがある。しかし、アクセシビリティの問題が潜んでいる。

animationendイベントの問題

実装例

アニメーションを伴うモーダルやダイアログを閉じる際、以下のような実装をよく見かける。

modal.classList.add('fade-out');
modal.addEventListener('animationend', () => {
  modal.remove();
  setState('closed');
});

1. prefers-reduced-motion 対応時の問題

ユーザーがシステムで「視覚効果を減らす」設定を有効にしている場合、以下のような CSS を適用する。このような設定は、モダンなリセットCSSにも含まれていることがある。

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}

animationendイベントとprefers-reduced-motionとの組み合わせにより次の問題が発生する。

  1. アニメーションが無効化される
  2. animationend イベントが発火しない
  3. ダイアログが閉じない

同様の問題は他の状況でも発生する可能性がある。

  • ブラウザ設定でユーザーがアニメーションを無効化している場合
  • 省電力モードでバッテリー節約のためアニメーションが制限される場合
  • 古いデバイスで性能の問題によりアニメーションがスキップされる場合
  • 開発者ツールでアニメーション速度を変更した場合の予期しない動作

2. animationendイベント自体の信頼性問題

animationendイベントには、prefers-reduced-motion以外にも以下の問題がある。

  • Race condition - アニメーションは視覚的に実行されても、イベントが発火しない場合もある
  • 無限アニメーション - animation-iteration-count: infiniteではanimationendが発火しない
  • ブラウザ間の挙動差異 - 特にChromeで問題が報告されている

これらの問題からも、animationendへの依存は避けるべきである。

スクリーンリーダー利用者への影響

スクリーンリーダーを使用するユーザーにとって、視覚的なアニメーションは意味を持たない。しかし、アニメーション完了に依存した処理は、これらのユーザーにも影響を与えてしまう。

  • DOM の状態が正しく更新されない
  • フォーカス管理が適切に行われない
  • ARIA 属性の更新が遅延する

解決方法

代替アプローチの比較

animation-duration短縮アプローチ

一部の実装では、アニメーションを無効化せず、極めて短い時間(0.01ms等)に設定する方法もある。

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}

この方法ならanimationendイベントは発火するが、以下の問題がある。

  • 全てのアニメーションに影響する過激な変更
  • 意図しない副作用の可能性
  • 個別制御が困難

そのため、本記事ではsetTimeoutを推奨する。

setTimeoutによる解決

setTimeoutを使用する。

既存コードからの移行手順

animationendを使用している既存のコードは、以下の手順で安全に移行できる。

// Step 1: 既存の実装
modal.addEventListener('animationend', handleEnd);

// Step 2: setTimeout併用版
let done = false;
modal.addEventListener('animationend', () => {
  if (!done) {
    done = true;
    handleEnd();
  }
});

setTimeout(() => {
  if (!done) {
    done = true;
    handleEnd();
  }
}, 300);

// Step 3: 最終版
setTimeout(handleEnd, 300);

基本的な改善

animationend の代わりに setTimeout を使用することで、確実に処理を実行できる。

// ✅ 改善された実装
modal.classList.add('fade-out');
setTimeout(() => {
  modal.remove();
  setState('closed');
}, 300); // アニメーション時間と同じ値

この方法なら基本的な問題は解決するが、アニメーション時間とタイマーの同期が課題となる。

prefers-reduced-motion への対応

さらにアクセシビリティを考慮した完全な実装は以下のようになる。

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

const animationDuration = prefersReducedMotion ? 0 : 300;

modal.classList.add('fade-out');
setTimeout(() => {
  modal.remove();
  setState('closed');
}, animationDuration);

実装例

1. React カスタムフック

再利用可能なカスタムフックとして実装する。

import { useState, useEffect, useCallback } from 'react';

const useAccessibleAnimation = (duration = 300) => {
  const [animating, setAnimating] = useState(false);
  const [reduced, setReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReduced(mq.matches);

    const onChange = (e) => setReduced(e.matches);
    mq.addEventListener('change', onChange);

    return () => mq.removeEventListener('change', onChange);
  }, []);

  const delay = reduced ? 0 : duration;

  const start = useCallback(
    (callback) => {
      setAnimating(true);
      setTimeout(() => {
        setAnimating(false);
        callback?.();
      }, delay);
    },
    [delay],
  );

  return { animating, start, delay };
};

2. React モーダルコンポーネント

カスタムフックを使用したモーダルの実装例。

const Modal = ({ isOpen, onClose, children }) => {
  const [closing, setClosing] = useState(false);
  const { start, delay } = useAccessibleAnimation(300);

  const close = () => {
    setClosing(true);
    start(() => {
      setClosing(false);
      onClose();
    });
  };

  if (!isOpen && !closing) return null;

  return (
    <div className={`modal ${closing ? 'closing' : ''}`}>
      {children}
      <button onClick={close}>Close</button>
    </div>
  );
};

3. 対応する CSS

CSS 側でもアクセシビリティを考慮する。

.modal {
  /* 基本スタイル */
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.modal--closing {
  animation: fadeOut var(--animation-duration, 300ms) ease-out;
}

@keyframes fadeOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

/* アニメーション無効時は即座に非表示 */
@media (prefers-reduced-motion: reduce) {
  .modal--closing {
    animation: none;
    opacity: 0;
  }
}

4. ユーザーコントロールの実装

システム設定を知らないユーザーや、借りたデバイスを使用している場合を考慮し、サイト内でアニメーションを制御できる機能を提供することも重要である。

const useAnimationPreference = () => {
  const [reduced, setReduced] = useState(() => {
    const saved = localStorage.getItem('reduceMotion');
    return saved === 'true' || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  });

  const toggle = () => {
    const value = !reduced;
    setReduced(value);
    localStorage.setItem('reduceMotion', String(value));
  };

  return { reduced, toggle };
};

これにより、ユーザーは自分の好みに応じてアニメーションを制御でき、よりアクセシブルな体験を提供できる。

まとめ

animationend イベントは便利だが、アクセシビリティの観点から注意が必要である。setTimeout を使用し、prefers-reduced-motion を考慮することで、すべてのユーザーにとって確実に動作するインターフェースを実装できる。

参考文献