HextaUI

Input OTP

入力、貼り付け、SMS のオートフィルに対応したワンタイムコードのスロット。コードが連鎖して入るオプトインのアニメーションと、検証中のステータスを備えています。

Type or paste 123456 to pass. Anything else fails.

"use client"

import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
  type InputOTPStatus,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPDemo() {
  const [value, setValue] = React.useState("")
  const [status, setStatus] = React.useState<InputOTPStatus>("idle")
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  function verify(code: string) {
    setStatus("loading")
    clearTimeout(timer.current)
    timer.current = setTimeout(() => {
      if (code === "123456") {
        setStatus("success")
        return
      }
      setStatus("error")
      timer.current = setTimeout(() => {
        setValue("")
        setStatus("idle")
      }, 900)
    }, 1200)
  }

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-3">
      <Label htmlFor="input-otp-demo">Verification code</Label>
      <InputOTP
        id="input-otp-demo"
        length={6}
        variant="separate"
        animated
        status={status}
        value={value}
        onValueChange={(next) => {
          setValue(next)
          setStatus("idle")
        }}
        onValueComplete={verify}
        aria-describedby="input-otp-demo-hint"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p id="input-otp-demo-hint" className="text-sm text-muted-foreground">
        Type or paste 123456 to pass. Anything else fails.
      </p>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/input-otp.json

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
<label htmlFor="code">Verification code</label>
<InputOTP id="code" length={6}>
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
</InputOTP>

1 文字につき 1 つの <InputOTPSlot /> をレンダリングし、length を同じ数に設定します。スロットは自分で位置を判断するため、同期を保つ index prop はありません。

InputOTP
├── InputOTPGroup
│   └── InputOTPSlot
├── InputOTPSeparator
└── InputOTPGroup
    └── InputOTPSlot

連結

デフォルトの見た目。各 <InputOTPGroup /> が、スロットを端を共有する 1 つの帯に連結します。

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPBasic() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-2">
      <Label htmlFor="input-otp-basic">Verification code</Label>
      <InputOTP id="input-otp-basic" length={6}>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

分離

variant="separate" は、各スロットに角丸の独自のボックスを与え、その間に余白を設けます。

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPSeparate() {
  return (
    <InputOTP length={6} variant="separate" aria-label="Verification code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

サイズ

sm、default、lg は、入力欄とボタンの高さに合っています。タッチスクリーンでは、すべてのサイズが 16px のフォントで 44px 以上に拡大します。

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPSizes() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-4">
      <InputOTP length={4} size="sm" aria-label="Small code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <InputOTP length={4} aria-label="Default code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <InputOTP length={4} size="lg" aria-label="Large code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

アニメーション付き

animated はデフォルトでオフです。オンにすると、入力した文字は下から現れ、削除した文字は沈んで消え、残りはスライドして詰まり、オートフィル、貼り付け、または独自の state によるコード全体は、スロットごとに連鎖して入ります。Fill code を押すと、連鎖を確認できます。

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPAnimated() {
  const [value, setValue] = React.useState("")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-4">
      <InputOTP
        length={6}
        variant="separate"
        animated
        value={value}
        onValueChange={setValue}
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => setValue("482913")}>
          Fill code
        </Button>
        <Button variant="ghost" size="sm" onClick={() => setValue("")}>
          Clear
        </Button>
      </div>
    </div>
  )
}

ステータス

status はコードの検証結果を表示します。loading はスロットをロックしてフィールドを処理中とマークし、error はすべてのスロットを不正とマークし、success は縁を緑にします。いずれも通知されます。animated を指定すると、loading は波を走らせ、error は 1 回揺れ、success は文字をポップさせます。

"use client"

import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  type InputOTPStatus,
} from "@/components/ui/input-otp"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const statuses: InputOTPStatus[] = ["idle", "loading", "success", "error"]

export function InputOTPStatusExample() {
  const [status, setStatus] = React.useState<InputOTPStatus>("loading")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-4">
      <InputOTP
        length={6}
        variant="separate"
        animated
        status={status}
        defaultValue="381904"
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <ToggleGroup
        aria-label="Status"
        size="sm"
        value={[status]}
        onValueChange={(next) => {
          if (next[0]) {
            setStatus(next[0] as InputOTPStatus)
          }
        }}
      >
        {statuses.map((item) => (
          <ToggleGroupItem key={item} value={item}>
            {item}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
    </div>
  )
}

制御

value と onValueChange を渡します。値は常にフィルタリング済みのコードで、length を超えることはありません。

"use client"

import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPControlled() {
  const [value, setValue] = React.useState("")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-3">
      <InputOTP
        length={6}
        value={value}
        onValueChange={setValue}
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p className="text-sm text-muted-foreground tabular-nums">
        {value === "" ? "Enter your code." : `You entered: ${value}`}
      </p>
    </div>
  )
}

Form

name を指定すると、コードがフォームと一緒に送信されます。autoSubmit は最後のスロットが埋まった時点で送信するため、オートフィルされたコードは、もう一度タップしなくてもサインインできます。

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPForm() {
  const [submitted, setSubmitted] = React.useState<string>()

  return (
    <form
      className="flex max-w-full min-w-0 flex-col items-start gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get("code")))
      }}
    >
      <Label htmlFor="input-otp-form">Sign-in code</Label>
      <InputOTP id="input-otp-form" name="code" length={6} required autoSubmit>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          Continue
        </Button>
        <p role="status" className="text-sm text-muted-foreground tabular-nums">
          {submitted ? `Submitted ${submitted}` : null}
        </p>
      </div>
    </form>
  )
}

