Skip to main contentSkip to docs navigation

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.

JSX
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.

JSX
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.

–
JSX
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.

Enter the 6-digit code sent to your phone.
JSX
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.

Current value: (empty)
JSX
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.

JSX
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.

JSX
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.

JSX
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

PropTypeDefault
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?number6
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".