How to build an accessible form error summary アクセシブルなフォームエラーサマリーの作り方
Validate on submit, focus a concise error summary, and keep every message connected to its field. 送信時に検証し、エラーサマリーへフォーカスを移動。各メッセージを入力項目と関連付けます。
ライブデモ Live Demo
概要・用途・特徴Overview, Usage & Features
何ができるかWhat it does
A submit-time validation pattern with one error summary above the form and one message beside every invalid field.
フォーム上部のエラー一覧と、各入力欄の個別メッセージを組み合わせた送信時検証パターンです。
どこで使うかWhere to use
Use it when a form has several fields and users need a reliable way to find every problem after submission.
複数項目があり、送信後にすべての問題箇所を確実に見つける必要があるフォームに適しています。
特徴Key features
Summary focus after invalid submit, anchor links to fields, aria-invalid state, described-by error messages, progressive clearing, and reset support.
エラー送信後のサマリーフォーカス、項目へのリンク、aria-invalid、説明関連付け、修正時の段階的な解除、リセットに対応します。
調整可能パラメータ Adjustable Parameters
| Parameter | Default | Description |
|---|---|---|
field IDs | stable IDs | Connect labels, controls, inline errors, and summary links. |
validation timing | submit, then input | Show the summary after submit and clear corrected field errors during input. |
locale (React) | en | Use ja for Japanese labels and messages. |
server errors | none | Merge API validation failures into the same error model. |
実装コード Implementation Code
<form class="error-summary-form" data-error-summary-form novalidate>
<div class="error-summary" data-error-summary tabindex="-1" hidden>
<h2 id="error-summary-title">Check the following fields</h2>
<ul aria-labelledby="error-summary-title" data-error-list></ul>
</div>
<div class="field">
<label for="account-email">Email</label>
<input id="account-email" name="email" type="email" required aria-describedby="account-email-error" />
<p class="field-error" id="account-email-error" data-error-for="account-email"></p>
</div>
<div class="field">
<label for="account-password">Password</label>
<input id="account-password" name="password" type="password" minlength="8" required aria-describedby="account-password-hint account-password-error" />
<p id="account-password-hint" class="field-hint">Use at least 8 characters.</p>
<p class="field-error" id="account-password-error" data-error-for="account-password"></p>
</div>
<button type="submit">Create account</button>
<button type="reset" class="secondary">Reset</button>
</form>
<script>
document.querySelectorAll('[data-error-summary-form]').forEach((form) => {
const summary = form.querySelector('[data-error-summary]');
const list = form.querySelector('[data-error-list]');
const fields = [...form.querySelectorAll('input[required]')];
const message = (field) => field.validity.valueMissing
? field.labels[0].textContent + ' is required.'
: field.type === 'email' && field.validity.typeMismatch
? 'Enter an email address in the correct format.'
: 'Use at least ' + field.minLength + ' characters.';
function setError(field, text = '') {
field.setAttribute('aria-invalid', String(Boolean(text)));
form.querySelector('[data-error-for="' + field.id + '"]').textContent = text;
}
function renderSummary(errors) {
list.replaceChildren(...errors.map(({ field, text }) => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = '#' + field.id;
link.textContent = text;
link.addEventListener('click', (event) => { event.preventDefault(); field.focus(); });
li.append(link);
return li;
}));
summary.hidden = errors.length === 0;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const errors = fields.filter((field) => !field.validity.valid).map((field) => ({ field, text: message(field) }));
fields.forEach((field) => setError(field, field.validity.valid ? '' : message(field)));
renderSummary(errors);
if (errors.length) summary.focus();
});
fields.forEach((field) => field.addEventListener('input', () => {
if (field.validity.valid) setError(field);
}));
form.addEventListener('reset', () => queueMicrotask(() => {
fields.forEach((field) => setError(field));
renderSummary([]);
}));
});
</script>
.error-summary-form { box-sizing: border-box; width: min(100%, 520px); margin: 0 auto; padding: 28px; border: 1px solid #d6d8e0; border-radius: 18px; background: #fbfbfd; color: #20232d; font-family: Inter, system-ui, sans-serif; }
.error-summary-form * { box-sizing: border-box; }
.error-summary { margin-bottom: 24px; padding: 18px 20px; border: 2px solid #b4233c; border-radius: 12px; background: #fff1f3; }
.error-summary:focus-visible { outline: 3px solid #3158d4; outline-offset: 3px; }
.error-summary h2 { margin: 0 0 10px; font-size: 18px; }
.error-summary ul { margin: 0; padding-left: 22px; }
.error-summary a { color: #8f1730; font-weight: 700; text-underline-offset: 3px; }
.field { margin-bottom: 20px; }
.field label { display: block; margin-bottom: 7px; font-weight: 700; }
.field input { width: 100%; min-height: 48px; padding: 10px 12px; border: 1px solid #89909f; border-radius: 9px; background: #fff; color: inherit; font: inherit; }
.field input[aria-invalid="true"] { border: 2px solid #b4233c; }
.field input:focus-visible, .error-summary-form button:focus-visible { outline: 3px solid #3158d4; outline-offset: 3px; }
.field-hint, .field-error { margin: 7px 0 0; font-size: 13px; line-height: 1.5; }
.field-hint { color: #596170; }
.field-error { min-height: 20px; color: #a21631; font-weight: 650; }
.error-summary-form button { min-height: 46px; padding: 10px 16px; border: 1px solid #3158d4; border-radius: 9px; background: #3158d4; color: #fff; font-family: inherit; font-size: 14px; font-weight: 700; line-height: 1; cursor: pointer; }
.error-summary-form button.secondary { margin-left: 8px; background: transparent; color: #3158d4; }
import { useEffect, useId, useRef, useState } from 'react';
import './F-008.css';
export default function FormErrorSummary({ locale = 'en' }) {
const prefix = useId();
const summaryRef = useRef(null);
const emailRef = useRef(null);
const passwordRef = useRef(null);
const [errors, setErrors] = useState([]);
const ja = locale === 'ja';
const fields = [
{ key: 'email', ref: emailRef, label: ja ? 'メールアドレス' : 'Email' },
{ key: 'password', ref: passwordRef, label: ja ? 'パスワード' : 'Password' },
];
useEffect(() => { if (errors.length) summaryRef.current?.focus(); }, [errors]);
function validate() {
return fields.flatMap(({ key, ref, label }) => {
const field = ref.current;
if (field.validity.valid) return [];
const text = field.validity.valueMissing
? (ja ? label + 'を入力してください。' : label + ' is required.')
: key === 'email'
? (ja ? '正しい形式のメールアドレスを入力してください。' : 'Enter an email address in the correct format.')
: (ja ? '8文字以上で入力してください。' : 'Use at least 8 characters.');
return [{ key, text }];
});
}
function submit(event) { event.preventDefault(); setErrors(validate()); }
function clearIfValid(key, ref) { if (ref.current.validity.valid) setErrors((all) => all.filter((error) => error.key !== key)); }
function errorFor(key) { return errors.find((error) => error.key === key)?.text || ''; }
return (
<form className="error-summary-form" noValidate onSubmit={submit} onReset={() => setErrors([])}>
{errors.length > 0 && (
<div className="error-summary" ref={summaryRef} tabIndex={-1}>
<h2 id={prefix + '-summary-title'}>{ja ? '入力内容を確認してください' : 'Check the following fields'}</h2>
<ul aria-labelledby={prefix + '-summary-title'}>{errors.map((error) => (
<li key={error.key}><a href={'#' + prefix + '-' + error.key} onClick={(event) => { event.preventDefault(); fields.find((field) => field.key === error.key).ref.current.focus(); }}>{error.text}</a></li>
))}</ul>
</div>
)}
{fields.map(({ key, ref, label }) => (
<div className="field" key={key}>
<label htmlFor={prefix + '-' + key}>{label}</label>
<input ref={ref} id={prefix + '-' + key} name={key} type={key === 'email' ? 'email' : 'password'} required minLength={key === 'password' ? 8 : undefined} aria-invalid={Boolean(errorFor(key))} aria-describedby={prefix + '-' + key + '-error'} onInput={() => clearIfValid(key, ref)} />
<p className="field-error" id={prefix + '-' + key + '-error'}>{errorFor(key)}</p>
</div>
))}
<button type="submit">{ja ? 'アカウントを作成' : 'Create account'}</button>
<button type="reset" className="secondary">{ja ? 'リセット' : 'Reset'}</button>
</form>
);
}
.error-summary-form { box-sizing: border-box; width: min(100%, 520px); margin: 0 auto; padding: 28px; border: 1px solid #d6d8e0; border-radius: 18px; background: #fbfbfd; color: #20232d; font-family: Inter, system-ui, sans-serif; }
.error-summary-form * { box-sizing: border-box; }
.error-summary { margin-bottom: 24px; padding: 18px 20px; border: 2px solid #b4233c; border-radius: 12px; background: #fff1f3; }
.error-summary:focus-visible { outline: 3px solid #3158d4; outline-offset: 3px; }
.error-summary h2 { margin: 0 0 10px; font-size: 18px; }
.error-summary ul { margin: 0; padding-left: 22px; }
.error-summary a { color: #8f1730; font-weight: 700; text-underline-offset: 3px; }
.field { margin-bottom: 20px; }
.field label { display: block; margin-bottom: 7px; font-weight: 700; }
.field input { width: 100%; min-height: 48px; padding: 10px 12px; border: 1px solid #89909f; border-radius: 9px; background: #fff; color: inherit; font: inherit; }
.field input[aria-invalid="true"] { border: 2px solid #b4233c; }
.field input:focus-visible, .error-summary-form button:focus-visible { outline: 3px solid #3158d4; outline-offset: 3px; }
.field-error { min-height: 20px; margin: 7px 0 0; color: #a21631; font-size: 13px; font-weight: 650; line-height: 1.5; }
.error-summary-form button { min-height: 46px; padding: 10px 16px; border: 1px solid #3158d4; border-radius: 9px; background: #3158d4; color: #fff; font-family: inherit; font-size: 14px; font-weight: 700; line-height: 1; cursor: pointer; }
.error-summary-form button.secondary { margin-left: 8px; background: transparent; color: #3158d4; }
仕組みとカスタマイズHow It Works & Customization
仕組みHow it works
The form disables native popup validation with novalidate but keeps native validity rules. Submit collects invalid controls, renders inline messages and summary links, then focuses the summary without scrolling twice. Each link focuses its field.
novalidateでブラウザのポップアップだけを止め、requiredなどの標準validityは利用します。送信時に無効な項目を集め、個別メッセージとサマリーリンクを描画してから、サマリーへフォーカスします。リンクを押すと該当項目へ移動します。
カスタマイズ方法Customization
Replace fields and messages, but keep stable IDs, one error element per field, and a deterministic mapping between summary links and controls. Localize labels and validation copy together.
項目と文言は変更できますが、ID、項目ごとのエラー要素、サマリーリンクと入力欄の対応は維持してください。ラベルと検証文言は同じ言語へ揃えます。
注意点Caveats
Client validation does not replace server validation. Merge server errors into the same summary after a failed response. Do not move focus on every keystroke; focus the summary only after an explicit submit attempt.
クライアント検証はサーバー検証の代わりになりません。サーバーエラーも同じサマリーへ統合してください。入力のたびにフォーカスを動かさず、明示的な送信後だけサマリーへ移動します。
よくある質問 Frequently Asked Questions
Why focus the error summary after submit? 送信後にエラーサマリーへフォーカスする理由は?
It puts keyboard and screen-reader users at a concise overview of every problem. Links then provide direct access to each invalid field.
キーボードやスクリーンリーダーの利用者が、すべての問題を最初に把握できるためです。リンクから各入力項目へ直接移動できます。
Should validation run on every keystroke? 入力のたびに検証すべきですか?
Do not show a full summary before the user attempts submission. After that attempt, clear an individual error when its field becomes valid without moving focus.
最初の送信前からサマリーを表示する必要はありません。送信後は、項目が有効になった時点で個別エラーを消し、フォーカスは移動させません。
How should server validation errors be handled? サーバー側の検証エラーはどう扱いますか?
Map them to the same field IDs and summary structure. Keep the user's values, render the returned messages, and focus the summary after the failed response.
同じ入力IDとサマリー構造へ割り当てます。入力値を維持したまま返却メッセージを表示し、失敗応答後にサマリーへフォーカスします。
AIへの指示テンプレート AI Prompt Template
以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.