GODRICH

認証コード 入力画面が直面する9つの状況 バリデーションまで対応

認証コード 入力画面は、6桁の数字を1マスずつ入力させ、桁移動や貼り付け、誤入力の処理までUIとして作り込む部品です。この記事では9つの状況を選び、バニラJSとReactの両方で実装しました。格子に並ぶ9つはiframeの中で最初から動いていて、クリックでは操作できません。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は派手さではなく、入力を受け取る仕組みが積み重なる順です。まず1マスごとの自動移動と逆方向の削除という基本の2つから始め(01〜02)、貼り付けと端末側の入力方式という外から来る入力の扱いを重ね(03〜04)、見た目の切り替えと失敗時の演出という状態の表現に進み(05〜06)、最後に時間経過とOS連携、モーション設定の尊重という実際のログイン画面に近い場面を並べました(07〜09)。9つとも1マスにつき1文字までしか受け付けないinputを並べ、Reactでは同じ振る舞いをuseStateの配列ひとつで管理しています。

01次のマスへ自動移動

数字を1つ入力すると、その場でカーソルが自動的に次のマスへ移動します。会員登録の認証やログインの二段階認証のように、6桁を素早く連続入力させたい場面に向いています。

6칸자동 포커스숫자만
boxes.forEach(function (b, i) {
  b.addEventListener('input', function () {
    b.value = b.value.replace(/[^0-9]/g, '').slice(-1);
    if (b.value && boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
  });
});

02バックスペースで前のマスへ

空のマスでバックスペースを押すと、カーソルが前のマスに戻ってそこに入っていた数字を消します。認証コードを打ち間違えたときに、消してから打ち直す動きに向いています。

백스페이스이전 칸연쇄 삭제
boxes.forEach(function (b, i) {
  b.addEventListener('keydown', function (e) {
    if (e.key === 'Backspace' && !b.value && boxes[i - 1]) {
      boxes[i - 1].focus({ preventScroll: true });
    }
  });
});

03コード全体を貼り付けて分配

メールアプリなどでコピーした6桁のコードを1マス目に貼り付けると、数字だけを取り出して各マスに1文字ずつ配ります。メールで届いた認証コードをそのまま貼り付けたいときに向いています。

clipboard6칸 분배숫자 외 무시
b.addEventListener('paste', function (e) {
  var t = (e.clipboardData || window.clipboardData).getData('text').replace(/[^0-9]/g, '');
  if (!t) return;
  e.preventDefault();
  t.slice(0, 6).split('').forEach(function (ch, k) { if (boxes[k]) boxes[k].value = ch; });
  (boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});

04モバイル数字キーパッド固定

スマートフォンでマスをタップすると、文字キーボードではなく数字だけのキーパッドがすぐに開きます。inputmode="numeric"とpattern="[0-9]*"を各inputに付けるだけで実現でき、詳しい仕様はMDNのinputmode属性の解説にあります。

inputmode numericpattern [0-9]*텐키
<input
  type="text"
  inputmode="numeric"
  pattern="[0-9]*"
  maxlength="1"
  autocomplete="one-time-code"
  aria-label="認証コード 1番目の桁">

05マスク表示切替

目のアイコンのようなボタンを押すと、それまで点で隠れていた数字がそのまま見える状態に切り替わります。銀行アプリの口座暗証番号や決済PINのように、周りの目を気にする場面に向いています。

마스킹토글점 대체
var toggle = pin.querySelector('.pin__mask-toggle');
var masked = true;
toggle.addEventListener('click', function () {
  masked = !masked;
  boxes.forEach(function (b) { b.type = masked ? 'password' : 'text'; });
  toggle.setAttribute('aria-pressed', String(!masked));
  toggle.textContent = masked ? '数字を表示' : '再び隠す';
});

06エラー時に揺れてクリア

確認ボタンを押したときにコードが間違っていると、6マス全体が横に揺れてから自動的に空になります。認証に失敗したことを一目で伝え、そのまま打ち直させたい場面に向いています。

흔들림 300ms자동 비움빨간 테두리
confirmBtn.addEventListener('click', function () {
  var code = boxes.map(function (b) { return b.value; }).join('');
  if (code.length < 6 || code !== '123456') {
    pin.classList.add('is-error');
    status.hidden = false;
    pin.addEventListener('animationend', function clear() {
      pin.classList.remove('is-error');
      boxes.forEach(function (b) { b.value = ''; });
      boxes[0].focus({ preventScroll: true });
      pin.removeEventListener('animationend', clear);
    });
  }
});

07再送信タイマー

再送信ボタンは60秒のカウントダウンが0になるまで押せず、数字だけが1秒ごとに減っていきます。SMS認証コードの再送信を、連打によるスパムを防ぎながら受け付けたいときに向いています。

60초비활성카운트다운
var resend = pin.querySelector('.pin__resend');
var left = 60;
function tick() {
  resend.textContent = left > 0 ? left + '秒後に再送信' : '再送信';
  resend.disabled = left > 0;
  if (left > 0) { left--; setTimeout(tick, 1000); }
}
resend.addEventListener('click', function () { if (!resend.disabled) { left = 60; tick(); } });

08SMS OTP自動入力

SMSで届いた認証コードをOSが検知すると、キーボード上部の候補をタップするだけで6マスすべてが一度に埋まります。iOSやAndroidでのSMS認証コードの自動入力に向いていて、MDNのautocomplete属性の解説にはone-time-codeという値がこの用途のために用意されていると書かれています。

autocompleteone-time-codeOS 감지
<input type="text" inputmode="numeric" pattern="[0-9]*"
       maxlength="1" autocomplete="one-time-code"
       aria-label="認証コード 1番目の桁">
<input type="text" inputmode="numeric" pattern="[0-9]*"
       maxlength="1" autocomplete="one-time-code"
       aria-label="認証コード 2番目の桁">

09モーション削減設定時は静的フォーカス

システムのモーション削減設定が有効な端末で開くと、アニメーションなしでカーソルが次のマスへ即座に移動します。前庭機能に敏感な利用者のために、OSの設定をそのまま尊重したい場面に向いています。

prefers-reduced-motion즉시 이동정적 포커스
const [reduced, setReduced] = useState(false);
useEffect(() => {
  if (!respectReducedMotion) return;
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
  setReduced(mq.matches);
  const onChange = () => setReduced(mq.matches);
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}, [respectReducedMotion]);

どこで崩れるか — 落とし穴

9つとも同じ.pin__caretという要素をデモの中で使い、ループするアニメーションでマスの上を移動する光の帯として次に入力する位置を示しています。1つ目の落とし穴は09を確認しているときに気づいたもので、prefers-reduced-motion: reduceのブロックは.pin__caretのanimationとtransitionを止めるだけで、.pin__ghost.pin__signalのようにopacityを1へ戻す行を持っていません。その結果、最初の版ではモーションを減らした端末で光の帯が一度も見えないまま止まっていました。zipに入っている版では.pin.is-demo .pin__caretにopacityとtransformの静止状態を足して、動かないまま位置だけ示すように直してあります。

2つ目は06のエラー演出を確認しているときのことです。確認ボタンを押してコードが間違っているとstatus.hiddenをfalseにして「コードが一致しません」という文章を表示しますが、最初の版では揺れが終わって入力が空に戻ったあともstatus.hiddenを再びtrueへ戻す行がなく、エラー文が出っぱなしでした。zipの版では各マスのinputイベントで文を隠すように直してあります。直した9ファイルは 39kzjrs9 という文字列でロックを解いて開けます。下のカードからzipを取ってください。

3つ目は03の貼り付けを試したときのことです。clipboardData.getData('text')で取り出した文字列から数字以外をすべて取り除いてから配っているため、コピー元に空白やハイフンが混ざった「123-456」のような文字列を貼り付けても、記号は無視されて6マスへ正しく分配されました。

アクセシビリティ

9つとも各マスのinputaria-label="認証コード N番目の桁"を付け、外側のdivにはrole="group"aria-label="認証コード入力"を与えているため、スクリーンリーダーは今どの桁を読んでいるかを一つずつ伝えられます。キーボードだけでもTabで次のマスへ進み、02のバックスペースで前のマスへ戻れるので、マウスやタップに頼らずに6桁を入力し終えられます。06のエラー文はrole="status"を持つ段落なので、スクリーンリーダーはページ全体を読み直さずに失敗だけを割り込んで読み上げます。モーション削減の設定は次のCSSでまとめて止めていて、対象からわざと外れている.pin__caretが上の落とし穴として残っています。

@media (prefers-reduced-motion: reduce) {
  .pin__ghost, .pin__caret, .pin__signal,
  .pin__row, .pin__resend, .pin__input {
    animation: none !important;
    transition: none !important;
  }
  .pin.is-demo .pin__ghost { opacity: 1; }
  .pin.is-demo .pin__signal { opacity: 1; transform: scale(1); }
}

入力UIの他の作例はこちらのカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。

FAQ

9つを同じページに並べても動きは干渉しませんか?

干渉しません。それぞれの.pin要素は自分の中のinputだけをdata-pin-rowの範囲でquerySelectorしているため、01の自動移動と07のタイマーを隣に置いても、片方のsetTimeoutがもう片方のマスへ影響することはありません。格子のiframeも1つずつ独立した文書として読み込まれているので、9つ同時に自動再生していても値が混ざることはありません。

Reactでは何を使って認証コードの状態を管理しますか?

zipのreact/フォルダの各コンポーネントは、6要素の配列をuseStateひとつで持ち、lengthonCompletecolorclassNameという共通のpropsを受け取ります。05は追加でmasked、06はexpectedonError、07はresendSecondsonResend、09はrespectReducedMotionというpropsを持ち、フォーカス移動そのものはclosest('[data-pin-row]')で兄弟のinputを探して呼び出す共通の関数が担っています。

スマホでも同じように動きますか?

はい、01から07までと09はiOSでもAndroidでも同じ順番でマスが埋まります。08のSMS自動入力は、iOSのSafariがautocomplete="one-time-code"を持つ入力の並びを検出してキーボード上部に候補を出す仕組みで、Androidでも同じ属性に加えてブラウザ側のSMS読み取り機能が使われるため、どちらの端末でも手で6桁を打たずに済みます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。