HextaUI

Input OTP

Slots de código de un solo uso que admiten escritura, pegado y autocompletado de SMS, con una animación opcional que encadena los códigos en cascada y un estado para la verificación.

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

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

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>

Renderiza un <InputOTPSlot /> por carácter y define length con el mismo número. Los slots encuentran su posición por sí solos, así que no hay una prop index que mantener sincronizada.

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

Unido

El aspecto por defecto. Cada <InputOTPGroup /> une sus slots en una sola tira con bordes compartidos.

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

Separado

variant="separate" da a cada slot su propia caja redondeada con un hueco entre ellas.

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

Tamaños

sm, default y lg coinciden con las alturas del input y del botón. En pantallas táctiles cada tamaño crece hasta al menos 44px con una fuente de 16px.

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

Animado

animated está desactivado por defecto. Con él, los caracteres escritos suben al entrar, los borrados se hunden al salir mientras el resto se desliza, y un código completo procedente de autocompletado, pegado o de tu propio estado se encadena en cascada slot a slot. Pulsa Fill code para ver la cascada.

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

Estado

status muestra el resultado de comprobar el código. loading bloquea los slots y marca el campo como ocupado, error marca todos los slots como no válidos y success vuelve verdes los bordes. Cada uno se anuncia. Con animated, loading ejecuta una onda, error sacude una vez y success hace aparecer los caracteres con un efecto pop.

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

Controlado

Pasa value y onValueChange. El valor es siempre el código filtrado, nunca más largo 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

Con un name, el código se envía con el formulario. autoSubmit envía en cuanto se llena el último slot, así que un código autocompletado inicia sesión sin otro toque.

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

Con Field

Dentro de un <Field /> la etiqueta, la descripción y el error se vinculan por ti. Introduce cualquier cosa menos 000000 para ver el error.

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

Inválido

aria-invalid en la raíz marca todos los slots. Vincula el mensaje con 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>
  )
}

Letras y números

validationType="alphanumeric" acepta códigos de recuperación y de invitación, y normalizeValue los pasa a mayúsculas mientras se escriben o pegan.

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

Enmascarado

mask oculta cada carácter, para PIN. Desactiva el autocompletado con autoComplete="off" cuando el valor no sea un código de un solo uso.

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

Separador personalizado

Agrupa los slots como quieras y pasa tu propio icono a <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>
  )
}

Deshabilitado

Un campo deshabilitado no puede recibir foco ni editarse.

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 derecha a izquierda

Los slots se llenan desde la derecha y las teclas de flecha siguen lo que ves. Dale a los slots posteriores al primero un aria-label traducido. Define dir="ltr" en el campo para mantener un código de izquierda a derecha en una página de derecha a izquierda.

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>
  )
}
KeyAcción
TabMueve el foco al campo, al primer slot vacío, y de nuevo fuera. Solo un slot está en el orden de tabulación.
←→Pasa al slot anterior o siguiente, en orden visual en diseños de derecha a izquierda.
Home↑Pasa al primer slot.
End↓Pasa al slot posterior al último carácter.
BackspaceBorra el carácter del slot, o el anterior cuando el slot está vacío. Los caracteres posteriores retroceden.
DeleteBorra el carácter del slot y mantiene el foco allí.
CtrlBackspaceBorra todo el código. ⌘ Backspace en macOS.
CtrlASelecciona todo el código (⌘ A en macOS). Backspace o Delete lo borra entonces y vuelve al primer slot, escribir o pegar lo reemplaza, y Ctrl C copia todo. Cualquier otra tecla o un clic termina la selección.
  • Cada slot es un input real. El primero toma su nombre de tu <label> o aria-label; los demás se llaman "Character 2 of 6" y así sucesivamente. Pasa aria-label en un slot para traducirlo.
  • El primer slot tiene autocomplete="one-time-code", así que iOS y macOS ofrecen códigos de Mensajes y Mail, Android ofrece códigos SMS y los gestores de contraseñas pueden rellenarlo. Un código completo que llega a un solo slot se reparte entre todos ellos. La cascada animada se ejecuta para cualquier origen, incluidos los códigos que definas desde la API WebOTP.
  • Siempre que el código queda vacío mientras un slot tiene foco, como tras borrar un código incorrecto, el foco vuelve al primer slot para que el siguiente intento empiece en el lugar correcto.
  • Cuando se define status, una región activa oculta junto al campo lo anuncia. Cambia las palabras con loadingLabel, successLabel y errorLabel.
  • Con animated, los caracteres se dibujan en una capa oculta para los lectores de pantalla mientras los inputs conservan el valor real. Con movimiento reducido, los caracteres solo se desvanecen y la onda de estado se convierte en un pulso suave.

