OTP Input
Chassis OtpInput groups individually styled digit boxes into a sequential entry control for verification codes, PINs, and two-factor authentication.
Overview
OtpInput renders length single-character boxes (6 by default) with automatic focus advancement after each digit, backspace navigation back to the previous box, arrow-key navigation, and full-code paste distribution. The first box gets autocomplete="one-time-code" for SMS/email code autofill on supported browsers.
import { OtpInput } from '@chassis-ui/react'
export const Example = () => {
return <OtpInput aria-label="Verification code" length={6} />
}Connected boxes
Pass inputGroup to visually connect the boxes into a single bordered control.
import { OtpInput } from '@chassis-ui/react'
export const Example = () => {
return <OtpInput aria-label="Verification code" inputGroup length={6} />
}Grouped with a separator
Pass groupSizes (e.g. [3, 3]) to split the boxes into groups with a – separator between them — the total number of boxes becomes the sum of groupSizes, overriding length. Combine with inputGroup to connect each group separately.
import { OtpInput } from '@chassis-ui/react'
export const Example = () => {
return <OtpInput aria-label="Verification code" groupSizes={[3, 3]} inputGroup />
}As a form field
Pass label/help to wrap the group in a .form-field with an associated FormLabel (linked via aria-labelledby, since there's no single input to target with htmlFor) and FormHelp. Pass name to have OtpInput create a hidden <input type="hidden"> kept in sync with the code, for native form submission.
With required, every box is required: a code with a box left empty holds its form back from submitting, and the first empty box takes focus. The field then shows as invalid, with its invalidFeedback, until it has a value or the form is reset; set invalid yourself, to false too, to decide how it looks instead.
import { OtpInput } from '@chassis-ui/react'
export const Example = () => {
return (
<OtpInput
label="Verification code"
help="Enter the 6-digit code sent to your phone."
inputGroup
name="code"
/>
)
}Controlled value
Pass value/onChange to control the code externally, and onComplete to react once every box is filled. Omit them (use defaultValue instead) to let OtpInput manage its own state.
import { useState } from 'react'
import { OtpInput } from '@chassis-ui/react'
export const Example = () => {
const [value, setValue] = useState('')
const [complete, setComplete] = useState<string | null>(null)
return (
<div className="vstack gap-sm">
<OtpInput
aria-label="Verification code"
length={4}
onChange={setValue}
onComplete={setComplete}
value={value}
/>
<div className="form-help">
Current value: {value || '(empty)'}
{complete && <> — complete: {complete}</>}
</div>
</div>
)
}Validation
Pass invalid/valid to show a validation state across every box.
import { OtpInput } from '@chassis-ui/react'
export const Example = () => {
return (
<div className="vstack gap-md">
<OtpInput aria-label="Verified code" defaultValue="123456" inputGroup valid />
<OtpInput aria-label="Invalid code" defaultValue="123" inputGroup invalid />
</div>
)
}Disabled
Add disabled to prevent input and make every box non-interactive.
import { OtpInput } from '@chassis-ui/react'
export const Example = () => {
return (
<OtpInput aria-label="Verification code" defaultValue="123" disabled inputGroup length={6} />
)
}Masked
Pass mask to render each box as type="password", hiding entered digits. Every masked box gets autocomplete="one-time-code": browsers ignore off on a password field, so a box without it would be offered saved passwords.
import { OtpInput } from '@chassis-ui/react'
export const Example = () => {
return <OtpInput aria-label="Verification code" defaultValue="123" inputGroup mask />
}Keyboard interaction
Each box handles digit entry, navigation, and paste the same way, regardless of which box currently has focus:
- 0–9 enters a digit and advances focus to the next box.
- ←/→ move focus between boxes.
- Backspace on an empty box clears and focuses the previous box; on a filled box, clears it in place.
- Delete clears the focused box and shifts every following digit one box left.
- Pasting a full code anywhere distributes it across all boxes from the first one.
Accessibility
Each box has no visible label, so it carries its own aria-label ("Digit 1", "Digit 2", …) generated automatically — screen readers announce each box's position. Label the group as a whole with aria-label or aria-labelledby on OtpInput itself, and associate help text with aria-describedby.
Scope
Built on React Aria's useTextField/useFocusRing per box — there's no dedicated "OTP input" hook, so auto-advance, backspace/delete navigation, and paste-splitting are hand-written to match chassis-css's vanilla otp-input.js behavior.
Unlike the vanilla component, OtpInput doesn't expose imperative focus()/clear() methods or a complete/input DOM event pair — use the declarative value/defaultValue/onChange/onComplete props instead.
API
OtpInput
| Prop | Type | Default |
|---|---|---|
aria-describedby? | string | — |
Identifies the element that describes the group, e.g. a FormHelp help element. | ||
aria-label? | string | — |
An accessible label for the group, used when there's no visible <label>. | ||
aria-labelledby? | string | — |
Identifies a visible <label> element for the group. | ||
className? | string | — |
| A string of all className you want applied to the base component. | ||
defaultValue? | string | — |
The initial code (uncontrolled), e.g. "123" for a partially filled code. | ||
disabled? | boolean | — |
| Prevents input and makes every box non-interactive. | ||
groupSizes? | number[] | — |
Splits the boxes into groups (e.g. [3, 3] for a "123-456" layout), rendering a
.form-otp-separator between each group. The total number of boxes becomes the sum of
groupSizes, overriding length. | ||
help? | ReactNode | — |
| A description for the field, rendered below the boxes. | ||
id? | string | — |
id forwarded to the group container. | ||
inputGroup? | boolean | — |
Visually connects the boxes into a single bordered control. When groupSizes is set, each
group is connected separately. | ||
invalid? | boolean | — |
Set component validation state to invalid. Left unset, the component shows as invalid by itself
from a submit it held back (an empty required one, say) until it no longer would, or its
form is reset. Set it, to false too, and it alone decides. | ||
invalidFeedback? | ReactNode | — |
An error message for the field, rendered below the boxes when invalid is set, and after a
submit the field held back, until it no longer would. | ||
label? | ReactNode | — |
The field's caption, rendered as a FormLabel associated with the group via
aria-labelledby (there's no single input to target with htmlFor). | ||
length? | number | 6 |
Number of digit boxes. Ignored when groupSizes is set. Defaults to 6. | ||
mask? | boolean | — |
Mask entered digits using type="password" boxes. | ||
name? | string | — |
name of an auto-created hidden input kept in sync with the code, for native form
submission. Omit to skip creating it. | ||
onChange? | ((value: string) => void) | — |
| Callback fired whenever the code changes. | ||
onComplete? | ((value: string) => void) | — |
| Callback fired once every box is filled. | ||
required? | boolean | — |
| Toggle the required state for the component. Every box is then required: a code with a box left empty holds its form back from submitting, and the first empty box takes focus. | ||
separator? | ReactNode | – |
Content of the separator rendered between groups when groupSizes is set. Defaults to –. | ||
size? | "sm" | "lg" | — |
| Size the boxes sm or lg. | ||
valid? | boolean | — |
| Set component validation state to valid. | ||
validFeedback? | ReactNode | — |
A success message for the field, rendered below the boxes when valid is set. | ||
value? | string | — |
The code (controlled), digits only, e.g. "123456". | ||