
はじめに
Reactで認証フォームを作るとき、エラー表示は {error ? <p>{error}</p> : null} と書くのが定番です。
ただ、この書き方は「エラーが出たことをスクリーンリーダーに確実に伝える」という点で、意外な落とし穴があります。
実運用しているサービスのアクセシビリティ改善で、この問題に加えて、トグルボタンの作り方とa11yスナップショットの扱いも見直しました。
共通していたのは、「axe-coreで違反が出ない」ことと「実際にアクセシブルである」ことは別だ、という点です。
この記事では、その3つの修正をコード付きで整理します。
こんな人におすすめ
- Reactのフォームで
role="alert"を使っているのに、スクリーンリーダーで読まれないことがある方 - パスワード表示切替ボタンの
aria-labelを、状態に応じて書き換えている方 - axe-coreの結果をJSONで保存し、PRの差分がノイズだらけになって困っている方
- Playwrightやtesting-libraryで、ARIA状態をテストに組み込みたい方
罠1: 条件付きレンダリングだとアナウンスされないことがある
role="alert" の要素を、エラーが出た瞬間にDOMへ追加する書き方です。
{displayError ? <p className="auth-error">{displayError}</p> : null}
ライブリージョンは、基本的に「すでにDOMにある領域の中身が変わったとき」に通知する仕組みです。
要素ごと新規に挿入すると、スクリーンリーダーによってはアナウンスしないことがあります。
axe-coreのドキュメントでも、既知の注意点として触れられています。
対策は、コンテナを常にDOMへ置いておき、中身だけを入れ替えることです。
<p
className="auth-error"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
{displayError ?? ''}
</p>
エラーがないときは空の <p> が残るだけです。
エラーが発生すると、監視中の領域に文字が流入するので、読み上げが安定します。
空コンテナを消すのに display: none は使わない
空の <p> が余白や枠線を出すのを避けたくなりますが、display: none は禁物です。
アクセシビリティツリーから外れるため、ライブリージョンとしての監視も止まってしまいます。
代わりに、box-modelだけを潰します。
/* display: none は aria-live を無効にするので使わない */
.auth-error:empty {
padding: 0;
border-left-width: 0;
margin: 0;
}
:empty はReactの '' 描画でも効くため、中身があるときだけ通常の見た目に戻ります。
罠2: トグルボタンのaria-labelを切り替えない
パスワードの表示・非表示ボタンで、こう書いていないでしょうか。
aria-label={show ? 'パスワードを隠す' : 'パスワードを表示'}
押すたびに名前が変わるため、スクリーンリーダーの読み上げが冗長になります。
WAI-ARIA APGのToggle Buttonパターンでは、ラベルは固定し、状態は aria-pressed で伝えます。
<button
type="button"
aria-pressed={show}
aria-label="パスワードの表示切替"
onClick={() => setShow((v) => !v)}
>
{/* アイコン */}
</button>
「パスワードの表示切替、トグルボタン、オフ」のように、名前と状態が分けて読まれます。
テストでも状態をロケーターに含める
testing-libraryでも、Playwrightでも、getByRole に pressed オプションを渡せます。
const TOGGLE_NAME = 'パスワードの表示切替'
expect(
screen.getByRole('button', { name: TOGGLE_NAME, pressed: false })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: TOGGLE_NAME }))
expect(
screen.getByRole('button', { name: TOGGLE_NAME, pressed: true })
).toBeInTheDocument()
状態のアサーションがロケーターの中に収まるので、読みやすくなります。
罠3: a11yスナップショットに実行時刻を入れない
axe-coreの結果を「既知の違反の記録」としてJSONで保存する運用があります。
このとき、次のように生成時刻を入れてしまうと、再生成のたびに全ファイルが差分になります。
export interface BaselineFile {
$schema?: string
screenKey: A11yScreenKey
- generatedAt: string
axeVersion: string
wcagTags: string[]
violations: SerializedViolation[]
}
ファイルの目的は違反の記録なので、実行時情報は不要です。
生成関数側の new Date().toISOString() によるデフォルト代入も消しました。
これで再生成が冪等になり、PRの差分には違反の増減だけが現れます。
E2Eテストでエラー状態を再現するコツ
永続コンテナにしたことで、E2Eでは「コンテナに文字が入るのを待つ」書き方ができます。
const suffix = crypto.randomUUID().slice(0, 12)
await page.getByLabel('メールアドレス').fill(`nobody-${suffix}@example.com`)
await page.getByLabel('パスワード').fill('wrong-password')
await page.getByRole('button', { name: 'ログイン' }).click()
// APIの応答待ち。空文字から何かが入るまで待つ
await expect(page.getByRole('alert')).toContainText(/.+/, { timeout: 10_000 })
メールアドレスにランダムなサフィックスを付けると、未登録アドレスでの失敗を確実に再現できます。
並列ワーカー間での衝突も避けられます。
同じアドレスで何度も失敗させないので、レート制限に当たりにくくなる点も利点です。
つまづきやすいポイント
- 永続コンテナにすると、他の
role="alert"と合わせてgetByRole('alert')が複数ヒットすることがあります。クラス名やスコープで絞ってください aria-live="assertive"とrole="alert"は暗黙の値が重なります。明示しても害はありませんが、読み上げの優先度は用途に合わせて選んでください:emptyは空白文字や改行が入ると効きません。displayError ?? ''のように、本当に空の文字列を描画してください- スクリーンリーダーの挙動は組み合わせで異なります。実機での確認も併用してください
まとめ
エラー表示は、条件付きレンダリングではなく、永続コンテナへの内容注入にすると安定します。
空の見た目は display: none ではなく、:empty でbox-modelだけを潰します。
トグルボタンはラベルを固定し、状態を aria-pressed で伝えます。
a11yのスナップショットから実行時刻を除くと、PRの差分が読みやすくなります。
自動チェックは入口にすぎないので、読み上げの挙動まで意識して実装するのがおすすめです。

