TECH · HTMLフォームのバリデーション

触る前に赤くしない入力検証

サンプルとコード

01

JavaScriptなしの検証

単独で開く ↗

required・type・pattern・min/max だけで検証します。:user-invalid を使うので、まだ触っていない空欄が最初から赤くなることがありません。

HTML
<form class="form" novalidate-example>
  <div class="field">
    <label for="name">お名前</label>
    <input id="name" name="name" type="text" required minlength="2" autocomplete="name"
      aria-describedby="name-error" />
    <p class="error" id="name-error">2 文字以上でご入力ください。</p>
  </div>

  <div class="field">
    <label for="email">メールアドレス</label>
    <input id="email" name="email" type="email" required autocomplete="email"
      aria-describedby="email-hint email-error" />
    <p class="hint" id="email-hint">お返事のためだけに使います。</p>
    <p class="error" id="email-error">メールアドレスの形式でご入力ください。</p>
  </div>

  <div class="field">
    <label for="zip">郵便番号</label>
    <input id="zip" name="zip" type="text" inputmode="numeric" required
      pattern="[0-9]{3}-?[0-9]{4}" autocomplete="postal-code"
      aria-describedby="zip-hint zip-error" />
    <p class="hint" id="zip-hint">ハイフンはあってもなくても構いません。</p>
    <p class="error" id="zip-error">7 桁の数字でご入力ください(例: 150-0001)。</p>
  </div>

  <div class="field">
    <label for="age">ご年齢</label>
    <input id="age" name="age" type="number" min="13" max="120" required
      aria-describedby="age-error" />
    <p class="error" id="age-error">13 から 120 までの数字でご入力ください。</p>
  </div>

  <button type="submit">送信する</button>
</form>
CSS
.form {
  background: #fff; border: 1px solid var(--line);
  border-radius: 14px; padding: 1.4rem;
  display: grid; gap: 1.1rem;
}
.field { display: grid; gap: 0.3rem; }
.field label { font-size: 0.88rem; font-weight: 600; line-height: 1.7; }
.field .hint { font-size: 0.78rem; color: var(--muted); line-height: 1.8; }
.field input {
  font: inherit; line-height: 1.7; color: inherit;
  padding: 0.55rem 0.75rem;
  border: 1px solid var(--line); border-radius: 9px;
}
.field input:focus {
  outline: 0; border-color: var(--accent);
  box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.15);
}

/* ここが肝。:invalid だと「まだ触っていない空欄」まで最初から赤くなる。
   :user-invalid は、利用者が入力または送信を試みたあとだけ当たる。 */
.field input:user-invalid {
  border-color: var(--danger);
}
.field input:user-valid {
  border-color: var(--ok);
}

/* エラー文は既定で隠し、:user-invalid のときだけ出す。
   入力欄とは aria-describedby で結んでおくので、
   読み上げでも「何が問題か」が伝わる。 */
.field .error { display: none; font-size: 0.8rem; color: var(--danger); line-height: 1.8; }
.field input:user-invalid ~ .error { display: block; }

.form button {
  font: inherit; line-height: 1.7; cursor: pointer;
  padding: 0.7rem 1.2rem; border: 0; border-radius: 9px;
  background: var(--accent); color: #fff; font-weight: 700;
}
.form button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
AIへの指示文
HTML の標準機能だけで検証が効くフォームを作ってください。JavaScript は使いません。
要件:
- 名前は required と minlength、メールは type="email"、
  郵便番号は pattern="[0-9]{3}-?[0-9]{4}"、年齢は type="number" と min/max。
- 色を変えるのは :invalid ではなく **:user-invalid** を使う。
  :invalid だとまだ触っていない空欄が最初から赤くなる、と理由をコメントで書く。
- エラー文は既定で display: none にし、
  input:user-invalid ~ .error で表示する。
- 各入力に aria-describedby で補足文とエラー文を結び付ける。
- すべての入力に label を for と id で結び、autocomplete を正しく指定する。
- 日本語の本文なので line-height は 1.85、letter-spacing は 0 にする。
02

エラー文を自分の言葉にする

単独で開く ↗

ブラウザ既定の吹き出しを止め、validity のどのフラグが立ったかで文言を出し分けます。入力中は赤くせず、一度エラーになった欄だけ入力しながら回復を知らせます。

