Reactで、ワンタイムパスワードを1桁ずつの欄で入力する方法

​​React​Playwright,TypeScript,フォーム

ワンタイムパスワードの6桁を、1桁ずつ分かれた6つの欄で入力するコンポーネントを React で作りました。数字を打つと次の欄へフォーカスが進み、空の欄で Backspace を押すと前の欄へ戻ります。メールなどからコピーしたコードを貼り付けると、6つの欄に1桁ずつ振り分けます。

6つの欄の値は、親のコンポーネントが1つの配列で持ちます。欄どうしの移動は、それぞれの <input> を ref で持っておき、focus() を呼んで行います。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。SMS で届いたコードの自動入力は、この環境では試せません。代わりに、6文字を一度に入れる操作(Playwright の insertText)で、まとめて値が入る場合を確かめています。iPhone や Android の実機、Firefox と Safari、日本語入力(IME)で変換しながら打ったときの動きは試していません。

6つの欄に 1、2 と打つとフォーカスが次の欄へ進み、a を打っても何も入らない。空の3桁目で Backspace を押すと2桁目の 2 が消えて2桁目に戻り、5 を打つ。続けて「3456」を貼り付けると3〜6桁目に入って「6桁そろいました:153456」と出て、「確認する」を押すと「送信したコード:153456」と出る
1桁ずつ打つ、数字以外を打つ、Backspace で戻る、貼り付ける、送信する様子。値が変わった欄と下の表示を、その瞬間だけピンクにしている(左上の説明、右上の回数、右下のキー、カーソルと押した位置の輪は GIF 用に重ねたもの)

6つの欄の値を、1つの配列で持つ

コンポーネントの OtpInput は、6つの欄の値を ['1', '5', '', '', '', ''] のような配列で受け取ります。1つの欄に1文字で、空の欄は空文字です。値を1つの文字列('15')で持つ書き方もありますが、途中の欄だけが空になった場合(3桁目だけを消したときなど)を表しにくいため、配列にしました。

欄の数は、渡された配列の長さで決まります。欄ごとに useRef を呼ぶことはできないので、ref は配列を1つ用意し、ref に渡す関数(ref コールバック)の中で、それぞれの <input> を入れていきます。React の公式ドキュメントにある、ref コールバックで ref のリストを管理する方法と同じ考え方です。ref コールバックのリファレンスにあるとおり、React 19 では、ref コールバックが返した関数を、ref を外すときの片付けの関数として呼びます。代入の結果が戻り値にならないよう、{ } で囲んでいます。

tsx
import { useRef, type ChangeEvent, type ClipboardEvent, type KeyboardEvent } from 'react';

// 全角の数字を半角にしてから、数字以外を取り除く
function onlyDigits(s: string) {
  return s.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)).replace(/\D/g, '');
}

type Props = {
  value: string[]; // 1欄に1文字。空の欄は ''
  onChange: (value: string[]) => void;
  onComplete?: (code: string) => void;
  labelledBy: string; // まとめて読み上げる見出しの id
};

