Input OTP
One-time code slots that take typing, paste and SMS autofill, with an opt-in animation that cascades codes in and a status for verifying.
Type or paste 123456 to pass. Anything else fails.
pnpm dlx shadcn@latest add https://hextaui.com/r/input-otp.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
Add the theme tokens to your global CSS, if you haven’t yet.
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cnCopy and paste the following code into your project.
components/ui/input-otp.tsx lib/motion.ts Update the import paths to match your project setup.
Render one <InputOTPSlot /> per character and set length to the same number. Slots find their position on their own, so there is no index prop to keep in sync.
Joined
The default look. Each <InputOTPGroup /> joins its slots into one strip with shared edges.
Separate
variant="separate" gives every slot its own rounded box with a gap between them.
Sizes
sm, default and lg match the input and button heights. On touch screens every size grows to at least 44px with a 16px font.
Animated
animated is off by default. With it, typed characters rise in, deleted ones sink out while the rest slide over, and a whole code from autofill, paste or your own state cascades in slot by slot. Press Fill code to see the cascade.
Status
status shows the result of checking the code. loading locks the slots and marks the field busy, error marks every slot invalid and success turns the edges green. Each one is announced. With animated, loading runs a wave, error shakes once and success pops the characters.
Controlled
Pass value and onValueChange. The value is always the filtered code, never longer than length.
Enter your code.
Form
With a name, the code is submitted with the form. autoSubmit submits as soon as the last slot fills, so an autofilled code signs people in without another tap.
With Field
Inside a <Field /> the label, description and error are linked for you. Enter anything but 000000 to see the error.
We sent it to [email protected].
Invalid
aria-invalid on the root marks every slot. Link the message with aria-describedby.
That code doesn’t match. Check the latest message.
Letters and numbers
validationType="alphanumeric" accepts recovery and invite codes, and normalizeValue upper-cases them as they are typed or pasted.
Masked
mask hides each character, for PINs. Turn autofill off with autoComplete="off" when the value is not a one-time code.
Custom separator
Group the slots any way you like and pass your own icon to <InputOTPSeparator />.
Disabled
A disabled field can’t be focused or edited.
Right to left
Slots fill from the right and the arrow keys follow what you see. Give slots after the first a translated aria-label. Set dir="ltr" on the field to keep a code left to right in a right-to-left page.
| Key | Action |
|---|---|
| Tab | Moves focus into the field, to the first empty slot, and out again. Only one slot is in the tab order. |
| ←→ | Moves to the previous or next slot, in visual order in right-to-left layouts. |
| Home↑ | Moves to the first slot. |
| End↓ | Moves to the slot after the last character. |
| Backspace | Deletes the character in the slot, or the one before it when the slot is empty. Later characters move back. |
| Delete | Deletes the character in the slot and keeps focus there. |
| CtrlBackspace | Clears the whole code. ⌘ Backspace on macOS. |
| CtrlA | Selects the whole code (⌘ A on macOS). Backspace or Delete then clears it and returns to the first slot, typing or pasting replaces it, and Ctrl C copies all of it. Any other key or a click ends the selection. |
- Each slot is a real input. The first takes its name from your
<label>oraria-label; the others are named "Character 2 of 6" and so on. Passaria-labelon a slot to translate it. - The first slot has
autocomplete="one-time-code", so iOS and macOS offer codes from Messages and Mail, Android offers SMS codes, and password managers can fill it. A whole code that lands in one slot is spread across all of them. The animated cascade runs for every source, including codes you set from the WebOTP API. - Whenever the code becomes empty while a slot has focus, such as after a wrong code is cleared, focus moves back to the first slot so the next attempt starts in the right place.
- When
statusis set, a hidden live region next to the field announces it. Change the words withloadingLabel,successLabelanderrorLabel. - With
animated, characters are drawn on a layer hidden from screen readers while the inputs keep the real value. Under reduced motion, characters only fade and the status wave becomes a gentle pulse.
Built on the Base UI OTP field. Every Base UI prop is passed through.
| Prop | Type | Default |
|---|---|---|
lengthRequired. The number of slots; render the same number of InputOTPSlot parts. | number | – |
variant | "joined" | "separate" | "joined" |
size | "sm" | "default" | "lg" | "default" |
animatedAnimate characters in and out, cascade multi-character input and animate the status. | boolean | false |
statusThe result of checking the code. Loading makes the slots read-only. | "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 | – |
onValueCompleteCalled when the last slot fills. | (value: string, details) => void | – |
onValueInvalidCalled when typed or pasted characters are rejected. | (value: string, details) => void | – |
validationType | "numeric" | "alpha" | "alphanumeric" | "none" | "numeric" |
normalizeValueRuns after filtering. Keep it idempotent. | (value: string) => string | – |
inputModeDefaults from validationType. | string | – |
autoComplete | string | "one-time-code" |
autoSubmit | boolean | false |
mask | boolean | false |
aria-invalidMarks every slot invalid. | boolean | – |
name | string | – |
form | string | – |
idGoes on the first slot, so a label's htmlFor points at it. | string | – |
disabled | boolean | false |
readOnly | boolean | false |
required | boolean | false |
className | string | (state) => string | – |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="input-otp" | The root. |
data-variant="joined" | "separate" | The current variant. |
data-size | The current size. |
data-status | The status, when one is set. |
data-animated | Present when animated is on. |
data-shake | Present while the field shakes after the status turns to error. |
data-complete | Present when every slot is filled. |
data-filled | Present when any slot is filled. |
data-focused | Present while a slot has focus. |
data-disabled | Present when disabled. |
data-readonly | Present when read-only, including while loading. |
data-required | Present when required. |
data-invalid / data-valid / data-touched / data-dirty | Field state, inside a Field. |
data-slot="input-otp-status" | The hidden live region, a sibling of the root. |
A plain element that lays out a run of slots. In the joined variant its slots share edges.
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="input-otp-group" | Target the group in CSS. |
A box holding one input. className goes on the box; every other prop goes on the input.
| Prop | Type | Default |
|---|---|---|
aria-labelIgnored on the first slot, which uses the label. | string | "Character N of M" |
classNameThe state has the slot's index, value, filled and the field state. | string | (state) => string | – |
placeholder | string | – |
| Attribute | Description |
|---|---|
data-slot="input-otp-slot" | The box. |
data-filled | Present when the slot has a character. |
data-status | The root's status, when not idle. |
--input-otp-index | The slot's position, used to stagger the status motion. |
data-slot="input-otp-input" | The input inside, with Base UI's data-filled, data-focused, data-complete and field attributes. |
data-slot="input-otp-char" | The drawn character when animated. |
A separator with a minus icon. Pass children to use another icon.
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" |
className | string | (state) => string | – |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="input-otp-separator" | Target the separator in CSS. |
The class names behind a slot and a group (inputOTPGroupVariants). Call them with { variant, size }.