Reactで、ワンタイムパスワードを1桁ずつの欄で入力する方法
ワンタイムパスワードの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つの配列で持つ
コンポーネントの OtpInput は、6つの欄の値を ['1', '5', '', '', '', ''] のような配列で受け取ります。1つの欄に1文字で、空の欄は空文字です。値を1つの文字列('15')で持つ書き方もありますが、途中の欄だけが空になった場合(3桁目だけを消したときなど)を表しにくいため、配列にしました。
欄の数は、渡された配列の長さで決まります。欄ごとに useRef を呼ぶことはできないので、ref は配列を1つ用意し、ref に渡す関数(ref コールバック)の中で、それぞれの <input> を入れていきます。React の公式ドキュメントにある、ref コールバックで ref のリストを管理する方法と同じ考え方です。ref コールバックのリファレンスにあるとおり、React 19 では、ref コールバックが返した関数を、ref を外すときの片付けの関数として呼びます。代入の結果が戻り値にならないよう、{ } で囲んでいます。
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桁目から順に打った結果です。
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 で次の欄へ進めています。
記事の版: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つ前の欄を空にしてフォーカスを戻します。左右の矢印キーでも、隣の欄へ移れるようにしました。
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つだけです。
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} を付けた版と比べました。
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 に渡します。この例では、受け取ったコードを画面に出すだけで、サーバーには送っていません。すべての欄が埋まるまで「確認する」のボタンを押せないようにし、書き直したら前の表示を消しています。
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 で消すと、表示が消えてボタンが押せなくなりました。