Field と組み合わせる

<Field /> の内側では、ラベル、説明、エラーが自動で関連付けられます。000000 以外を入力すると、エラーを確認できます。

"use client"

import * as React from "react"

import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPField() {
  const [value, setValue] = React.useState("")
  const [error, setError] = React.useState<string>()

  return (
    <Field invalid={error !== undefined} className="w-fit">
      <FieldLabel>Verification code</FieldLabel>
      <InputOTP
        length={6}
        value={value}
        onValueChange={(next) => {
          setValue(next)
          setError(undefined)
        }}
        onValueComplete={(code) => {
          if (code !== "000000") {
            setError("That code has expired. Request a new one.")
          }
        }}
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <FieldDescription>We sent it to [email protected].</FieldDescription>
      <FieldError errors={error ? [{ message: error }] : []} />
    </Field>
  )
}

無効な値

ルートの aria-invalid はすべてのスロットをマークします。メッセージは aria-describedby で関連付けます。

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPInvalid() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-2">
      <InputOTP
        length={6}
        defaultValue="111111"
        aria-invalid
        aria-label="Verification code"
        aria-describedby="input-otp-invalid-error"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p id="input-otp-invalid-error" className="text-sm text-destructive">
        That code doesn’t match. Check the latest message.
      </p>
    </div>
  )
}

文字と数字

validationType="alphanumeric" は、リカバリーコードや招待コードを受け付け、normalizeValue は入力または貼り付けの際にそれらを大文字にします。