export function OtpInput({ value, onChange, onComplete, labelledBy }: Props) {
  const length = value.length;
  const refs = useRef<(HTMLInputElement | null)[]>([]);

  const focusAt = (i: number) => refs.current[Math.max(0, Math.min(i, length - 1))]?.focus();

  // start の欄から順に数字を入れ、入れ終わった次の欄へフォーカスを移す
  const fillFrom = (start: number, digits: string) => {
    const next = [...value];
    let pos = start;
    for (const d of digits) {
      if (pos >= length) break;
      next[pos++] = d;
    }
    onChange(next);
    focusAt(pos);
    if (next.every((d) => d !== '')) onComplete?.(next.join(''));
  };

  const handleChange = (i: number, e: ChangeEvent<HTMLInputElement>) => {
    const raw = e.target.value;
    if (raw === '') {
      // Backspace などで欄が空になった
      onChange(value.map((d, j) => (j === i ? '' : d)));
      return;
    }
    // 欄の値ではなく、今回入った文字(InputEvent の data)を使う。
    // すでに数字がある欄に打つと値は「37」のようになるが、data は打った「7」だけになる
    const inserted = (e.nativeEvent as InputEvent).data;
    const digits = onlyDigits(inserted ?? raw);
    if (digits === '') return; // 数字以外のキーは受け付けない
    fillFrom(i, digits);
  };

  const handleKeyDown = (i: number, e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === value[i]) {
      // 入っている数字と同じ数字を打っても値が変わらず onChange が呼ばれないので、ここで次へ進む
      e.preventDefault();
      focusAt(i + 1);
    } else if (e.key === 'Backspace' && value[i] === '' && i > 0) {
      // 空の欄で Backspace を押したら、1つ前の欄を消してそこへ戻る
      e.preventDefault();
      onChange(value.map((d, j) => (j === i - 1 ? '' : d)));
      focusAt(i - 1);
    } else if (e.key === 'ArrowLeft') {
      e.preventDefault();
      focusAt(i - 1);
    } else if (e.key === 'ArrowRight') {
      e.preventDefault();
      focusAt(i + 1);
    }
  };

  const handlePaste = (i: number, e: ClipboardEvent<HTMLInputElement>) => {
    e.preventDefault();
    const digits = onlyDigits(e.clipboardData.getData('text'));
    if (digits !== '') fillFrom(i, digits);
  };

  return (
    <div role="group" aria-labelledby={labelledBy} style={{ display: 'flex', gap: '8px' }}>
      {value.map((d, i) => (
        <input
          key={i}
          ref={(el) => {
            refs.current[i] = el;
          }}
          type="text"
          inputMode="numeric"
          autoComplete={i === 0 ? 'one-time-code' : 'off'}
          aria-label={`${i + 1}桁目`}
          value={d}
          onChange={(e) => handleChange(i, e)}
          onKeyDown={(e) => handleKeyDown(i, e)}
          onPaste={(e) => handlePaste(i, e)}
          onFocus={(e) => e.target.select()}
          style={{ width: '2.4em', height: '2.8em', textAlign: 'center', fontSize: '20px', border: '1px solid #9aa5ab', borderRadius: '6px' }}
        />
      ))}
    </div>
  );
}

中心になるのは fillFrom() です。指定した欄から順に数字を入れ、入れ終わった次の欄へフォーカスを移します。1文字打ったときも、貼り付けたときも、この関数を通ります。6つの欄がすべて埋まったら、onComplete に6桁のコードを渡します。

打った数字で次の欄へ進み、数字以外は入れない

欄に文字が入ると onChange が呼ばれます。handleChange() は、今回入った文字から数字だけを取り出し、fillFrom() で値を更新して次の欄へ進みます。数字が1つも残らなければ、何もせずに終わります。値は親が持つ配列のままなので、打った「a」は欄に残りません。全角の「1」は、半角の「1」に直してから受け付けます。

Chromium で、1桁目から順に打った結果です。

text
1 を打つ                          [1 _ _ _ _ _] フォーカス=2桁目 ボタン=押せない 表示=-
a を打つ                          [1 _ _ _ _ _] フォーカス=2桁目 ボタン=押せない 表示=-
2 を打つ                          [1 2 _ _ _ _] フォーカス=3桁目 ボタン=押せない 表示=-
3456 を打つ                       [1 2 3 4 5 6] フォーカス=6桁目 ボタン=押せる 表示=6桁そろいました:123456

[ ] の中は6つの欄の値で、空の欄は _ です。6桁目まで埋まると、フォーカスは6桁目に残り、onComplete で受け取ったコードが表示されました。

すでに数字が入っている欄に打つときは、前の数字を置き換えます。そのために、欄にフォーカスが来たら select() で中の文字を選択しておきます。MDN の select() の説明のとおり、select() は入力欄の文字をすべて選択します。選択された状態で打てば、前の数字は打った数字に置き換わります。検証環境では、2桁目をクリックしたときの選択範囲は 0〜1(1文字全体)で、「7」を打つと2桁目が「7」になりました。

選択が外れ、カーソルが数字の後ろにある状態で打つと、欄の値は「37」のように2文字になります。そこで handleChange() では、欄の値ではなく、今回入った文字だけを使います。MDN の InputEvent の data の説明のとおり、input イベントの data で、新しく入った文字を受け取れます。React の onChange では、React のイベントオブジェクトの nativeEvent から読みます。data は null のこともあると MDN にあるので、そのときは欄の値全体を使います。検証では、2桁目の末尾にカーソルを置いて「5」を打つと、2桁目が「5」に置き換わり、3桁目へ進みました。

