Claude Codeでaria-describedbyを点検しフォームのエラー文を結び直す
フォーム部品ごとにラベル・必須表示・エラー文の結びつきを読み取り専用のサブエージェントで点検し、aria-describedbyの結び直しと送信エラー時のフォーカス移動まで直す手順です。
フォームのエラー文が画面には出ているのに、入力欄とプログラム上でつながっていない。この状態は、見た目のレビューでは見つかりません。直す側も、部品ごとにlabel、必須表示、エラー文を読み比べる単調な作業になります。
この作業は、読み取り専用のサブエージェントに点検させ、直しは本体の会話で行う分担に向いています。この記事では、点検用サブエージェントの定義、報告の受け取り方、aria-describedbyでエラー文を結び直すコード、送信エラー時のフォーカス移動とaria-liveの置き方を順に書きます。
aria-describedbyは「名前」ではなく「説明」をつなぐ
aria-describedbyは、要素を説明するテキストを持つ別要素のidを列挙する属性です。値は、スペース区切りのid参照リストです。入力欄の名前を与えるlabelとは役割が違い、ヒントやエラー文のような追加情報を載せる場所になります。
MDNは、aria-labelledbyが対象の本質を示すラベルを指すのに対し、aria-describedbyはユーザーが必要とするかもしれない追加情報を指すと説明しています。フォームに当てはめると、次のように分けられます。
| 情報 | つなぎ方 | 例 |
|---|---|---|
| 入力欄の名前 | つなぎ方<label for> | 例メールアドレス |
| 入力形式のヒント | つなぎ方aria-describedby | 例例: name@example.com |
| エラー文 | つなぎ方aria-describedby | 例正しい形式で入力してください |
| 無効状態 | つなぎ方aria-invalid | 例検証に失敗したときだけtrue |
aria-describedbyには、フォームの外でも使えるという性質があります。参照先は画面に見えていなくてもよく、閉じた説明を「ヘルプ」ボタンで開く作りでも使えます。ただし、参照できるのは同じDOM文書内の要素だけです。別ページの文言は指せません。
内容が長い、または構造を持つ説明はaria-detailsが向くとされています。エラー文は短い文字列なので、aria-describedbyで足ります。
aria-errormessageとの使い分け
エラー文の専用属性としてaria-errormessageもあります。MDNによると、この属性はaria-invalid="true"のときだけ使い、参照先の要素は見える状態でなければなりません。この記事では、ヒントとエラー文を同じ仕組みで扱えるようaria-describedbyに寄せます。プロジェクトでaria-errormessageを選んでいる場合は、点検の基準だけ差し替えれば同じ流れで使えます。
点検の基準を4役割に分ける
サブエージェントへ「アクセシビリティを見て」と頼むと、報告が散らかります。点検の対象を、フォーム部品1つあたり4つの役割に絞ります。
- ラベル: すべての入力欄に
<label for>、または同等の名前がある - 必須表示: 必須であることが色だけでなく文字や記号で見える
- エラー文: エラー文の要素に
idがあり、入力欄のaria-describedbyから参照されている - 無効状態: 検証失敗時だけ
aria-invalid="true"になる
必須表示については、MDNが「必須の項目は色だけに頼らず、文字やアイコンで示す」ことを求めています。もう1点、ラベルに「必須」という語がすでに含まれているなら、aria-requiredは付けないことが推奨されています。スクリーンリーダーが「必須」を二重に読み上げるのを避けるためです。<input>にHTMLのrequiredが付いていれば、この属性は要りません。
無効状態の側にも、守りたい順序があります。MDNは、必須の空欄にaria-invalid="true"を付けるのは送信を試みた後にするよう書いています。入力途中の欄を最初からエラー扱いにしないためです。この4役割は、WCAGの3.3.1「エラーの特定」(レベルA)、3.3.2「ラベルまたは説明」(レベルA)、3.3.3「エラー修正の提案」(レベルAA)に対応します。
点検専用のサブエージェントを置く
サブエージェントは、.claude/agents/にMarkdownファイルとして置きます。プロジェクト固有のものはここに置き、バージョン管理に含めてチームで共有できます。nameとdescriptionが必須で、toolsを省略すると使えるツールをすべて引き継ぎます。点検役は読むだけなので、toolsで許可リストを絞ります。
---
name: form-a11y-auditor
description: フォーム部品のlabel・必須表示・エラー文の結びつきを点検する。フォームのコンポーネントを追加・変更したときは積極的に使う
tools: Read, Grep, Glob
model: sonnet
---
あなたはフォームのアクセシビリティを点検する読み取り専用のレビュー役です。
ファイルは変更しません。
## 点検する対象
src/components/forms/ 以下と、`<form>` または `<input>` `<select>` `<textarea>`
を含むコンポーネントです。
## 部品ごとに確かめる4点
1. label: `<label for>` または同等の名前が、入力欄のidと一致している
2. 必須表示: 必須項目に、色以外の手がかり(文字・記号)がある
3. エラー文: エラー文の要素にidがあり、入力欄のaria-describedbyが
そのidを含む。idがその場で組み立てられて確認できないときは
「未確定」とする
4. 無効状態: aria-invalid="true" が、検証失敗時だけ付く作りになっている
## 報告の形式
部品ごとに1行、`ファイル:行 | label | 必須 | エラー文 | 無効状態`
の順に ok / ng / 未確定 を書く。ng と未確定には、理由を1文添える。
最後に、ng の件数と未確定の件数だけを書く。修正案は書かない。descriptionに「積極的に使う」と入れているのは、Claudeに自動で委任させたいときの書き方としてドキュメントが挙げている表現です。説明文を長くしすぎないことも注意点とされています。
tools: Read, Grep, Globにしているのは、書き込みと実行を取り上げて、点検が副作用を持たないようにするためです。同じ考え方でデータベース分析役を読み取り専用に固定する例は、Claude Codeのサブエージェントをhookで読み取り専用のDB分析役にするにあります。
ファイルを置いた直後から反映されます。Claude Codeは
.claude/agents/を監視しており、数秒で変更を検知します。ただし、そのセッションで初めて作るagentsディレクトリは、再起動が必要です。
「未確定」を許す理由
実際のフォームでは、idをuseId()やテンプレート文字列で組み立てる部品が多くあります。静的に読むだけのサブエージェントは、その場合に参照が一致するかを断定できません。ngとokのどちらかに倒させると、見落としや誤報が出ます。「未確定」の区分を作っておけば、後で実機かテストで確かめる対象として切り出せます。
点検を回して報告を読む
点検は、メンションで呼び出すと確実にそのサブエージェントが動きます。自然文で名前を出す方法もありますが、その場合に委任するかどうかはClaudeの判断です。
@"form-a11y-auditor (agent)" src/components/forms と src/app/signup を点検して返ってくる報告は、次のような形になります(形式の例です)。
src/components/forms/TextField.tsx:14 | label ok | 必須 ng | エラー文 ng | 無効状態 ng
必須が赤い * だけで表示され、テキストの手がかりがない。エラー<p>にidがない
src/components/forms/Select.tsx:9 | label ok | 必須 ok | エラー文 未確定 | 無効状態 ok
aria-describedby が props.describedBy 経由で渡され、参照先を静的に追えない
src/app/signup/page.tsx:41 | label ng | 必須 ok | エラー文 ng | 無効状態 ng
パスワード確認欄が placeholder だけで名前を持つ
ng 7 件 / 未確定 1 件報告を受けたら、本体の会話で次の順に進めます。
- ngを部品ごとにまとめて修正を依頼する(共通部品の修正で一度に消える行を先に直す)
- 未確定の行は、実際にブラウザで開いて、フォーカス時の読み上げか開発者ツールのアクセシビリティツリーで確かめる
- 直した後にサブエージェントをもう一度呼び、ngが減ったことを確かめる
点検役が修正案を書かないよう指示してあるのは、点検と修正の判断を同じ文脈に混ぜないためです。サブエージェントの文脈は本体と分かれるので、点検でファイルを大量に読んでも、本体の会話は報告の数行しか受け取りません。
エラー文をaria-describedbyで結び直す
最もよくある不具合は、ラベルが入力欄と結びついておらず、エラー文が単なる赤い<p>として入力欄の近くに出ている形です。
// 直す前: 画面では隣り合うが、プログラム上は何もつながっていない
<label>メールアドレス</label>
<input name="email" className={error ? "error" : ""} />
{error && <p className="red">正しい形式で入力してください</p>}共通のフィールド部品に寄せて、idの組み立てを1か所に閉じ込めます。
import { useId } from "react";
type FieldProps = {
label: string;
hint?: string;
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
export function Field({ label, hint, error, required, ...rest }: FieldProps) {
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy =
[hint && hintId, error && errorId].filter(Boolean).join(" ") || undefined;
return (
<div>
<label htmlFor={id}>
{label}
{required && "(必須)"}
</label>
{hint && <p id={hintId}>{hint}</p>}
<input
id={id}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...rest}
/>
{error && <p id={errorId}>{error}</p>}
</div>
);
}この部品には、点検の4役割が1つずつ入っています。
- ラベルは
htmlForとidで結ばれ、必須は「(必須)」という文字で見える。aria-requiredは付けず、HTMLのrequiredに任せる - ヒントとエラー文は
aria-describedbyに空白区切りで並ぶ。エラーがなければ参照から外れる aria-invalidは、エラーがあるときだけtrueになる
useId()を使っているので、同じ部品を画面に複数並べてもidが衝突しません。逆に、id="email-error"のように固定の文字列にした部品を繰り返すと、同じidが複数でき、参照がどれを指すか決まりません。
修正を依頼するときのプロンプト
Claude Codeには、部品名と変更範囲を決めて頼みます。
さっきの点検で ng だった部品を直して。方針:
- 共通の Field 部品に寄せ、label は htmlFor、ヒントとエラー文は aria-describedby
- 必須は文字で示し、aria-required は付けない(HTML の required を使う)
- aria-invalid は検証に失敗したときだけ true
- 既存の呼び出し側は props を変えずに動くこと
直したら npm run typecheck を実行して送信エラー時のフォーカスとaria-live
個々の欄の結びつきを直しても、送信ボタンを押した直後の体験は別問題です。送信が失敗しても、画面のどこで何が起きたかを、見えない利用者へ伝える仕組みは別に用意する必要があります。
扱う手段は2つです。
- フォーカスを最初のエラー欄へ移す: エラー文が
aria-describedbyで結ばれていれば、移動先の入力欄の説明としてエラー文が読まれる設計になる - 件数を
aria-liveで伝える: フォーカスを動かさずに、状態の変化を知らせる
WCAGの4.1.3「ステータスメッセージ」(レベルAA)は、フォーカスを受け取らなくても支援技術がステータスメッセージを提示できるよう、roleやプロパティで判別可能にすることを求めています。MDNは、aria-liveを空の要素に付けておき、更新時にそこへ短い通知を入れる使い方を示しています。この記事の例も、通知用の要素を初回描画から置いておき、中身だけを更新します。
import { useEffect, useRef, useState } from "react";
export function SignupForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [status, setStatus] = useState("");
const formRef = useRef<HTMLFormElement>(null);
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const next = validate(new FormData(e.currentTarget)); // 自作の検証
setErrors(next);
const count = Object.keys(next).length;
setStatus(count ? `入力エラーが${count}件あります` : "");
}
// エラーが描画された後で、最初に無効な欄へフォーカスを移す
useEffect(() => {
formRef.current
?.querySelector<HTMLElement>('[aria-invalid="true"]')
?.focus();
}, [errors]);
return (
<form ref={formRef} onSubmit={onSubmit} noValidate>
{/* 初回描画から存在させ、中身だけ更新する */}
<p aria-live="polite">{status}</p>
{/* Field 部品を並べる */}
</form>
);
}フォーカス移動をuseEffectに置いているのは、エラー文とaria-invalidが描画された後に.focus()を呼ぶためです。setErrorsの直後に呼ぶと、まだaria-invalidが付いていない時点の欄を探してしまいます。
aria-liveの値はpoliteにしています。MDNは、assertiveが直前の読み上げを中断するため、中断が必須の場面以外では使わないよう警告しています。フォーム送信の失敗は、利用者を遮る必要のある通知とは言えず、politeで足ります。
この構成では、フォーカス移動の読み上げとaria-liveの通知が重なる可能性があります。件数の通知は残しつつ、実機で二重に聞こえて邪魔にならないかを確かめてください。邪魔であれば、件数を通知する側を外し、フォーカス移動だけにする判断もあります。
直した後の確かめ方
構造は自動検査で確かめられます。Claude CodeでPlaywrightとaxeのアクセシビリティ検査をPRで落とすの手順で、ラベルのない入力欄のような違反を落とせます。ただし、「エラー文が本当に問題を説明しているか」「aria-describedbyの参照先が意図した要素か」は、機械では判断しきれません。サブエージェントの「未確定」の行は、ここで実機に回します。
部品の実装そのものをアクセシブルな既製品に任せる方法は、React Aria MCPをClaude Codeに登録しアクセシブルなUI部品を実装させるに書いています。この記事の方法は、既存のフォームを後から点検して直す場面に向いています。
フォームの検証をZodとReact Hook Formで組むなら、Claude CodeでReact Hook FormとZodのフォームを作る手順で作る部品に、ここで点検したFieldの形を当てはめられます。
つまずきやすい点
idが一致しないまま、静かに効かなくなる: aria-describedbyの値と、参照先のidの綴りが違っても、画面上の見た目は何も変わりません。点検の対象に「参照先のidが同じファイル内、または同じ部品内に存在する」ことを入れておくと、タイプミスを拾えます。
エラーがない欄にまで参照が残る: エラー文を消しても、aria-describedbyにidだけが残ると、存在しない要素を指します。上のFieldのように、エラーがあるときだけidを並べる作りにします。
必須を二重に読ませる: ラベルに「(必須)」と書き、さらにaria-required="true"を付けると、MDNが避けるよう書いている二重読み上げの形になります。
空欄を最初から無効扱いにする: 画面を開いた直後にaria-invalid="true"を付けると、まだ入力していない欄がエラーとして読まれます。付けるのは検証の後です。
aria-liveの要素を、エラー時に初めて挿入する: 通知用の要素は、描画の最初から置いておきます。{status && <p aria-live="polite">...</p>}の形は避けます。
まとめ
フォームの点検は、部品ごとにlabel、必須表示、エラー文、無効状態の4役割を見る作業に分解できます。静的に読めることは読み取り専用のサブエージェントに任せ、判断のつかない行は「未確定」として実機へ回すと、報告の精度が保てます。
直す側では、idの組み立てを共通のField部品に閉じ込めるのが効きます。aria-describedbyをエラーがあるときだけ参照させ、送信失敗時にはuseEffectで最初の無効な欄へフォーカスを移す。aria-liveは空の要素に置いておく。この3点を押さえれば、見た目のレビューでは見つからないずれを、点検と修正のサイクルで潰していけます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。