HextaUI

Input OTP

Slots de código de uso único que aceitam digitação, colagem e preenchimento automático de SMS, com uma animação opcional que faz os códigos entrarem em cascata e um status para a verificação.

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

Adiciona o componente, os tokens de tema do HextaUI e quaisquer componentes do HextaUI dos quais ele 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>

Renderize um <InputOTPSlot /> por caractere e defina length com o mesmo número. Os slots encontram a própria posição sozinhos, então não há uma prop index para manter em sincronia.

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

Unido

A aparência padrão. Cada <InputOTPGroup /> une seus slots em uma única faixa com bordas compartilhadas.

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" dá a cada slot a própria caixa arredondada, com um espaço entre elas.

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

Tamanhos

sm, default e lg correspondem às alturas do input e do botão. Em telas sensíveis ao toque, todo tamanho cresce para pelo menos 44px com fonte 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 vem desativado por padrão. Com ele, os caracteres digitados sobem, os apagados descem enquanto os demais deslizam, e um código inteiro vindo do preenchimento automático, de uma colagem ou do seu próprio estado entra em cascata, slot a slot. Pressione Fill code para ver a cascata.

"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

status mostra o resultado da verificação do código. loading trava os slots e marca o campo como ocupado, error marca todos os slots como inválidos e success deixa as bordas verdes. Cada um é anunciado. Com animated, loading executa uma onda, error balança uma vez e success faz os caracteres saltarem.

"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

Passe value e onValueChange. O valor é sempre o código filtrado, nunca maior 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

Com um name, o código é enviado com o formulário. autoSubmit envia assim que o último slot é preenchido, então um código preenchido automaticamente faz o login sem mais um 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>
  )
}

Com Field

Dentro de um <Field />, o rótulo, a descrição e o erro são vinculados para você. Digite qualquer coisa diferente de 000000 para ver o erro.

"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 na raiz marca todos os slots. Vincule a mensagem com 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 e números

validationType="alphanumeric" aceita códigos de recuperação e de convite, e normalizeValue os converte em maiúsculas conforme são digitados ou colados.

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

Mascarado

mask oculta cada caractere, para PINs. Desative o preenchimento automático com autoComplete="off" quando o valor não for um código de uso único.

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

Agrupe os slots como quiser e passe seu próprio ícone 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>
  )
}

Desabilitado

Um campo desativado não pode receber foco nem ser editado.

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

Da direita para a esquerda

Os slots são preenchidos pela direita e as teclas de seta seguem o que você vê. Dê aos slots depois do primeiro um aria-label traduzido. Defina dir="ltr" no campo para manter um código da esquerda para a direita em uma página da direita para a esquerda.

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>
  )
}
TeclaAção
TabMove o foco para dentro do campo, até o primeiro slot vazio, e de volta para fora. Apenas um slot está na ordem de tabulação.
←→Move para o slot anterior ou seguinte, em ordem visual em layouts da direita para a esquerda.
Home↑Move para o primeiro slot.
End↓Move para o slot depois do último caractere.
BackspaceApaga o caractere do slot, ou o anterior quando o slot está vazio. Os caracteres seguintes recuam.
DeleteApaga o caractere do slot e mantém o foco nele.
CtrlBackspaceLimpa o código inteiro. ⌘ Backspace no macOS.
CtrlASeleciona o código inteiro (⌘ A no macOS). Backspace ou Delete então o limpa e volta ao primeiro slot, digitar ou colar o substitui, e Ctrl C copia tudo. Qualquer outra tecla ou um clique encerra a seleção.
  • Cada slot é um input de verdade. O primeiro recebe seu nome do seu <label> ou de aria-label; os demais se chamam "Character 2 of 6" e assim por diante. Passe aria-label em um slot para traduzi-lo.
  • O primeiro slot tem autocomplete="one-time-code", então o iOS e o macOS oferecem códigos do Mensagens e do Mail, o Android oferece códigos de SMS e os gerenciadores de senhas podem preenchê-lo. Um código inteiro que cai em um só slot é distribuído por todos eles. A cascata animada roda para toda origem, inclusive códigos definidos pela API WebOTP.
  • Sempre que o código fica vazio enquanto um slot tem foco, como depois de um código errado ser limpo, o foco volta ao primeiro slot para que a próxima tentativa comece no lugar certo.
  • Quando status está definido, uma região live oculta ao lado do campo o anuncia. Altere as palavras com loadingLabel, successLabel e errorLabel.
  • Com animated, os caracteres são desenhados em uma camada oculta para leitores de tela enquanto os inputs mantêm o valor real. Com movimento reduzido, os caracteres apenas fazem fade e a onda de status vira um pulso suave.

