HextaUI

Input OTP

Des emplacements pour code à usage unique qui acceptent la saisie, le collage et le remplissage automatique par SMS, avec une animation facultative qui fait cascader les codes et un statut de vérification.

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

Ajoute le composant, les tokens de thème HextaUI et les composants HextaUI dont il dépend.

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>

Rendez un <InputOTPSlot /> par caractère et définissez length au même nombre. Les emplacements trouvent seuls leur position : il n’y a donc pas de prop index à garder synchronisée.

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

Joint

L’apparence par défaut. Chaque <InputOTPGroup /> réunit ses emplacements en une seule bande aux bords partagés.

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>
  )
}

Séparé

variant="separate" donne à chaque emplacement sa propre boîte arrondie avec un écart entre elles.

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>
  )
}

Tailles

sm, default et lg correspondent aux hauteurs du champ et du bouton. Sur écran tactile, chaque taille passe à au moins 44 px avec une police de 16 px.

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>
  )
}

Animé

animated est désactivé par défaut. Activé, les caractères saisis montent en entrant, ceux supprimés descendent en sortant pendant que les autres glissent, et un code entier venant du remplissage automatique, du collage ou de votre propre état cascade emplacement par emplacement. Appuyez sur Fill code pour voir la cascade.

"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>
  )
}

Statut

status affiche le résultat de la vérification du code. loading verrouille les emplacements et marque le champ comme occupé, error marque chaque emplacement comme invalide et success colore les bords en vert. Chacun est annoncé. Avec animated, loading lance une vague, error vibre une fois et success fait rebondir les caractères.

"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>
  )
}

Contrôlé

Passez value et onValueChange. La valeur est toujours le code filtré, jamais plus long que 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

Avec un name, le code est soumis avec le formulaire. autoSubmit soumet dès que le dernier emplacement est rempli : un code rempli automatiquement connecte donc l’utilisateur sans un appui de plus.

"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>
  )
}

Avec Field

Dans un <Field />, le libellé, la description et l’erreur sont reliés pour vous. Saisissez n’importe quoi sauf 000000 pour voir l’erreur.

"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>
  )
}

Invalide

aria-invalid sur la racine marque chaque emplacement. Reliez le message avec 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>
  )
}

Lettres et chiffres

validationType="alphanumeric" accepte les codes de récupération et d’invitation, et normalizeValue les met en majuscules au fur et à mesure de la saisie ou du collage.

"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>
  )
}

Masqué

mask masque chaque caractère, pour les codes PIN. Désactivez le remplissage automatique avec autoComplete="off" lorsque la valeur n’est pas un code à usage unique.

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>
  )
}

Séparateur personnalisé

Groupez les emplacements comme vous voulez et passez votre propre icône à <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>
  )
}

Désactivé

Un champ désactivé ne peut ni recevoir le focus ni être modifié.

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>
  )
}

De droite à gauche

Les emplacements se remplissent depuis la droite et les flèches suivent ce que vous voyez. Donnez aux emplacements après le premier un aria-label traduit. Définissez dir="ltr" sur le champ pour garder un code de gauche à droite dans une page de droite à gauche.

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>
  )
}
ToucheAction
TabDéplace le focus dans le champ, vers le premier emplacement vide, et en dehors. Un seul emplacement est dans l’ordre de tabulation.
←→Passe à l’emplacement précédent ou suivant, dans l’ordre visuel dans les mises en page de droite à gauche.
Home↑Passe au premier emplacement.
End↓Passe à l’emplacement après le dernier caractère.
BackspaceSupprime le caractère de l’emplacement, ou celui d’avant lorsque l’emplacement est vide. Les caractères suivants reculent.
DeleteSupprime le caractère de l’emplacement et y garde le focus.
CtrlBackspaceEfface tout le code. ⌘ Backspace sur macOS.
CtrlASélectionne tout le code (⌘ A sur macOS). Backspace ou Delete l’efface alors et revient au premier emplacement, saisir ou coller le remplace, et Ctrl C le copie en entier. Toute autre touche ou un clic met fin à la sélection.
  • Chaque emplacement est un vrai champ. Le premier tire son nom de votre <label> ou de aria-label ; les autres sont nommés « Character 2 of 6 » et ainsi de suite. Passez aria-label sur un emplacement pour le traduire.
  • Le premier emplacement a autocomplete="one-time-code" : iOS et macOS proposent donc des codes issus de Messages et Mail, Android propose les codes SMS, et les gestionnaires de mots de passe peuvent le remplir. Un code entier arrivant dans un seul emplacement est réparti sur tous. La cascade animée s’exécute pour toutes les sources, y compris les codes que vous définissez depuis l’API WebOTP.
  • Chaque fois que le code devient vide alors qu’un emplacement a le focus, par exemple après l’effacement d’un mauvais code, le focus revient au premier emplacement pour que l’essai suivant démarre au bon endroit.
  • Lorsque status est défini, une région live masquée à côté du champ l’annonce. Modifiez les mots avec loadingLabel, successLabel et errorLabel.
  • Avec animated, les caractères sont dessinés sur une couche masquée aux lecteurs d’écran tandis que les champs gardent la vraie valeur. Avec la réduction des animations, les caractères ne font que se fondre et la vague de statut devient une douce pulsation.