入っている数字と同じ数字を打ったときは、欄の値が変わりません。onKeyDown の処理を除いた版で、「5」の入った2桁目を選択して「5」を打つと、2桁目でブラウザの input イベントは1回起きましたが、フォーカスは2桁目のままでした。フォーカスが進まなかったことから、この入力では onChange が呼ばれなかったことが分かります。そこで、押されたキーが欄の数字と同じなら、onKeyDown で次の欄へ進めています。

text
記事の版:2桁目の 5 を選択して 5            [1 5 _ _ _ _] フォーカス=3桁目 ボタン=押せない 表示=-
  2桁目で input イベントが起きた回数: 0
nosamekey.html:2桁目の 5 を選択して 5  [1 5 _ _ _ _] フォーカス=2桁目 ボタン=押せない 表示=-
  2桁目で input イベントが起きた回数: 1

どちらも、1桁目と2桁目に「15」と入れたあと、2桁目をクリックして「5」を打っています。nosamekey.html が、onKeyDown の処理を除いた版です。記事の版では、onKeyDown で preventDefault() を呼ぶので、input イベントは起きず、フォーカスが3桁目へ進みました。

Backspace で前の欄に戻り、矢印キーで欄を移る

数字が入っている欄で Backspace を押したときは、ブラウザの通常の動きで文字が消え、onChange に空文字が届きます。handleChange() は、その欄を空にするだけで、フォーカスは動かしません。

空の欄では消す文字がないので、Backspace を押しても欄の値は変わりません。そこで onKeyDown で Backspace を受け取り、1つ前の欄を空にしてフォーカスを戻します。左右の矢印キーでも、隣の欄へ移れるようにしました。

text
6桁目で Backspace                 [1 2 3 4 5 _] フォーカス=6桁目 ボタン=押せない 表示=-
空の6桁目で Backspace               [1 2 3 4 _ _] フォーカス=5桁目 ボタン=押せない 表示=-
← を2回                          [1 2 3 4 _ _] フォーカス=3桁目 ボタン=押せない 表示=-
9 を打つ(3桁目を上書き)                 [1 2 9 4 _ _] フォーカス=4桁目 ボタン=押せない 表示=-

1回目の Backspace で6桁目が消え、2回目で5桁目が消えてフォーカスが5桁目に移りました。矢印キーで3桁目へ移って「9」を打つと、「3」が「9」に置き換わっています。

貼り付けたコードは、6つの欄に振り分ける

コードをコピーして貼り付けたときは、onPaste で受け取ります。MDN の paste イベントの説明にあるとおり、貼り付けられる文字は clipboardData.getData() で読めます。通常の貼り付けを preventDefault() で止め、取り出した数字を fillFrom() で入れます。

「123-456」のように数字以外の文字が混じっていても、数字だけを取り出して入れます。貼り付けた欄から後ろに入るので、4桁目で6桁を貼り付けると、入るのは4〜6桁目の3つだけです。

text
1桁目で「123-456」を貼り付け             [1 2 3 4 5 6] フォーカス=6桁目 ボタン=押せる 表示=6桁そろいました:123456
4桁目で「987654」を貼り付け              [1 2 3 9 8 7] フォーカス=6桁目 ボタン=押せる 表示=6桁そろいました:123987
1桁目で「abc」を貼り付け                 [1 2 3 9 8 7] フォーカス=1桁目 ボタン=押せる 表示=6桁そろいました:123987

数字を含まない「abc」を貼り付けたときは、どの欄も変わりませんでした。

maxLength={1} を付けずに、まとめて入る値を受け取る

1つの欄に1文字なので maxLength={1} を付けたくなりますが、OtpInput では付けていません。付けると、6桁がまとめて1つの欄に入ってきたときに、2文字目から先が切り捨てられるからです。

まとめて入ってくるのは、ブラウザの自動入力でコードが入る場合です。1桁目に付けた autoComplete="one-time-code" は、MDN の autocomplete 属性の説明で、ワンタイムパスワードの欄であることを表す値とされています。コードは、SMS やメール、認証アプリで受け取ることが多いものです。MDN の WebOTP API の説明によると、正しい形式の SMS が届くと、Safari がこの欄へのコードの自動入力を提案します。one-time-code を付けたのは1桁目だけなので、自動入力のコードは1桁目にまとめて入ると考え、handleChange() で6つの欄に振り分けます。