HTML
<form class="form" id="form" novalidate>
  <p class="summary" id="summary" role="alert"></p>

  <div class="field">
    <label for="user">ユーザー名</label>
    <input id="user" name="user" type="text" required minlength="3" maxlength="20"
      pattern="[a-z0-9_]+" aria-describedby="user-error" />
    <p class="error" id="user-error"></p>
  </div>

  <div class="field">
    <label for="mail">メールアドレス</label>
    <input id="mail" name="mail" type="email" required aria-describedby="mail-error" />
    <p class="error" id="mail-error"></p>
  </div>

  <button type="submit">登録する</button>
</form>
CSS
.form {
  background: #fff; border: 1px solid var(--line);
  border-radius: 14px; padding: 1.4rem;
  display: grid; gap: 1.1rem;
}
.field { display: grid; gap: 0.3rem; }
.field label { font-size: 0.88rem; font-weight: 600; line-height: 1.7; }
.field input {
  font: inherit; line-height: 1.7; color: inherit;
  padding: 0.55rem 0.75rem;
  border: 1px solid var(--line); border-radius: 9px;
}
.field input:focus {
  outline: 0; border-color: var(--accent);
  box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.15);
}
.field input[aria-invalid="true"] { border-color: var(--danger); }

/* エラー文の枠は常に DOM に置いておき、中身の有無で見せる。
   あとから要素を挿入すると、読み上げが変化に気づかないことがある。 */
.field .error {
  min-height: 0;
  font-size: 0.8rem; color: var(--danger); line-height: 1.8;
}
.field .error:empty { display: none; }

.form button {
  font: inherit; line-height: 1.7; cursor: pointer;
  padding: 0.7rem 1.2rem; border: 0; border-radius: 9px;
  background: var(--accent); color: #fff; font-weight: 700;
}
.summary {
  margin: 0; padding: 0.8rem 1rem;
  border: 1px solid #fecaca; background: #fef2f2; color: #991b1b;
  border-radius: 10px; font-size: 0.86rem; line-height: 1.85;
}
.summary:empty { display: none; }
JavaScript
const form = document.getElementById("form");
const summary = document.getElementById("summary");

// 検証ルールごとに、自分の言葉の文言を用意する。
// validity オブジェクトのどのフラグが立ったかで出し分ける。
const MESSAGES = {
  user: {
    valueMissing: "ユーザー名をご入力ください。",
    tooShort: "3 文字以上でご入力ください。",
    tooLong: "20 文字以内でご入力ください。",
    patternMismatch: "使えるのは半角英小文字・数字・アンダースコアだけです。",
  },
  mail: {
    valueMissing: "メールアドレスをご入力ください。",
    typeMismatch: "メールアドレスの形式でご入力ください。",
  },
};

function messageFor(input) {
  const rules = MESSAGES[input.name] ?? {};
  for (const key of Object.keys(rules)) {
    if (input.validity[key]) return rules[key];
  }
  // 想定外の失敗はブラウザ既定の文言に任せる
  return input.validationMessage;
}

function showError(input) {
  const box = document.getElementById(input.id + "-error");
  if (input.checkValidity()) {
    box.textContent = "";
    input.removeAttribute("aria-invalid");
    return true;
  }
  box.textContent = messageFor(input);
  input.setAttribute("aria-invalid", "true");
  return false;
}

const fields = [...form.querySelectorAll("input")];

// 入力中に赤くしない。一度フォーカスを外してから検証する。
fields.forEach((input) => {
  input.addEventListener("blur", () => showError(input));
  // 一度エラーを出した欄だけ、入力しながら回復を知らせる
  input.addEventListener("input", () => {
    if (input.getAttribute("aria-invalid") === "true") showError(input);
  });
});