Construído sobre o campo OTP do Base UI. Toda prop do Base UI é repassada.

PropTipoPadrão
lengthObrigatório. O número de slots; renderize o mesmo número de partes InputOTPSlot.
number–
variant
"joined" | "separate""joined"
size
"sm" | "default" | "lg""default"
animatedAnima a entrada e a saída dos caracteres, faz em cascata a entrada de vários caracteres e anima o status.
booleanfalse
statusO resultado da verificação do código. Loading deixa os slots como somente leitura.
"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–
onValueCompleteChamado quando o último slot é preenchido.
(value: string, details) => void–
onValueInvalidChamado quando caracteres digitados ou colados são rejeitados.
(value: string, details) => void–
validationType
"numeric" | "alpha" | "alphanumeric" | "none""numeric"
normalizeValueExecuta após a filtragem. Mantenha-a idempotente.
(value: string) => string–
inputModeO padrão vem de validationType.
string–
autoComplete
string"one-time-code"
autoSubmit
booleanfalse
mask
booleanfalse
aria-invalidMarca todos os slots como inválidos.
boolean–
name
string–
form
string–
idVai no primeiro slot, para que o htmlFor de um rótulo aponte para ele.
string–
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescrição
data-slot="input-otp"A raiz.
data-variant="joined" | "separate"A variante atual.
data-sizeO tamanho atual.
data-statusO status, quando um está definido.
data-animatedPresente quando animated está ativado.
data-shakePresente enquanto o campo balança depois que o status vira error.
data-completePresente quando todos os slots estão preenchidos.
data-filledPresente quando qualquer slot está preenchido.
data-focusedPresente enquanto um slot tem foco.
data-disabledPresente quando desabilitado.
data-readonlyPresente quando somente leitura, inclusive durante o carregamento.
data-requiredPresente quando obrigatório.
data-invalid / data-valid / data-touched / data-dirtyEstado do campo, dentro de um Field.
data-slot="input-otp-status"A região live oculta, irmã da raiz.

Um elemento simples que organiza uma sequência de slots. Na variant joined, os slots compartilham as bordas.

PropTipoPadrão
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescrição
data-slot="input-otp-group"Selecione o grupo no CSS.

Uma caixa que contém um input. className vai na caixa; todas as outras props vão no input.

PropTipoPadrão
aria-labelIgnorado no primeiro slot, que usa o rótulo.
string"Character N of M"
classNameO estado tem o índice do slot, o valor, filled e o estado do campo.
string | (state) => string–
placeholder
string–
AtributoDescrição
data-slot="input-otp-slot"A caixa.
data-filledPresente quando o slot tem um caractere.
data-statusO status da raiz, quando não é idle.
--input-otp-indexA posição do slot, usada para escalonar o movimento do status.
data-slot="input-otp-input"O input interno, com os atributos data-filled, data-focused, data-complete e de campo do Base UI.
data-slot="input-otp-char"O caractere desenhado quando animated.

Um separador com um ícone de menos. Passe children para usar outro ícone.

PropTipoPadrão
orientation
"horizontal" | "vertical""horizontal"
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescrição
data-slot="input-otp-separator"Seleciona o separador no CSS.

Os nomes de classe por trás de um slot e de um grupo (inputOTPGroupVariants). Chame-os com { variant, size }.

Usado em blocos

Blocos que se baseiam em Input OTP.