Construit sur le champ OTP de Base UI. Toutes les props de Base UI sont transmises.

PropTypePar défaut
lengthRequis. Le nombre d’emplacements ; rendez le même nombre de parties InputOTPSlot.
number–
variant
"joined" | "separate""joined"
size
"sm" | "default" | "lg""default"
animatedAnime l’entrée et la sortie des caractères, fait cascader la saisie de plusieurs caractères et anime le statut.
booleanfalse
statusLe résultat de la vérification du code. Le chargement met les emplacements en lecture seule.
"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–
onValueCompleteAppelé lorsque le dernier emplacement est rempli.
(value: string, details) => void–
onValueInvalidAppelé lorsque des caractères saisis ou collés sont rejetés.
(value: string, details) => void–
validationType
"numeric" | "alpha" | "alphanumeric" | "none""numeric"
normalizeValueS’exécute après le filtrage. Gardez-le idempotent.
(value: string) => string–
inputModePar défaut, d’après validationType.
string–
autoComplete
string"one-time-code"
autoSubmit
booleanfalse
mask
booleanfalse
aria-invalidMarque chaque emplacement comme invalide.
boolean–
name
string–
form
string–
idSe place sur le premier emplacement, pour que le htmlFor d’un libellé le cible.
string–
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="input-otp"La racine.
data-variant="joined" | "separate"La variante actuelle.
data-sizeLa taille actuelle.
data-statusLe statut, lorsqu’il est défini.
data-animatedPrésent lorsque animated est activé.
data-shakePrésent pendant que le champ vibre après le passage du statut à error.
data-completePrésent lorsque chaque emplacement est rempli.
data-filledPrésent lorsqu’un emplacement est rempli.
data-focusedPrésent tant qu’un emplacement a le focus.
data-disabledPrésent lorsque l’élément est désactivé.
data-readonlyPrésent en lecture seule, y compris pendant le chargement.
data-requiredPrésent lorsqu’elle est requise.
data-invalid / data-valid / data-touched / data-dirtyÉtat du champ, dans un Field.
data-slot="input-otp-status"La région live masquée, sœur de la racine.

Un élément simple qui dispose une série d’emplacements. Dans la variante joined, ses emplacements partagent leurs bords.

PropTypePar défaut
render
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="input-otp-group"Ciblez le groupe en CSS.

Une boîte contenant un champ. className va sur la boîte ; toutes les autres props vont sur le champ.

PropTypePar défaut
aria-labelIgnoré sur le premier emplacement, qui utilise le libellé.
string"Character N of M"
classNameL’état contient l’index de l’emplacement, sa valeur, filled et l’état du champ.
string | (state) => string–
placeholder
string–
AttributDescription
data-slot="input-otp-slot"La boîte.
data-filledPrésent lorsque l’emplacement contient un caractère.
data-statusLe statut de la racine, lorsqu’il n’est pas idle.
--input-otp-indexLa position de l’emplacement, utilisée pour échelonner le mouvement du statut.
data-slot="input-otp-input"Le champ à l’intérieur, avec les attributs data-filled, data-focused, data-complete et field de Base UI.
data-slot="input-otp-char"Le caractère dessiné lorsque animated est activé.

Un séparateur avec une icône moins. Passez des enfants pour utiliser une autre icône.

PropTypePar défaut
orientation
"horizontal" | "vertical""horizontal"
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="input-otp-separator"Ciblez le séparateur en CSS.

Les noms de classes derrière un emplacement et un groupe (inputOTPGroupVariants). Appelez-les avec { variant, size }.

Utilisé dans les blocks

Des blocks qui s’appuient sur Input OTP.