form.addEventListener("submit", (e) => {
  e.preventDefault();
  const bad = fields.filter((input) => !showError(input));
  if (bad.length) {
    summary.textContent = bad.length + " 件の入力をご確認ください。";
    bad[0].focus(); // 最初の問題箇所へ移動させる
    return;
  }
  summary.textContent = "";
  alert("送信しました(サンプルなので実際には送信されません)");
});
AIへの指示文
ブラウザ既定のエラー文を、自分の言葉に置き換えたフォームを作ってください。
要件:
- form に novalidate を付け、submit で preventDefault してから自前で検証する。
- 文言は入力欄ごとに、valueMissing / tooShort / patternMismatch / typeMismatch
  などのフラグ別に用意し、input.validity のどれが立ったかで出し分ける。
  想定外の失敗は input.validationMessage に任せる。
- エラー文の枠は最初から DOM に置き、textContent の有無で見せる。
  あとから要素を挿入すると読み上げが変化に気づかないことがある、と理由を書く。
- 入力中は赤くしない。blur で検証し、
  一度 aria-invalid="true" が付いた欄だけ input でも再検証する。
- 送信時は最初の問題箇所へ focus を移し、件数を role="alert" の要素に出す。
03

パスワード要件の可視化

単独で開く ↗

条件を最初から全部見せ、満たしたものが順に緑になります。入力してから怒られるのではなく、入力しながら分かる形です。表示切替ボタンは aria-pressed と連動させています。

HTML
<form class="form" onsubmit="return false">
  <div class="field">
    <label for="pw">パスワード</label>
    <div class="pw-row">
      <input id="pw" name="pw" type="password" autocomplete="new-password"
        aria-describedby="pw-rules" />
      <button type="button" id="toggle" aria-pressed="false">表示</button>
    </div>

    <div class="meter" id="meter" data-score="0" aria-hidden="true">
      <span></span><span></span><span></span><span></span>
    </div>

    <ul class="rules" id="pw-rules">
      <li data-rule="len" data-met="false">10 文字以上</li>
      <li data-rule="mix" data-met="false">英字と数字の両方を含む</li>
      <li data-rule="sym" data-met="false">記号を 1 つ以上含む</li>
      <li data-rule="nospace" data-met="false">先頭と末尾に空白がない</li>
    </ul>
  </div>
</form>
CSS
.form {
  background: #fff; border: 1px solid var(--line);
  border-radius: 14px; padding: 1.4rem;
  display: grid; gap: 1rem;
}
.field { display: grid; gap: 0.35rem; }
.field label { font-size: 0.88rem; font-weight: 600; line-height: 1.7; }
.pw-row { display: flex; gap: 0.5rem; }
.pw-row input {
  flex: 1; min-width: 0;
  font: inherit; line-height: 1.7; color: inherit;
  padding: 0.55rem 0.75rem;
  border: 1px solid var(--line); border-radius: 9px;
}
.pw-row input:focus {
  outline: 0; border-color: var(--accent);
  box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.15);
}
.pw-row button {
  font: inherit; font-size: 0.84rem; line-height: 1.7; cursor: pointer;
  padding: 0 0.8rem; border: 1px solid var(--line);
  border-radius: 9px; background: #f2f4f9; color: var(--muted);
  white-space: nowrap;
}

/* 満たした条件を隠さず、最初から全部見せる。
   「押してから怒られる」体験を避けるのが目的。 */
.rules { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.2rem; }
.rules li {
  display: flex; align-items: baseline; gap: 0.5rem;
  font-size: 0.83rem; line-height: 1.8; color: var(--muted);
}
.rules li::before { content: "○"; color: var(--line); font-size: 0.9em; }
.rules li[data-met="true"] { color: var(--ok); }
.rules li[data-met="true"]::before { content: "●"; color: var(--ok); }

