【React】OTP入力欄(6桁の確認コード)の作り方|自動で次の箱へ進む動きから貼り付けまでを解説

公開日: 2026年11月12日 · 読了目安 10 分

ReactでOTP入力欄(6桁の確認コード)をライブラリなしで作る方法です。1つの文字列を6つの箱に表示し、自動で次へ進む動き、Backspaceで戻る動き、貼り付けで全桁を埋める動きまで、完成品の試し方とあわせて解説します。

OTP入力欄は、貼り付けた瞬間に崩れやすい

OTP 入力欄の3つの状態。空の状態では最初の箱に枠とキャレット、入力中は4つ目の箱に枠、全桁そろうと6つの箱の枠が緑になる。
OTP 入力欄の3つの状態。空の状態では最初の箱に枠とキャレット、入力中は4つ目の箱に枠、全桁そろうと6つの箱の枠が緑になる。

メールに「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] を表示します。空の箱は、まだ存在しない文字というだけです。文字列の長さが箱の数と同じになったら、入力は完了です。

tsx
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」の形でラベルを付けます。読み上げソフトを使う人にも、今どの桁にいるかが伝わります。

tsx
<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つ目の箱に値を入れる」処理ではありません。文字列のある位置に書き込み、書いた最後の数字の次の箱へフォーカスを移します。

ts
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つ前の数字を消して前の箱へ戻ります。

ts
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:貼り付けで全部の箱を埋める

貼り付けは、ユーザーがいちばん気づく動きです。そして、いちばん短い処理でもあります。

クリップボードの文字を読み、数字だけを残して文字列を入れ替えます。フォーカスは、埋まった最後の箱に置きます。

tsx
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つ目の箱で入力されると、穴が空きます。

そこで、フォーカスが当たったときに位置を確かめます。先の箱だった場合は、最初の空の箱へ戻します。

tsx
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% の濃さで止めて見せます。

css
@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入力欄を使う

DesignLayer の「OTP 桁入力」の詳細ページ。左のライブプレビューで入力、Backspace、貼り付けを試せる。ソース全文のコピーは 30日ビルドパスが必要。
DesignLayer の「OTP 桁入力」の詳細ページ。左のライブプレビューで入力、Backspace、貼り付けを試せる。ソース全文のコピーは 30日ビルドパスが必要。

どの処理も長くはありません。時間がかかるのは、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 カードの記事 も参考になります。

このガイドのコンポーネント

コンポーネントを見る →

関連ガイド