Morphos
Inputs

OtpField

A one-time password input with individual cells, paste support, and a completion callback.

Interactive example

Installation

npm install @morphos/inputs
pnpm add @morphos/inputs
yarn add @morphos/inputs
bun add @morphos/inputs

Import

import { OtpField } from '@morphos/inputs'

Usage

@Component()
class MyComponent extends StatefulComponent {
  verify(code: string) {
    console.log('OTP submitted:', code)
  }

  render() {
    return (
      <OtpField
        class="morphos-otp-field"
        length={6}
        onComplete={(value) => this.verify(value)}
        aria-label="One-time password"
      />
    )
  }
}

Controlled value

@Component()
class MyComponent extends StatefulComponent {
  @State() code = ''

  render() {
    return (
      <OtpField
        class="morphos-otp-field"
        length={4}
        value={this.code}
        onValueChange={(value) => (this.code = value)}
        onComplete={(value) => console.log('Done:', value)}
        aria-label="PIN"
      />
    )
  }
}

Alphanumeric OTP

@Component()
class MyComponent extends StatefulComponent {
  submitCode(code: string) {
    fetch('/api/verify', { method: 'POST', body: JSON.stringify({ code }) })
  }

  render() {
    return (
      <OtpField
        class="morphos-otp-field"
        length={8}
        pattern="[A-Za-z0-9]"
        inputMode="text"
        onComplete={(value) => this.submitCode(value)}
        aria-label="Verification code"
      />
    )
  }
}

With name for form submission

@Component()
class MyComponent extends StatefulComponent {
  render() {
    return (
      <form method="post" action="/verify">
        <OtpField
          class="morphos-otp-field"
          length={6}
          name="otp"
          inputMode="numeric"
          pattern="[0-9]"
          aria-label="One-time password"
        />
        <button type="submit">Verify</button>
      </form>
    )
  }
}

Props — OtpField

PropTypeDefaultDescription
lengthnumber6Number of individual input cells
valuestringControlled value (indexed per-cell; extra characters beyond length are ignored)
defaultValuestringUncontrolled initial value
onValueChange(value: string) => voidFires on every cell change with the combined value
onComplete(value: string) => voidFires once all cells hold a non-empty value
disabledbooleanfalseDisables all cell inputs
namestringA hidden <input type="hidden"> with this name carries the combined value for form submission
patternstring"[0-9]"Regex pattern applied to each cell's pattern attribute
inputMode"numeric" | "text""numeric"Virtual keyboard hint for mobile browsers
classstringAdditional CSS classes on the root element
idstringBase id — the group container gets id, and each cell gets {id}-0, {id}-1, etc. (falls back to an auto-generated base when omitted)
aria-labelstringAccessible label for the group container

data-* attributes

AttributeElementWhen present
data-disabledRootdisabled is true
data-indexEach input cellAlways — zero-based index of the cell

Keyboard navigation

KeyBehavior
Any characterFills the current cell with the last typed character and advances to the next cell
ArrowRightMove focus to the next cell
ArrowLeftMove focus to the previous cell
BackspaceClear the current cell; if already empty, move to the previous cell and clear it
PasteDistribute the pasted text across cells, starting at the focused cell, one character per cell

Rendered structure

<div role="group" aria-label="..." data-disabled?>
  <input type="text" maxlength="1" data-index="0" inputmode="numeric" pattern="[0-9]" />
  <input type="text" maxlength="1" data-index="1" inputmode="numeric" pattern="[0-9]" />
  <!-- ... repeated `length` times -->
  <input type="hidden" name="otp" value="..." /><!-- only when `name` is set -->
</div>

Accessibility

The root element has role="group" and the aria-label provided as a prop. Each cell is a separate <input type="text" maxlength="1"> with an auto-generated id and an aria-label like "Digit 1 of 6". onComplete fires as soon as every cell holds a non-empty value, allowing immediate action without requiring an explicit submit.

Pasting distributes the clipboard text starting at the focused cell, one character per remaining cell, and characters beyond length are discarded. Cells before the paste start point and cells past the end of the pasted text are left untouched.

Styling example

.morphos-otp-field > input[data-index] {
  width: 2.5rem;
  height: 3rem;
  text-align: center;
}

.morphos-otp-field > input[data-index]:focus-visible {
  outline: none;
  border-color: var(--morphos-color-accent);
  box-shadow: var(--morphos-focus-ring);
}

.morphos-otp-field[data-disabled] > input[data-index] {
  opacity: 0.5;
  cursor: not-allowed;
}

On this page