【React】OTP入力欄(6桁の確認コード)の作り方|自動で次の箱へ進む動きから貼り付けまでを解説
公開日: 2026年11月12日 · 読了目安 10 分
ReactでOTP入力欄(6桁の確認コード)をライブラリなしで作る方法です。1つの文字列を6つの箱に表示し、自動で次へ進む動き、Backspaceで戻る動き、貼り付けで全桁を埋める動きまで、完成品の試し方とあわせて解説します。
OTP入力欄は、貼り付けた瞬間に崩れやすい

メールに「739 204」と届きます。ユーザーはそれをコピーして、ログイン画面に戻って貼り付けます。
ところが、埋まったのは最初の箱の「7」だけです。残りの5つは空のままです。
OTP(一度だけ使う確認コード)の入力欄を初めて作ると、たいていこうなります。箱を6つ並べるだけなら10分で終わりそうに見えます。
実際には、細かい動きが次々に必要になります。次の箱へ進む動き、Backspace で戻る動き、コードの貼り付け、スマホの数字キーボードです。
先に短い答えを書きます。React の OTP 入力欄は、1つの数字の文字列を複数の箱に分けて表示したものです。
入力はその文字列に書き込み、フォーカスを次へ進めます。Backspace は1文字消して前へ戻し、貼り付けは文字列をまるごと入れ替えます。文字列の途中に空きを作らなければ、難しい場合分けの多くは消えます。
この記事で分かることは次のとおりです。
コードを6つの状態ではなく、1つの文字列で持つ方法
入力で次の箱へ進め、複数桁が一度に来ても受け取る方法
Backspace を「数字がある箱」と「空の箱」の両方で動かす方法
739 204のようなコードを貼り付けて、全部の箱を埋める方法キャレット、数字が出る動き、完了時の緑の枠の作り方
以下の挙動と時間は、DesignLayer の「OTP 桁入力」の実装から取っています。冒頭の動画は、そのコンポーネントに実際に入力したものです。
掲載しているコードは短い説明用の抜粋で、ソース全文ではありません。
自作のOTP入力欄が崩れる理由
つまずくのは箱の見た目ではなく、その裏にある状態の持ち方です。
6つの箱に6つの状態を持たせている
箱ごとに値を持たせると、コードに穴が空きます。1つ目に数字、2つ目は空、3つ目に数字、という並びを止めるものがありません。
そうなると、並びごとに Backspace の動きを決める必要が出てきます。送信する値をどう組み立てるかも、決めなければなりません。
maxLength={1} が貼り付けと自動入力を止めている
maxLength={1} は、いかにも正しい制限に見えます。しかし貼り付けや自動入力のコードは、処理に届く前に最初の1文字へ切られます。
この記事のコンポーネントは maxLength を付けていません。届いた文字をすべて読み、数字をどこに置くかを自分で決めます。
Backspace が今の箱しか消さない
ブラウザの標準の Backspace は、今いる箱を空にして止まります。ユーザーは、普通の入力欄のように後ろへ消し続けられると思っています。
そう動かないと、打ち間違いを直すたびに箱を1つずつクリックすることになります。
ReactのOTP入力欄は「1つの文字列と6つの箱」で作る
方針は、小さく保つことです。
持つのは code のような文字列1つです。入れる前に、数字以外をすべて取り除きます。
i 番目の箱は code[i] を表示します。空の箱は、まだ存在しない文字というだけです。文字列の長さが箱の数と同じになったら、入力は完了です。
function digitsOnly(text: string) {
return text.replace(/\D/g, "");
}
const [code, setCode] = React.useState("");
// i 番目の箱は code[i] を表示する。空の箱は「文字がまだ無い」だけ。
const char = code[index] ?? "";
const complete = code.length === size;文字列は左から埋まるので、「次に入れる箱」はいつも1つに決まります。code.length 番目の箱です。
フォーカスの決まり、キャレット、Backspace は、すべてこの1つの数字から決まります。
DesignLayer のコンポーネントは value、defaultValue、onValueChange も受け取ります。値を親で持つ使い方にも、部品に任せる使い方にも対応します。
最後の箱が埋まると onComplete が呼ばれます。コードの送信は、ここにつなぎます。
ReactでOTP入力欄を作る6つの手順
見た目はシンプルで構いません。DesignLayer のコンポーネントでは、箱は幅 44px、高さ 56px、角丸 14px です。数字は 22px の太字で、箱の間隔は 8px です。
箱が6つのときは、3つ目と4つ目の間に短い線が入ります。コードを3桁ずつの2組として読めるようにするためです。
Step 1:箱を並べ、input の属性を正しく付ける
箱は1つずつが本物の <input> です。全体を role="group" で囲み、「Verification code」のような aria-label を付けます。
各 input には「Digit 2 of 6」の形でラベルを付けます。読み上げソフトを使う人にも、今どの桁にいるかが伝わります。
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete={index === 0 ? "one-time-code" : "off"}
aria-label={`Digit ${index + 1} of ${size}`}
value={char}
/>type="text" と inputMode="numeric" の組み合わせで、スマホでは数字キーボードが開きます。type="number" に付いてくる上下の矢印や癖は出ません。
autoComplete="one-time-code" は最初の箱だけに付けます。ブラウザが SMS で届いたコードを候補に出すための目印です。自動入力されたコードは、最初の箱に届くのが自然です。
Step 2:文字列に書き込んで、次の箱へ進める
入力は「3つ目の箱に値を入れる」処理ではありません。文字列のある位置に書き込み、書いた最後の数字の次の箱へフォーカスを移します。
function writeAt(index: number, text: string) {
const incoming = digitsOnly(text);
if (!incoming) return;
const start = Math.min(index, code.length); // 途中に空きを作らない
const next = (
code.slice(0, start) + incoming + code.slice(start + incoming.length)
).slice(0, size);
commit(next);
focusBox(Math.min(start + incoming.length, size - 1));
}大事な点は2つです。1つ目は Math.min(index, code.length) で書き込み位置を抑えていることです。これで文字列に空きができません。
2つ目は、incoming が1文字とは限らないことです。自動入力が6桁を最初の箱にまとめて入れても、同じ関数が各箱へ配ります。
箱にフォーカスが当たると、中の文字が選択されます。すでに数字がある箱では、次のキー入力が2文字目にならず、数字を置き換えます。
Step 3:Backspace で前の箱へ戻す
Backspace には2つの場合があります。文字列で持っていれば、見分けは簡単です。
フォーカスのある箱に数字があれば、その数字を消してとどまります。箱が空なら、1つ前の数字を消して前の箱へ戻ります。
if (event.key === "Backspace") {
event.preventDefault();
if (index < code.length) {
// この箱に数字がある: その数字を消して、同じ箱にとどまる。
commit(code.slice(0, index) + code.slice(index + 1));
focusBox(index);
} else if (index > 0) {
// この箱は空: 1つ前の数字を消して、前の箱へ戻る。
commit(code.slice(0, index - 1));
focusBox(index - 1);
}
}途中の数字を消すと、後ろの数字が左へ詰まります。文字列はいつも連続したままです。
最後の箱で Backspace を押し続ければ、コード全体が1箱ずつ消えていきます。
このコンポーネントは、矢印キー、Home、End にも対応しています。右矢印は最初の空の箱で止まります。Step 2 で位置を抑えたのと同じ理由です。
Step 4:貼り付けで全部の箱を埋める
貼り付けは、ユーザーがいちばん気づく動きです。そして、いちばん短い処理でもあります。
クリップボードの文字を読み、数字だけを残して文字列を入れ替えます。フォーカスは、埋まった最後の箱に置きます。
const onPaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
const pasted = digitsOnly(event.clipboardData.getData("text"));
if (!pasted) return;
event.preventDefault();
commit(pasted.slice(0, size));
focusBox(Math.min(pasted.length, size - 1));
};文字は digitsOnly を通るので、739 204 や 739-204 の形でコピーされたコードでも6つの箱が埋まります。
この処理はすべての箱に付けます。貼り付けるときに、どの箱にフォーカスがあっても動きます。
Step 5:フォーカスを意味のある箱に保つ
ユーザーはどの箱でもクリックできます。2桁しか入っていないのに5つ目の箱で入力されると、穴が空きます。
そこで、フォーカスが当たったときに位置を確かめます。先の箱だった場合は、最初の空の箱へ戻します。
onFocus={(event) => {
if (index > latest.current.length) {
focusBox(latest.current.length); // 最初の空の箱へフォーカスを戻す
return;
}
setFocused(index);
event.target.select();
}}latest は、いつも最新のコードを入れておく ref(再描画を待たずに読める入れ物)です。
フォーカスの処理は、React が新しい文字列で描き直す前に走ることがあります。状態を直接読むと、キー1回ぶん古い値になる場合があります。
Step 6:キャレット、数字が出る動き、完了の表示を描く
本物の input は、入力とアクセシビリティのために置いています。ただし、input 自身の文字とキャレットは透明です。
見えているものは、各箱の上に重ねた層が描いています。動きを付けられるのはこのためです。標準の input では、打ったばかりの文字を動かせません。
入力中の箱には、2px の濃い枠が付きます。箱が空なら、幅 2px・高さ 24px の自前のキャレットを出します。
キャレットが点滅するのは、箱にフォーカスがある間だけです。周期は 1.05 秒です。どこにもフォーカスがないときは、次に入れる箱に同じキャレットを 45% の濃さで止めて見せます。
@keyframes otp-digit-input-caret {
0%, 45% { opacity: 1; }
55%, 100% { opacity: 0; }
}
.otp-digit-input-caret {
animation: otp-digit-input-caret 1.05s steps(1, end) infinite;
}
@keyframes otp-digit-input-pop {
from { opacity: 0; transform: translateY(6px) scale(0.86); }
to { opacity: 1; transform: none; }
}
.otp-digit-input-pop {
animation: otp-digit-input-pop 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.otp-digit-input-caret, .otp-digit-input-pop { animation: none; }
}数字は 320ms かけて現れます。6px 下から上がりながら、0.86 倍から元の大きさになります。貼り付けでは6つの箱が同時に埋まるので、6つの数字が一緒に現れます。
文字列が埋まると、すべての箱の枠がグレーから緑(rgba(16, 185, 129, 0.75))に変わります。変化の時間は 300ms です。
フォーカスが残っている箱は、フォーカスが外れるまで濃い枠のままです。
OS で動きを減らす設定がオンなら、点滅、数字が出る動き、枠の変化をすべて止めます。状態は一度で切り替わります。
完成したReactのOTP入力欄を使う

