Components

Input OTP

A single-field code entry for one-time passwords and verification flows, rendered as a row of individually styled slots with full keyboard, paste, and autofill support.

Basic usage

Set maxLength and render one InputOTPSlot per character inside an InputOTPGroup.

With separator

Split the slots into multiple InputOTPGroup blocks and place an InputOTPSeparator between them to visually chunk the code.

Digits only

Pass a pattern to constrain input — here a four-digit PIN that rejects any non-numeric key. It is the regex source string, not a RegExp. The upstream input-otp package ships equivalent constants of its own (REGEXP_ONLY_DIGITS is ^\d+$), but @comitor/ui does not re-export them — write the literal rather than taking a direct dependency for one regex source string.

Invalid code

A rejected code should turn the whole row red. The catch: the slots are sibling <div>s, not descendants of the real input, so an aria-invalid on InputOTP alone changes nothing you can see. Put the flag on InputOTP for the semantics and on each InputOTPSlot for the border, which uses destructive-ink — the on-page red step, readable in both palettes — rather than the fill-role destructive.

1
2
3
4
5
6

That code has expired. Request a new one.

Disabled

The native disabled attribute blocks interaction and dims the entire container.

1
2
3
4
5
6

Props

InputOTP — forwards every prop of the underlying input-otp field, including the native input attributes.

PropTypeDefaultDescription
maxLengthrequirednumberNumber of characters in the code. Must match the highest slot index + 1.
childrenReact.ReactNodeThe slot markup. Mutually exclusive with render — supply exactly one.
render(props: RenderProps) => React.ReactNodeAlternative to children: render the slots yourself from { slots, isFocused, isHovering }.
valuestringControlled value of the input. Omit for an uncontrolled field.
defaultValuestringSeeds the uncontrolled value. The underlying field forwards it to the real <input> while also setting value itself, so React logs a controlled/uncontrolled warning — for a pre-filled field prefer value with readOnly or an onChange.
onChange(value: string) => voidFires with the full string each time the value changes.
onComplete(value: string) => voidFires once every slot is filled — ideal for auto-submitting a code.
patternstringRegex source string restricting allowed characters, e.g. "^[0-9]+$".
textAlign'left' | 'center' | 'right''left'Where the invisible caret sits relative to the typed characters.
pushPasswordManagerStrategy'increase-width' | 'none''increase-width'How to make room for a password-manager badge. Set to none if the badge is unwanted.
disabledbooleanfalseDisables the field and dims the container to 50% opacity.
containerClassNamestringClasses merged onto the flex container wrapping the groups and separators.
classNamestringClasses merged onto the real <input> element that sits invisibly across the slots.

InputOTPSlot — one slot per character.

PropTypeDefaultDescription
indexrequirednumberZero-based position of the slot within the code. An index past maxLength renders an empty, inert box rather than throwing.
aria-invalidboolean | 'true' | 'false'Draws the destructive-ink border on this slot. Set it per slot — the flag on InputOTP itself reaches the hidden input, not these boxes.
classNamestringAdditional Tailwind classes to merge onto the slot.

InputOTPGroup and InputOTPSeparator take plain <div> props. The group is a bare flex row whose only job is to let slots share rounded outer corners; the separator renders a minus glyph with role="separator" and takes no children.

Accessibility

  • Backed by a single real <input> element, so screen readers, password managers, and SMS autofill (autocomplete="one-time-code") all work out of the box.
  • That input carries no name of its own — pass aria-label on InputOTP (it is forwarded to the real <input>) or point a visible <label htmlFor> at it. Without one, the slots are announced only as a blank edit field.
  • Full keyboard support: arrow keys move between slots, Backspace clears and steps back, and pasting a full code fills every slot at once.
  • The active slot is marked with data-active: it takes a 3px ring-ring/50 ring — the part that actually carries the position at a glance — plus a blinking caret, and its border swaps to --primary-ink, the same token every other focused field in the system uses.
  • The separator carries role="separator" so it is announced as a divider rather than as content.
  • In dark mode the slots are filled with --input-fill. On a card the slot border alone is close to invisible there, so the fill is what keeps the boxes discernible — and it goes transparent again under data-contrast="high", where the border is already strong enough on its own.