実機の自動入力は試せないため、1桁目にフォーカスを置いて「123456」の6文字を一度に入れ(Playwright の insertText)、maxLength={1} を付けた版と比べました。

text
1桁目に「123456」                   [1 2 3 4 5 6] フォーカス=6桁目 ボタン=押せる 表示=6桁そろいました:123456
1桁目に全角の「123456」                [1 2 3 4 5 6] フォーカス=6桁目 ボタン=押せる 表示=6桁そろいました:123456
1桁目の末尾にカーソルで「123456」           [1 2 3 4 5 6] フォーカス=6桁目 ボタン=押せる 表示=6桁そろいました:123456
maxLength={1} の版で「123456」      [1 _ _ _ _ _] フォーカス=2桁目 ボタン=押せない 表示=-
maxLength={1} の版で貼り付け          [1 2 3 4 5 6] フォーカス=6桁目 ボタン=押せる 表示=6桁そろいました:123456

3行目は、「1」が入っている1桁目の末尾にカーソルを置いて入れた場合です。data の「123456」だけを使うので、もとの「1」は使われず、6桁がそのまま入りました。

maxLength={1} を付けた版では、1桁目に「1」だけが入り、残りの5文字は消えました。MDN の maxlength 属性の説明にあるとおり、ブラウザが上限を超える入力を止めたためです。貼り付けは onPaste で止めて自分で入れているので、maxLength があっても6桁が入りました。

実際の iPhone や Android で、自動入力のコードがどのような形で欄に入るか(入力のイベントが1回で届くか、など)は確かめていません。

入力欄に付ける属性

inputMode="numeric" は、スマートフォンで数字のキーボードを出すための指定です。MDN の inputmode の説明では、0〜9 の数字だけが必要な入力に使う値とされています。スマートフォンで実際に出るキーボードは確かめていません。

スクリーンリーダーで6つの欄がひとまとまりとして伝わるように、6つの欄を role="group" の <div> で囲み、aria-labelledby で「確認コード(6桁)」の見出しと結びつけました。MDN の group ロールの説明にあるように、関連する要素をひとまとまりとして扱わせるためのロールです。それぞれの欄には aria-label で「1桁目」〜「6桁目」の名前を付けています。Playwright で getByRole('group', { name: '確認コード(6桁)' }) を探すと、1つ見つかりました。スクリーンリーダーでの読み上げは試していません。

使う側では、6つ埋まるまで送信できなくする

使う側では、6つの空文字の配列を state に持ち、OtpInput に渡します。この例では、受け取ったコードを画面に出すだけで、サーバーには送っていません。すべての欄が埋まるまで「確認する」のボタンを押せないようにし、書き直したら前の表示を消しています。

tsx
import { useState, type FormEvent } from 'react';
import { OtpInput } from './OtpInput';

const empty = () => Array<string>(6).fill('');

export function App() {
  const [digits, setDigits] = useState(empty);
  const [message, setMessage] = useState('');
  const complete = digits.every((d) => d !== '');

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setMessage(`送信したコード:${digits.join('')}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <p id="otp-label">確認コード(6桁)</p>
      <OtpInput
        value={digits}
        onChange={(next) => {
          setDigits(next);
          setMessage(''); // 書き直したら、前の表示を消す
        }}
        onComplete={(code) => setMessage(`6桁そろいました:${code}`)}
        labelledBy="otp-label"
      />
      <button type="submit" disabled={!complete}>確認する</button>
      <p role="status">{message}</p>
    </form>
  );
}

OtpInput の fillFrom() は、onChange を呼んだあとに onComplete を呼びます。どちらも同じイベントの処理の中で呼ばれます。React の公式ドキュメントにある state の更新のまとめ方の説明のとおり、イベントの処理が終わるまで画面は描き直されません。setMessage('') のあとに setMessage('6桁そろいました…') が続くので、画面には後の表示が出ます。検証では、6桁目まで打ったときに「6桁そろいました:123456」が表示され、そこから6桁目を Backspace で消すと、表示が消えてボタンが押せなくなりました。

​Posted by 86