RoadieRoadie
  • Home
  • Foundations
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeFoundationsComponents
Appearance

Components

  • Navigation

    • Breadcrumb
    • Navigator
    • Steps
    • Tabs
  • Layout

    • Pane
    • ScrollArea
    • Separator
  • Actions

    • Button
    • IconButton
    • Toggle
    • Toggle group
  • Forms

    • Forms overview
    • Autocomplete
    • Checkbox
    • Combobox
    • Field
    • Fieldset
    • Input
    • Label
    • Number field
    • OTP field
    • Radio group
    • Select
    • Slider
    • Switch
    • Textarea
  • Overlays

    • Dialog
    • Drawer
    • Menu
    • Popover
    • Toast
    • Tooltip
  • Collections

    • Accordion
    • Card
    • Carousel
    • Collapsible
    • DataTable
    • List
    • Marquee
    • Table
  • Status

    • Badge
    • Callout
    • Countdown
    • Empty state
    • Meter
    • Progress
    • Skeleton
    • Sparkline
    • StatTile
  • Media & brand

    • Avatar
    • Icon Tile
    • Image
    • Logo
    • QR code
    • SpotIllustration
  • Text

    • CalendarTile
    • Code
    • DateTime
    • Duration
    • Highlight
    • Mark
    • Prose

OTP field

A one-time code input with one slot per character.

Import

import { OTPField } from '@oztix/roadie-components/otp-field'

Examples

Default

Set length and OTPField renders that many slots. Typing moves to the next slot, Backspace moves back, and pasting a full code fills every slot at once. Codes are numeric by default, so phones show the number pad.

<Field>
  <Field.Label>Login code</Field.Label>
  <OTPField length={6} />
</Field>

Grouped

groupSize splits the slots into groups with a separator between them. Six digits read as 3-3, which is easier to hold in your head while switching between apps.

<Field>
  <Field.Label>Login code</Field.Label>
  <OTPField length={6} groupSize={3} />
</Field>

Emphasis

normal is the sunken field surface that Input uses. subtle swaps the border for a tinted fill.

<div className='grid gap-4'>
  <OTPField length={6} aria-label='Normal' />
  <OTPField length={6} aria-label='Subtle' emphasis='subtle' />
</div>

Sizes

Slots match Input heights: sm is 32px, md 40px and lg 48px square, so they sit level with an Input or Button of the same size. Use lg for touch, such as checkout on a phone. When the row runs out of room, the slots shrink together rather than wrap.

<div className='grid gap-4'>
  <OTPField length={6} aria-label='Small' size='sm' />
  <OTPField length={6} aria-label='Medium' size='md' />
  <OTPField length={6} aria-label='Large' size='lg' />
</div>

States

Each slot uses is-interactive-field: neutral at rest, accent on focus, danger when invalid.

<div className='grid gap-4'>
  <div className='grid gap-1'>
    <p className='text-sm text-subtle'>Default</p>
    <OTPField length={6} aria-label='Default' />
  </div>
  <div className='grid gap-1'>
    <p className='text-sm text-subtle'>Filled</p>
    <OTPField length={6} aria-label='Filled' defaultValue='482913' />
  </div>
  <div className='grid gap-1'>
    <p className='text-sm text-subtle'>Invalid</p>
    <OTPField length={6} aria-label='Invalid' defaultValue='482913' invalid />
  </div>
  <div className='grid gap-1'>
    <p className='text-sm text-subtle'>Disabled</p>
    <OTPField length={6} aria-label='Disabled' disabled />
  </div>
</div>

Composition

With Field

Wrap it in Field for the label, helper text and spacing. Field.Label names the first slot and the group, and the helper text describes the group.

<Field required>
  <Field.Label showIndicator>Verification code</Field.Label>
  <OTPField length={6} groupSize={3} />
  <Field.HelperText>We sent a code to your email</Field.HelperText>
</Field>

With Field and error

Set invalid on the Field. Every slot turns danger and the group points at the error text.

<Field invalid>
  <Field.Label>Verification code</Field.Label>
  <OTPField length={6} groupSize={3} defaultValue='204816' />
  <Field.ErrorText>That code has expired</Field.ErrorText>
</Field>

Custom parts

Pass OTPField.Input and OTPField.Separator as children to lay the slots out yourself. The number of inputs must match length.

<Field>
  <Field.Label>Door code</Field.Label>
  <OTPField length={4}>
    <OTPField.Input />
    <OTPField.Input />
    <OTPField.Separator className='w-2' />
    <OTPField.Input />
    <OTPField.Input />
  </OTPField>
</Field>

Submit when complete

onValueComplete fires once the last slot fills, whether the code was typed, pasted or autofilled. Use it to check the code straight away. autoSubmit submits the surrounding form instead.

function LoginCode() {
  const [status, setStatus] = useState('idle')

  return (
    <form
      className='grid max-w-sm gap-4'
      onSubmit={(event) => event.preventDefault()}
    >
      <Field invalid={status === 'expired'}>
        <Field.Label>Login code</Field.Label>
        <OTPField
          length={6}
          groupSize={3}
          onValueChange={() => setStatus('idle')}
          onValueComplete={(code) =>
            setStatus(code === '123456' ? 'verified' : 'expired')
          }
        />
        <Field.HelperText>
          {status === 'verified'
            ? 'Code accepted. Signing you in.'
            : 'We sent a code to your email. Try 123456.'}
        </Field.HelperText>
        <Field.ErrorText>That code has expired</Field.ErrorText>
      </Field>
    </form>
  )
}

