Skip to content
Devix Open Source

Guide

Getting started

npm install @devix-labs/otp-input
<label for="code">Verification code</label>
<input id="code" name="code">
import { createOtpInput } from '@devix-labs/otp-input';
import '@devix-labs/otp-input/styles.css';

const otp = createOtpInput(document.querySelector('#code'), {
  length: 6,
  autoFocus: true,
  webOtp: true,
  onComplete: (code) => form.submit(),
});

The input you point at becomes the hidden field that carries the whole code, so a plain form posts one value under its existing name. Everything else is drawn beside it.

Autofill

Three things have to be true for a code to arrive by itself, and this does all three:

Platform What it needs What we do
iOS, Safari and password managers autocomplete="one-time-code" on the first box
Chrome on Android the WebOTP API webOtp: true asks for it
Dashlane, 1Password a real input they can fill the boxes are real inputs

When a manager drops all six characters into the first box — which is what they do — the code is spread across the rest instead of being truncated.

For WebOTP, your SMS has to end with the origin line:

Your code is 123456

@example.com #123456

What may be typed

createOtpInput(input, { length: 6, mode: 'numeric' });        // the default
createOtpInput(input, { length: 8, mode: 'alphanumeric' });   // folded to upper case
createOtpInput(input, { length: 4, allow: (c) => /[0-9a-f]/i.test(c) });   // your own rule

Arabic-Indic, Persian, Devanagari, Bengali and full-width digits are read as digits, so a code pasted from an Arabic SMS works.

Looks

createOtpInput(input, { length: 6, separators: [2], separator: '–' });   // 123–456
createOtpInput(input, { length: 4, mask: true });                        // a PIN

Separators are decoration: they never appear in the value.

After a wrong code

const otp = createOtpInput(input, { length: 6, onComplete: check });

async function check(code) {
  const ok = await verify(code);
  if (!ok) otp.reject('That code has expired — we have sent a new one');
}

reject() marks every box, announces the message to a screen reader, and puts the cursor back at the start.

The rest of the API

Call Does
getValue() / setValue(code)
clear() Empties it and focuses the first box.
focus(index?) Without an index, the first empty box.
reject(message?) Marks a failed attempt.
enable() / disable()
destroy() Puts the original input back exactly as it was.

Events dx:otpchange and dx:otpcomplete bubble from the group, and the hidden input fires input and change, so a framework binding on it works without any of this API.

React

import { OtpField } from '@devix-labs/otp-input/react';

<OtpField length={6} webOtp autoFocus onComplete={(code) => verify(code)} />

It renders as a plain input on the server and upgrades in the browser, so there is no hydration warning.

Vue, Svelte and <dx-otp length="6" name="code" web-otp> are in the package too.

Updated 15 Sep 2026