Construido sobre el campo OTP de Base UI. Todas las props de Base UI se pasan.

PropTipoPredeterminado
lengthObligatorio. El número de slots; renderiza el mismo número de partes InputOTPSlot.
number–
variant
"joined" | "separate""joined"
size
"sm" | "default" | "lg""default"
animatedAnima la entrada y salida de caracteres, encadena en cascada la entrada de varios caracteres y anima el estado.
booleanfalse
statusEl resultado de comprobar el código. Loading deja los slots en solo lectura.
"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–
onValueCompleteSe llama cuando se llena el último slot.
(value: string, details) => void–
onValueInvalidSe llama cuando se rechazan los caracteres escritos o pegados.
(value: string, details) => void–
validationType
"numeric" | "alpha" | "alphanumeric" | "none""numeric"
normalizeValueSe ejecuta después de filtrar. Mantenla idempotente.
(value: string) => string–
inputModePor defecto proviene de validationType.
string–
autoComplete
string"one-time-code"
autoSubmit
booleanfalse
mask
booleanfalse
aria-invalidMarca todos los slots como no válidos.
boolean–
name
string–
form
string–
idVa en el primer slot, para que el htmlFor de una etiqueta apunte a él.
string–
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescripción
data-slot="input-otp"La raíz.
data-variant="joined" | "separate"La variante actual.
data-sizeEl tamaño actual.
data-statusEl estado, cuando hay uno definido.
data-animatedPresente cuando animated está activado.
data-shakePresente mientras el campo se sacude tras pasar el estado a error.
data-completePresente cuando todos los slots están llenos.
data-filledPresente cuando algún slot está lleno.
data-focusedPresente mientras un slot tiene foco.
data-disabledPresente cuando está deshabilitado.
data-readonlyPresente cuando es de solo lectura, incluso durante la carga.
data-requiredPresente cuando es obligatorio.
data-invalid / data-valid / data-touched / data-dirtyEstado del campo, dentro de un Field.
data-slot="input-otp-status"La región activa oculta, hermana de la raíz.

Un elemento simple que organiza una serie de slots. En la variante joined, sus slots comparten bordes.

PropTipoPredeterminado
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescripción
data-slot="input-otp-group"Apunta al grupo en CSS.

Una caja que contiene un input. className va en la caja; cualquier otra prop va en el input.

PropTipoPredeterminado
aria-labelSe ignora en el primer slot, que usa la etiqueta.
string"Character N of M"
classNameEl estado incluye el índice del slot, su valor, filled y el estado del campo.
string | (state) => string–
placeholder
string–
AtributoDescripción
data-slot="input-otp-slot"La caja.
data-filledPresente cuando el slot tiene un carácter.
data-statusEl estado de la raíz, cuando no es idle.
--input-otp-indexLa posición del slot, usada para escalonar el movimiento del estado.
data-slot="input-otp-input"El input de su interior, con los atributos data-filled, data-focused, data-complete y de campo de Base UI.
data-slot="input-otp-char"El carácter dibujado cuando animated está activado.

Un separador con un icono de menos. Pasa children para usar otro icono.

PropTipoPredeterminado
orientation
"horizontal" | "vertical""horizontal"
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescripción
data-slot="input-otp-separator"Selecciona el separador en CSS.

Los nombres de clase detrás de un slot y un grupo (inputOTPGroupVariants). Llámalos con { variant, size }.

Usado en bloques

Bloques que se construyen sobre Input OTP.