render(<LoginCode />)

Guidelines

Let the browser fill the code

<OTPField length={6} onValueComplete={verify} />

Do

Keep the default autoComplete='one-time-code' so phones offer the code from a text or email. Check it in onValueComplete so nobody has to find a button.

<OTPField length={6} autoComplete='off' />

Don’t

Don't turn autofill off or make people press Continue after the last digit.

Say what went wrong

<Field.ErrorText>That code has expired</Field.ErrorText>

Do

Name the problem, such as an expired or incorrect code, and offer a way to send a new one.

<Field.ErrorText>Invalid input</Field.ErrorText>

Don’t

Don't use a generic error. People can't tell if they mistyped or need a fresh code.

Accessibility

  • The root is a group. Field.Label names the group and the first slot, and the other slots are named by position, such as "Character 2 of 6". Outside Field, aria-label on the root does the same job.
  • Only the active slot is in the tab order, so Tab moves in and out of the whole code. Arrow Left and Arrow Right move one slot at a time, up to the first empty slot, and Backspace clears and steps back.
  • Each slot is a real <input>, so screen readers, password managers and SMS autofill see it. A hidden input carries the whole value for forms.
  • Field.HelperText or Field.ErrorText describes the group, and invalid sets aria-invalid on every slot.

API reference

OTPField

Base UI
idstring

The id of the first input element. Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on).

autoCompletestring

The input autocomplete attribute. Applied to the first slot and hidden validation input.

Defaults to 'one-time-code'.

formstring

A string specifying the `form` element with which the hidden input is associated. This string's value must match the id of a `form` element in the same document.

lengthnumber
Required

The number of OTP input slots. Required so the root can clamp values, detect completion, and generate consistent validation markup before all slots hydrate.

autoSubmitboolean

Whether to submit the owning form when the OTP becomes complete.

Defaults to false.

maskboolean

Whether the slot inputs should mask entered characters. Pass `type` directly to individual `<OTPField.Input>` parts to use a custom input type.

Defaults to false.

inputMode"search" | "text" | "email" | "tel" | "url" | "none" | "numeric" | "decimal"

The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed.

validationType"none" | "numeric" | "alpha" | "alphanumeric"

The type of input validation to apply to the OTP value.

Defaults to 'numeric'.

normalizeValue((value: string) => string)

Function that normalizes the OTP value after whitespace and `validationType` filtering. It runs whenever OTP Field normalizes a value, including initial/default values, controlled values, and user edits. The returned value is filtered by `validationType` again, then clamped to `length`. It should be idempotent because OTP Field may normalize the same value more than once while handling edits, storing state, and rendering controlled or uncontrolled values. Non-idempotent normalizers can compound across those normalization passes. Characters rejected while normalizing typed or pasted text are reported through `onValueInvalid`.

requiredboolean

Whether the user must enter a value before submitting a form.

Defaults to false.

disabledboolean

Whether the component should ignore user interaction.

Defaults to false.

readOnlyboolean

Whether the user should be unable to change the field value.

Defaults to false.

namestring

Identifies the field when a form is submitted.

valuestring

The OTP value.

defaultValuestring

The uncontrolled OTP value when the component is initially rendered.

onValueChange((value: string, eventDetails: OTPFieldRootChangeEventDetails) => void)

Callback fired when the OTP value changes. The `eventDetails.reason` indicates what triggered the change: - `'input-change'` for typing or autofill - `'input-clear'` when a character is removed by text input - `'input-paste'` for paste interactions - `'keyboard'` for keyboard interactions that change the value

onValueInvalid((value: string, eventDetails: OTPFieldRootInvalidEventDetails) => void)

Callback fired when entered text contains characters that are rejected by validation or normalization before the OTP value updates. The `value` argument is the attempted user-entered string before normalization.

onValueComplete((value: string, eventDetails: OTPFieldRootCompleteEventDetails) => void)

Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than `onValueChange`, after the internal value update is applied. If a complete pasted value matches the current value, `onValueChange` does not fire. If `autoSubmit` is enabled, it runs immediately before the owning form is submitted.

classNamestring | ((state: OTPFieldRootState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

size"sm" | "md" | "lg"

Slot size, matching `Input` heights: 32, 40 or 48px square. Use `lg` for touch.

emphasis"normal" | "subtle"

Slot surface. `subtle` swaps the border for a tinted fill.

invalidboolean

Marks the code as invalid. Inherits from `Field` when omitted.

groupSizenumber

Slots per group when the root renders its own slots. A separator sits between groups, so `length={6} groupSize={3}` reads as 3-3.

OTPField.Input

Base UI
classNamestring | ((state: OTPFieldInputState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

size"sm" | "md" | "lg"

Slot size. Inherits from the root when omitted.

emphasis"normal" | "subtle"

Slot surface. Inherits from the root when omitted.

OTPField.Separator

Base UI
classNamestring | ((state: SeparatorState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

Inherited from SeparatorProps

orientation"horizontal" | "vertical"

The orientation of the separator.

Defaults to 'horizontal'.

Previous page← Number fieldNext pageRadio group →