.meter { display: flex; gap: 4px; }
.meter span {
  height: 5px; flex: 1; border-radius: 999px; background: var(--line);
  transition: background-color 0.2s ease;
}
.meter[data-score="1"] span:nth-child(-n+1) { background: var(--danger); }
.meter[data-score="2"] span:nth-child(-n+2) { background: #d97706; }
.meter[data-score="3"] span:nth-child(-n+3) { background: #65a30d; }
.meter[data-score="4"] span { background: var(--ok); }
@media (prefers-reduced-motion: reduce) { .meter span { transition: none; } }
JavaScript
const pw = document.getElementById("pw");
const meter = document.getElementById("meter");
const items = [...document.querySelectorAll("[data-rule]")];

const RULES = {
  len: (v) => v.length >= 10,
  mix: (v) => /[a-z]/i.test(v) && /[0-9]/.test(v),
  sym: (v) => /[^\w\s]/.test(v),
  nospace: (v) => v.length > 0 && v === v.trim(),
};

pw.addEventListener("input", () => {
  const v = pw.value;
  let score = 0;
  for (const li of items) {
    const met = RULES[li.dataset.rule](v);
    li.dataset.met = String(met);
    if (met) score++;
  }
  meter.dataset.score = String(score);
});

// 表示切替。type を変えるだけだが、aria-pressed も必ず連動させる。
const toggle = document.getElementById("toggle");
toggle.addEventListener("click", () => {
  const shown = pw.type === "text";
  pw.type = shown ? "password" : "text";
  toggle.textContent = shown ? "表示" : "隠す";
  toggle.setAttribute("aria-pressed", String(!shown));
  pw.focus();
});
AIへの指示文
パスワード入力欄に、要件の達成状況をその場で示す仕組みを作ってください。
要件:
- 条件は最初からすべて一覧で見せる(10文字以上/英字と数字/記号/前後に空白なし)。
  満たした項目は data-met="true" にし、CSS 側で色と記号を変える。
- 4段の強度メーターを置き、親の data-score を 0〜4 で切り替えて
  span:nth-child(-n+N) で塗る。JavaScript から色を書かない。
- 表示切替ボタンは type を password と text で入れ替え、
  aria-pressed とラベルを必ず連動させる。切替後は入力欄へ focus を戻す。
- autocomplete="new-password" を指定する。
- prefers-reduced-motion: reduce ではメーターの transition を切る。
04

複数ステップのフォーム

単独で開く ↗

段は fieldset で区切り、hidden 属性で出し分けます。隠れた段の入力は検証対象から外れるので、いまの段だけを検証すれば済みます。現在地は aria-current で示します。

HTML
<form class="form" id="form" novalidate>
  <ol class="steps" id="steps">
    <li aria-current="step" data-done="false">1. お客様情報</li>
    <li data-done="false">2. お届け先</li>
    <li data-done="false">3. 確認</li>
  </ol>

  <fieldset class="step" data-step="0">
    <legend>お客様情報</legend>
    <div class="field">
      <label for="s-name">お名前</label>
      <input id="s-name" name="name" type="text" required minlength="2" autocomplete="name" />
    </div>
    <div class="field">
      <label for="s-mail">メールアドレス</label>
      <input id="s-mail" name="mail" type="email" required autocomplete="email" />
    </div>
  </fieldset>

  <fieldset class="step" data-step="1" hidden>
    <legend>お届け先</legend>
    <div class="field">
      <label for="s-zip">郵便番号</label>
      <input id="s-zip" name="zip" type="text" inputmode="numeric" required
        pattern="[0-9]{3}-?[0-9]{4}" autocomplete="postal-code" />
    </div>
    <div class="field">
      <label for="s-addr">住所</label>
      <input id="s-addr" name="addr" type="text" required autocomplete="street-address" />
    </div>
  </fieldset>

  <fieldset class="step" data-step="2" hidden>
    <legend>確認</legend>
    <p class="done" id="review">入力内容がここに表示されます。</p>
  </fieldset>

  <div class="actions">
    <button type="button" id="prev" hidden>戻る</button>
    <button type="button" id="next" class="primary">次へ</button>
    <button type="submit" id="submit" class="primary" hidden>この内容で送信</button>
  </div>
</form>
CSS
.form {
  background: #fff; border: 1px solid var(--line);
  border-radius: 14px; padding: 1.4rem;
}

/* 進捗。現在地は aria-current で示し、CSS はそれを見て塗る。
   見た目のためだけの class を増やさない。 */
.steps { list-style: none; display: flex; gap: 0.5rem; margin: 0 0 1.2rem; padding: 0; }
.steps li {
  flex: 1; font-size: 0.78rem; line-height: 1.7; color: var(--muted);
  padding-top: 0.5rem; border-top: 3px solid var(--line);
}
.steps li[aria-current="step"] { color: var(--accent); border-top-color: var(--accent); font-weight: 700; }
.steps li[data-done="true"] { color: var(--ok); border-top-color: var(--ok); }

/* fieldset で段を区切る。hidden 属性で出し分けるので、
   隠れている段の入力欄はフォーカスも検証も対象外になる。 */
.step { border: 0; padding: 0; margin: 0; display: grid; gap: 0.9rem; }
.step[hidden] { display: none; }
.step legend {
  padding: 0; margin-bottom: 0.2rem;
  font-size: 1.02rem; font-weight: 700; line-height: 1.6;
}

.field { display: grid; gap: 0.3rem; }
.field label { font-size: 0.86rem; font-weight: 600; line-height: 1.7; }
.field input, .field select {
  font: inherit; line-height: 1.7; color: inherit;
  padding: 0.55rem 0.75rem;
  border: 1px solid var(--line); border-radius: 9px;
}
.field input:focus, .field select:focus {
  outline: 0; border-color: var(--accent);
  box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.15);
}
.field input:user-invalid { border-color: #dc2626; }

.actions { display: flex; gap: 0.6rem; margin-top: 1.2rem; }
.actions button {
  font: inherit; line-height: 1.7; cursor: pointer;
  padding: 0.6rem 1.2rem; border-radius: 9px; border: 1px solid var(--line);
  background: #fff; color: var(--muted);
}
.actions button.primary {
  margin-left: auto; border-color: transparent;
  background: var(--accent); color: #fff; font-weight: 700;
}
.actions button[hidden] { display: none; }
.done { margin: 0; color: var(--ok); font-weight: 700; line-height: 1.8; }
JavaScript
const form = document.getElementById("form");
const steps = [...form.querySelectorAll(".step")];
const marks = [...document.getElementById("steps").children];
const prev = document.getElementById("prev");
const next = document.getElementById("next");
const submit = document.getElementById("submit");
const review = document.getElementById("review");
let i = 0;

function render() {
  steps.forEach((s, n) => (s.hidden = n !== i));
  marks.forEach((m, n) => {
    m.dataset.done = String(n < i);
    if (n === i) m.setAttribute("aria-current", "step");
    else m.removeAttribute("aria-current");
  });
  prev.hidden = i === 0;
  next.hidden = i === steps.length - 1;
  submit.hidden = i !== steps.length - 1;

  if (i === steps.length - 1) {
    const d = Object.fromEntries(new FormData(form));
    review.textContent =
      d.name + " 様 / " + d.mail + " / " + d.zip + " " + d.addr;
  }
  // 段を切り替えたら、その段の最初の見出しへ移動させる。
  // 何も動かさないと、読み上げ利用者には画面が変わったことが伝わらない。
  steps[i].querySelector("legend")?.scrollIntoView({ block: "nearest" });
  steps[i].querySelector("input")?.focus();
}

// hidden な fieldset の中の入力は検証対象外になるので、
// 「今の段だけ」を検証すれば済む。
function validCurrent() {
  const inputs = [...steps[i].querySelectorAll("input")];
  let ok = true;
  for (const input of inputs) {
    if (!input.checkValidity()) {
      if (ok) input.focus();
      ok = false;
    }
    // :user-invalid を効かせるため、送信を試みた状態にする
    input.dispatchEvent(new Event("blur"));
  }
  return ok;
}

next.addEventListener("click", () => {
  if (!validCurrent()) { form.reportValidity(); return; }
  i = Math.min(i + 1, steps.length - 1);
  render();
});
prev.addEventListener("click", () => {
  i = Math.max(i - 1, 0);
  render();
});
form.addEventListener("submit", (e) => {
  e.preventDefault();
  alert("送信しました(サンプルなので実際には送信されません)");
});

render();
AIへの指示文
3段構成のステップフォームを作ってください。
要件:
- 各段は fieldset と legend で区切り、hidden 属性で出し分ける。
  hidden の中の入力は検証対象外になるので、いまの段だけ検証すればよい、
  という理由をコメントで書く。
- 進捗表示は ol と li。現在地は aria-current="step"、
  通過済みは data-done="true" にして、CSS 側でそれを見て塗る。
  見た目のためだけの class を増やさない。
- 「次へ」は現在の段の入力を checkValidity で確認し、
  通らなければ最初の問題箇所へ focus して進まない。
- 最終段では入力内容を FormData から組み立てて確認欄に出し、
  「次へ」を隠して「送信」を出す。
- 段を切り替えたら、その段の最初の入力へ focus を移す。
  画面が変わったことが読み上げ利用者にも伝わるようにするため。

この技術について

入力検証でライブラリを探す前に、HTMLだけでどこまでできるかを知っておくと設計が変わります。requiredtypepatternminlengthminmax。これらはブラウザが解釈し、送信を止め、問題のある欄へフォーカスを移し、メッセージまで出してくれます。JavaScriptが必要になるのは、その文言を自分の言葉に置き換えたいときからです。

「まだ触っていないのに赤い」を消す

標準機能を使うときに、ひとつだけ知っておくべき落とし穴があります。:invalid 疑似クラスは、ページを読み込んだ瞬間から当たります。必須の空欄はすべて invalid なので、何もしていないのにフォーム全体が赤く光ることになる。

これを解くのが :user-invalid です。利用者が実際に入力したか、送信を試みたあとにだけ当たります。:invalid:user-invalid に書き換えるだけで、「まだ何もしていないのに怒られた」という体験が消えます。対になる :user-valid もあるので、条件を満たした欄を緑にすることもできます。

invalid は最初から成立するが、user-invalid は利用者が触ってから成立する :invalid だけ お名前 まだ何も打っていないのに赤い :user-invalid お名前 触ってから初めて赤くなる 表示前 入力中 離れた ここで赤くする :invalid は最初の瞬間から成立してしまう。 利用者が触ったかどうかを見ているのが :user-invalid
最初から赤い画面は、まだ何もしていない人を責めます。旧来は「触ったか」をクラスで持って自前で管理していましたが、いまはブラウザ側が覚えています。

いつ検証するか、が体験を決める

タイミングの設計は、文言そのものより効きます。

入力中にリアルタイムで検証すると、メールアドレスを打ち始めた瞬間から赤くなります。正しく入力している途中の人を急かすだけで、何の役にも立ちません。かといって送信するまで何も言わないのも不親切です。

実務で摩擦が少ないのは、一度フォーカスを外したときに検証し、そこでエラーになった欄に限って入力中も再検証する形です。まだ触っていない欄は静かなまま、直している最中の欄だけが「直った」ことを教えてくれる。この記事の独自エラー文のサンプルは、この方針で書いてあります。

エラーを出す前は離れたときに検証し、出したあとは入力のたびに見直す 入力のたびに検証 t → 赤 ta → 赤 tar → 赤 打つそばから責められる 離れたときに検証 taro… (静か) 離れた → ここで判定 書き終わってから知らせる 一度エラーを出したあとは、入力のたびに見直す 直している最中に赤が消えれば、直ったことがその場で分かる。ここだけは即時でよい
同じ検証でも、出す前と出したあとで適切な間隔が違います。最初は書き終わるまで黙って待ち、一度指摘したあとは直った瞬間に取り下げる。この非対称が効きます。

パスワードは、条件を先に全部見せる

パスワードの要件を隠しておいて、送信してから「記号が必要です」と言うのは最悪の体験です。条件は最初からすべて並べ、満たしたものが順に緑になる形にしてください。入力してから怒られるのではなく、入力しながら分かる。

このとき、強度メーターの色をJavaScriptから直接書かないのがコツです。親要素に data-score="3" のような属性を置き、色はCSS側で決める。JavaScriptは状態を宣言するだけにしておくと、配色を変えたいときにCSSだけで済みます。

パスワードの条件は先に全部見せ、満たしたものから消していく 押してから知らせる 登録する 8文字以上にしてください 何度も往復する 先に全部見せる ✓ 8文字以上 ✓ 数字を含む ・記号を含む 満たした条件から順に消えていく 条件は入力欄の前に置く。読み上げでも「何を求められているか」が先に届く 満たした/満たしていない は aria-live ではなく、記号と色の両方で示す
パスワードはやり直しの回数がそのまま離脱につながります。条件を隠して1つずつ指摘するより、最初から全部見せて減らしていくほうが速く終わります。

段を分けたフォームは、隠し方が要点

長いフォームを複数ステップに分けるとき、段を hidden 属性で出し分けると都合が良いことがあります。hidden の中にある入力は検証の対象から外れるので、「いまの段だけを検証する」という処理が自然に書けるからです。display: none でも同様ですが、属性で状態を持つほうがCSSとJavaScriptの責務が分かれます。

もう一つ忘れやすいのが、段を切り替えたあとのフォーカスです。画面の中身が入れ替わっても、フォーカスが元の場所に残っていると、読み上げを使っている人には何が起きたのか伝わりません。新しい段の最初の入力へフォーカスを移してください。数行の処理ですが、これが無いとステップフォームは事実上使えなくなります。

段を隠すとき、隠した段の必須項目が原因で送信が黙って止まる display:none で隠す 段1(表示中) 段2・段3 は DOM から消える → 未入力の必須項目に飛べない hidden 属性で隠す 段1(表示中) 段2・段3 は残るが不活性 → 送信時の検証も効く 隠した段に必須項目があると、ブラウザは 「見えないのに埋めろ」と言えず、送信が黙って止まる 対処は2つ。隠す段の入力を disabled にするか、その段だけ検証を外す
多段フォームで「押しても何も起きない」の原因はほぼこれです。エラーの吹き出しを出す先が画面に無いため、ブラウザは黙って止まります。

最後に、これは安全のための仕組みではない

HTMLの検証は入力の手間を減らす補助であって、防御ではありません。開発者ツールで属性を消せば素通りしますし、そもそもフォームを経由しないリクエストも送れます。サーバ側の検証は必ず別途実装してください。ここを混同すると、見た目は丁寧なのに中身は無防備、という状態になります。

ブラウザの検証は打ち間違いを減らすためのもので、サーバ側の検証の代わりにはならない ブラウザの検証 打ち間違いを減らす 利用者のための機能 サーバ側の検証 不正な値を通さない 安全のための仕組み required も pattern も、開発者ツールで消せる。curl なら最初から通らない 前者は後者の代わりにならない。省略していいのは「見た目の親切さ」のほうではない
混同すると危険な場所です。画面側の検証は、正しく入力しようとしている人を助けるためのもので、悪意のある入力を止める手段ではありません。

使いどころとつまずきどころ

向いている場面

  • お問い合わせ・申込み・会員登録のフォーム
  • JavaScriptの読み込み前でも検証を効かせたい場面
  • 入力の途中で赤くしたくない体験を作りたいとき
  • 複数ステップに分けた長いフォーム

つまずきやすい点

  • :invalid は初期表示から当たる。:user-invalid を使う
  • pattern だけに頼るとエラーの理由が伝わらない
  • エラー文はaria-describedbyで入力欄に結び付ける
  • サーバ側の検証は必ず別途行う。HTMLの検証は補助

よくある質問

:invalid ではなく :user-invalid を使うべきなのはなぜですか。

:invalid は読み込んだ瞬間から当たるので、まだ何も触っていない必須欄が最初から赤くなります。これは「まだ何もしていないのに怒られた」という体験を生みます。:user-invalid は利用者が入力するか送信を試みたあとにだけ当たるので、この問題が起きません。

HTMLの検証だけでサーバ側の検証は省けますか。

省けません。HTMLの検証は入力の手間を減らす補助であって、安全のための仕組みではありません。開発者ツールで属性を消せば素通りしますし、フォームを経由しないリクエストも送れます。サーバ側の検証は必ず別途実装してください。

エラー文はどこに置くのが良いですか。

入力欄のすぐ下に置き、aria-describedby で入力欄と結び付けてください。上部にまとめるだけだと、読み上げ利用者が入力欄にフォーカスしたときに理由が伝わりません。件数の要約を上部に置き、個別の理由を各欄の下に置く、の両方が理想です。

入力中にリアルタイムで検証すべきですか。

最初の1回はやめてください。メールアドレスを打ち始めた瞬間から赤くなるのは、正しく入力している途中の人を急かすだけです。一度フォーカスを外して検証し、そこでエラーになった欄に限って入力中も再検証する。この形が最も摩擦が少なくなります。

同じ技術を使った完成例を、画面の型ごとにまとめています。作りたい画面が決まっているなら、こちらから探すほうが早いはずです。

ほかの技術