"use client"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPAlphanumeric() {
  return (
    <InputOTP
      length={8}
      validationType="alphanumeric"
      normalizeValue={(value) => value.toUpperCase()}
      aria-label="Recovery code"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

マスク

mask は、PIN 向けに各文字を隠します。値がワンタイムコードでない場合は、autoComplete="off" でオートフィルをオフにします。

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPMasked() {
  return (
    <InputOTP
      length={4}
      mask
      animated
      variant="separate"
      autoComplete="off"
      aria-label="PIN"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

カスタムのセパレーター

スロットを好きなようにグループ化し、独自のアイコンを <InputOTPSeparator /> に渡します。

import { IconPointFilled } from "@tabler/icons-react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPCustomSeparator() {
  return (
    <InputOTP length={6} variant="separate" aria-label="Pairing code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator>
        <IconPointFilled aria-hidden="true" />
      </InputOTPSeparator>
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator>
        <IconPointFilled aria-hidden="true" />
      </InputOTPSeparator>
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

無効

無効なフィールドにはフォーカスも編集もできません。

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPDisabled() {
  return (
    <InputOTP
      length={6}
      defaultValue="12"
      disabled
      aria-label="Verification code"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

右から左

スロットは右から埋まり、矢印キーは見た目に従います。最初以外のスロットには、翻訳した aria-label を付けてください。右から左のページでコードを左から右のままにするには、フィールドに dir="ltr" を設定します。

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPRtl() {
  return (
    <div
      dir="rtl"
      className="flex max-w-full min-w-0 flex-col items-start gap-2"
    >
      <Label htmlFor="input-otp-rtl">رمز التحقق</Label>
      <InputOTP id="input-otp-rtl" length={6} animated>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot aria-label="الخانة ٢ من ٦" />
          <InputOTPSlot aria-label="الخانة ٣ من ٦" />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot aria-label="الخانة ٤ من ٦" />
          <InputOTPSlot aria-label="الخانة ٥ من ٦" />
          <InputOTPSlot aria-label="الخانة ٦ من ٦" />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}
キーアクション
Tabフォーカスをフィールド内の最初の空のスロットに移し、また外に出します。タブ順に入るスロットは 1 つだけです。
←→前または次のスロットに移動します。右から左のレイアウトでは、見た目の順序に従います。
Home↑最初のスロットに移動します。
End↓最後の文字の次のスロットに移動します。
Backspaceスロット内の文字を削除し、スロットが空の場合はその前の文字を削除します。後続の文字は前に詰まります。
Deleteスロット内の文字を削除し、フォーカスをそこに保ちます。
CtrlBackspaceコード全体をクリアします。macOS では ⌘ Backspace。
CtrlAコード全体を選択します(macOS では ⌘ A)。その後 Backspace または Delete でクリアして最初のスロットに戻り、入力または貼り付けで置き換わり、Ctrl C ですべてコピーされます。他のキーやクリックで選択は解除されます。
  • 各スロットは本物の input です。最初のスロットは <label> または aria-label から名前を取り、それ以外は「Character 2 of 6」のように名付けられます。翻訳するには、スロットに aria-label を渡します。
  • 最初のスロットには autocomplete="one-time-code" が付いているため、iOS と macOS ではメッセージやメールからのコードが、Android では SMS のコードが提示され、パスワードマネージャーも入力できます。1 つのスロットに入ったコード全体は、すべてのスロットに振り分けられます。アニメーションによる連鎖は、WebOTP API から設定したコードを含め、すべての入力元で実行されます。
  • スロットにフォーカスがある間にコードが空になったとき(誤ったコードがクリアされた後など)は、フォーカスが最初のスロットに戻るため、次の入力が正しい位置から始まります。
  • status が設定されると、フィールドの隣にある非表示のライブリージョンがそれを通知します。文言は loadingLabel、successLabel、errorLabel で変更できます。
  • animated を指定すると、文字はスクリーンリーダーから隠されたレイヤーに描画され、入力欄は実際の値を保持します。モーションの低減が有効な場合、文字はフェードするだけで、ステータスの波は穏やかなパルスになります。

Base UI の OTP field をベースにしています。Base UI のすべての props がそのまま渡されます。

プロパティ型デフォルト
length必須。スロットの数。同じ数の InputOTPSlot パーツをレンダリングします。
number–
variant
"joined" | "separate""joined"
size
"sm" | "default" | "lg""default"
animated文字を表示・非表示のアニメーションで動かし、複数文字の入力を連鎖させ、ステータスをアニメーションさせます。
booleanfalse
statusコードの検証結果。loading の間、スロットは読み取り専用になります。
"idle" | "loading" | "success" | "error"–
loadingLabel
string"Verifying code"
successLabel
string"Code verified"
errorLabel
string"Code is incorrect"
value
string–
defaultValue
string–
onValueChange
(value: string, details) => void–
onValueComplete最後のスロットが埋まったときに呼ばれます。
(value: string, details) => void–
onValueInvalid入力または貼り付けられた文字が拒否されたときに呼ばれます。
(value: string, details) => void–
validationType
"numeric" | "alpha" | "alphanumeric" | "none""numeric"
normalizeValueフィルタリングの後に実行されます。冪等に保ってください。
(value: string) => string–
inputModeデフォルトは validationType から決まります。
string–
autoComplete
string"one-time-code"
autoSubmit
booleanfalse
mask
booleanfalse
aria-invalidすべてのスロットを不正とマークします。
boolean–
name
string–
form
string–
id最初のスロットに付与されるため、ラベルの htmlFor がそれを指します。
string–
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="input-otp"ルート。
data-variant="joined" | "separate"現在のバリアント。
data-size現在のサイズ。
data-status設定されている場合のステータス。
data-animatedanimated がオンのときに付与されます。
data-shakeステータスがエラーになった後、フィールドが揺れている間、付与されます。
data-completeすべてのスロットが埋まっているときに付与されます。
data-filledいずれかのスロットが埋まっているときに付与されます。
data-focusedスロットにフォーカスがある間、付与されます。
data-disabled無効のときに存在します。
data-readonly読み取り専用のとき(読み込み中を含む)に付与されます。
data-required必須のときに付与されます。
data-invalid / data-valid / data-touched / data-dirtyField 内での、フィールドの状態。
data-slot="input-otp-status"非表示のライブリージョン。ルートの兄弟要素です。

スロットの並びをレイアウトするプレーンな要素。joined の variant では、スロットが端を共有します。

プロパティ型デフォルト
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="input-otp-group"CSSでグループを指定します。

1 つの入力欄を保持するボックス。className はボックスに、それ以外の props はすべて入力欄に渡されます。

プロパティ型デフォルト
aria-labelラベルを使う最初のスロットでは無視されます。
string"Character N of M"
classNamestate には、スロットの index、value、filled、field の状態が含まれます。
string | (state) => string–
placeholder
string–
属性説明
data-slot="input-otp-slot"ボックス。
data-filledスロットに文字があるときに付与されます。
data-statusルートのステータス(idle でない場合)。
--input-otp-indexスロットの位置。ステータスの動きをずらすのに使われます。
data-slot="input-otp-input"内側の input。Base UI の data-filled、data-focused、data-complete と field の属性を持ちます。
data-slot="input-otp-char"animated のときに描画される文字。

マイナスアイコンのセパレーター。別のアイコンを使うには、children を渡します。

プロパティ型デフォルト
orientation
"horizontal" | "vertical""horizontal"
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="input-otp-separator"CSS でセパレーターを指定します。

スロットとグループの背後にあるクラス名(inputOTPGroupVariants)。{ variant, size } を渡して呼び出します。

使用しているブロック

Input OTP の上に構築されるブロック。