どの処理も長くはありません。時間がかかるのは、PC のキーボードとスマホの両方で、すべての動きを噛み合わせる部分です。
先に完成した動きを試したいときは、DesignLayer の OTP 桁入力 を開いてください。ライブプレビューは実際に入力できます。数字を打ち、Backspace を押し続け、コードを貼り付けてみてください。
閲覧とライブプレビューは無料です。試すだけなら、アカウントもカードも要りません。
このコンポーネントは無料サンプルではありません。ソース全文のコピーは、30日ビルドパスまたは Lifetime Access に含まれます(月額の Plus もあります)。
「Cursor 用にコピー」を押すと、組み込みの指示、ライブプレビューの URL、本体の TSX、npm の一覧がまとめてコピーされます。Cursor や Claude に貼れば、ログイン、2段階認証、メール確認の画面に置けます。
箱の数とラベルを差し替え、onComplete を自前の確認処理につないでください。このコンポーネントは入力欄だけです。コードの送信とサーバー側の照合は、自分で用意します。
無料アカウントでも、無料サンプル 10 個は回数の上限なくコピーでき、ローンチ動画のプロンプトは月1回です。
カタログ全体(146 個、うち 136 個は Pass 限定)を回数無制限でコピーできるのは、次の3つです。
30日ビルドパス($19 の1回払い)
Lifetime Access($79 の1回払い、先着100名のファウンディング価格)
Plus(月額 $9、いつでも解約可)
購入前に、現行の案内を 料金ページ で確認してください。カタログは https://design-layer.com/ja/components です。
ReactのOTP入力欄でよくある質問
作れます。コードを1つの数字の文字列で持ち、1文字につき input を1つ描きます。
入力は文字列に書き込んで次の箱へ進め、Backspace は1文字消して前へ戻します。貼り付けは文字列をまるごと入れ替えます。6つの箱のためにパッケージを入れる必要はありません。
まとめ:ReactのOTP入力欄は6つの手順で作れる
コードは、左から埋まる1つの数字の文字列で持つ
type="text"とinputMode="numeric"を使い、最初の箱にone-time-codeを付ける入力では文字列に書き込み、最後の数字の次の箱へフォーカスを移す
Backspace は「数字がある箱」と「空の箱」の2つに分ける
貼り付けでは数字だけを残し、コード全体を入れ替える
キャレット、数字が出る動き、完了の緑の枠は、input の上の層に描く
まずは Step 2 から Step 4 を、素の input で試してみてください。キーボードの動きが決まったら、キャレットと完了の表示を足します。
完成した入力欄から試したい場合は OTP 桁入力 を開いてください。
ログイン画面の残りの部分は ログインページのデザインガイド と リキッドガラスのログイン で解説しています。同じ作り方の小さな部品は スパークライン KPI カードの記事 も参考になります。



