A highly customizable, framework-agnostic OTP input component with full RTL support, i18n, accessibility, countdown timer, toast notifications, and Web Component support — zero dependencies.
- Zero dependencies — pure vanilla JS, ~17 KB gzipped (CSS inlined)
- 10 built-in themes — default, underline, rounded, pill, ghost, filled, soft, neon, gradient, elevated
- Full RTL & i18n — 12+ RTL locales, 8 numeral systems (Arabic-Indic, Persian, Hindi, Bengali, Tamil, Thai…)
- WCAG 2.1 AA accessible — ARIA labels, live error regions, keyboard navigation, high-contrast & reduced-motion support
- Countdown timer — progress-bar or circular ring style, with expiry callback
- Audio feedback — optional Web Audio key/success/error tones (no asset files)
- Resend button with configurable cooldown
- Toast notifications — 6 themes, 9 positions, auto-dismiss
- Clipboard paste detection with smart OTP extraction
- SMS autofill —
autocomplete="one-time-code"for iOS/Android keyboard suggestions, plus optional Web OTP API auto-fill - Undo/Redo (Ctrl+Z / Ctrl+Shift+Z)
- Haptic feedback (mobile vibration)
- Async verification — built-in loading spinner that awaits your server and resolves to a success or error state
- Secure mode — password masking with optional 👁 reveal toggle and brute-force attempt lockout
- On-screen keypad — optional built-in virtual number pad (mobile / kiosk / PIN), with shuffle-to-obscure
- Success celebration — success animation styles (pop / glow / bounce / flip) + optional 🎉 confetti burst
- Web Component
<otp-input>— drop in anywhere - Framework adapters — first-class React, Vue 3, Svelte, and Angular wrappers
- TypeScript — ships full type declarations for the core, Web Component, and every adapter
- Form-associated —
<otp-input name="otp">submits, validates, and resets natively inside a<form> - ESM + UMD + CJS builds for every environment
npm install otp-input-kit<script src="https://unpkg.com/otp-input-kit/dist/otp-input.umd.min.js"></script>import OTPInput from 'otp-input-kit';
const otp = OTPInput.create('#container', {
length: 6,
onComplete: (value) => console.log('OTP:', value),
});<div id="container"></div>
<script src="https://unpkg.com/otp-input-kit/dist/otp-input.umd.min.js"></script>
<script>
OTPInput.create('#container', {
length: 6,
onComplete: (value) => verifyOTP(value),
});
</script><script type="module">
import 'otp-input-kit';
</script>
<otp-input length="6" theme="rounded" direction="rtl" locale="ar" native-numerals></otp-input>| Option | Type | Default | Description |
|---|---|---|---|
length |
number |
6 |
Number of OTP digits |
type |
'numeric' | 'alpha' | 'alphanumeric' | 'hex' | 'custom' |
'numeric' |
Allowed character type |
pattern |
RegExp |
null |
Custom pattern (requires type: 'custom') |
secure |
boolean |
false |
Mask input like a password field |
revealToggle |
boolean |
false |
Add an 👁 button to peek at masked digits (secure mode) |
revealLabel / hideLabel |
string |
'Show code' / 'Hide code' |
Reveal-button label in each state (plain text — HTML is not interpreted) |
lockout |
object |
see below | Lock input after too many failed attempts |
autoFocus |
boolean |
true |
Focus first input on init |
autoSubmit |
boolean |
false |
Submit parent <form> on completion |
selectOnFocus |
boolean |
true |
Select digit text on focus |
direction |
'ltr' | 'rtl' | 'auto' |
'auto' |
Input reading direction |
locale |
string |
null |
BCP 47 locale tag (e.g. 'ar', 'fa', 'he') |
nativeNumerals |
boolean |
false |
Render locale-specific digit glyphs |
placeholder |
string |
'·' |
Empty cell placeholder character |
clipboardDetection |
boolean |
true |
Detect and auto-fill pasted OTPs |
haptic |
boolean |
true |
Vibration feedback on mobile |
webOtp |
boolean |
false |
Auto-fill from SMS via the Web OTP API (see SMS autofill). smsAutoRead is a legacy alias |
theme |
string |
'default' |
Input theme (see Themes) |
validate |
Function |
null |
(value) => errorString | null |
animation |
object |
see below | Error/success animation + confetti config |
keypad |
boolean | object |
false |
On-screen virtual number pad (see below) |
timer |
object |
see below | Countdown timer config |
resend |
object |
see below | Resend button config |
toast |
object |
see below | Toast notification config |
onVerify |
Function |
null |
async (value) => result — drives the loading→success/error flow (see Async Verification) |
loading |
object |
see below | Loading/verifying state config |
animation: {
// 'shake' | 'highlight' | 'both' | 'pulse' | 'buzz' | 'bounce' | 'glow' | 'wobble' | false
error: 'shake',
// true | false | 'pop' | 'glow' | 'bounce' | 'flip'
success: true,
confetti: false, // 🎉 burst on success
duration: 300, // ms
}All error styles animate every cell (errors normally fire when the code is
complete), respect prefers-reduced-motion, and trigger a haptic buzz on mobile.
An optional on-screen number pad — great for mobile, kiosks, and PIN entry.
keypad: {
enabled: false,
randomize: false, // shuffle key order (anti shoulder-surfing for PINs)
showClear: false, // include a "clear all" key
backspaceLabel: '⌫',
clearLabel: 'Clear',
}
// shorthand: keypad: truetimer: {
enabled: true,
duration: 60, // seconds
showProgress: true, // animated progress bar
style: 'bar', // 'bar' | 'ring' (circular countdown)
onExpire: () => {}, // callback when time runs out
}Subtle Web Audio feedback (keypress / success / error) — no asset files, created lazily on first key, off by default.
sound: {
enabled: false,
volume: 0.2, // 0..1
}resend: {
enabled: true,
cooldown: 60, // seconds between resends
label: 'Resend code',
onResend: () => {}, // called when user clicks resend
}toast: {
enabled: true,
position: 'top-right', // see positions below
theme: 'default', // see themes below
duration: 3500, // auto-dismiss delay in ms
successMessage: 'Verified!',
errorMessage: 'Invalid code',
}loading: {
text: 'Verifying…', // a11y label shown while verifying
successText: 'Verified',
errorText: 'Verification failed', // shown when onVerify rejects/returns false
clearOnError: true, // wipe the inputs after a failed verify
clearDelay: 900, // ms to keep the error visible before clearing
}lockout: {
enabled: false,
maxAttempts: 3, // failed tries before locking
duration: 30, // seconds the input stays locked
message: 'Too many attempts. Try again in {seconds}s.', // {seconds} counts down
onLock: (secondsRemaining) => {},
onUnlock: () => {},
}A failed attempt is counted on a rejected onVerify, a completion-validation
failure, or a manual setError(). The counter resets on success or unlock.
OTPInput.create('#container', {
onChange: (value) => {}, // fires on every keystroke
onComplete: (value) => {}, // fires when all digits filled
onError: (errors) => {}, // fires on validation failure
onFocus: ({ index, input }) => {}, // fires when a cell is focused
onBlur: ({ index, input }) => {}, // fires when a cell loses focus
onExpire: () => {}, // fires when timer expires
onResend: () => {}, // fires when resend is clicked
onVerified: (value) => {}, // fires when async verify succeeds
onFailed: (message) => {}, // fires when async verify fails
});const otp = OTPInput.create('#container', options);
otp.getValue() // → string of current digits
otp.setValue('123456') // fill all cells programmatically
otp.clear() // clear all cells
otp.focus() // focus first empty cell
otp.setError('msg') // show error state with message
otp.clearError() // remove error state
otp.setLoading(true) // toggle the verifying spinner + disable inputs
otp.setSuccess('msg') // manually enter the verified/success state
otp.toggleReveal() // peek at masked digits (secure mode); pass a boolean to force
otp.lock() // lock the input for the cooldown
otp.unlock() // release a lock early and reset the attempt counter
otp.isLocked() // → boolean
otp.destroy() // unmount and clean up DOM
otp.startTimer() // start / restart countdown
otp.stopTimer() // stop countdownotp.on('complete', (value) => {});
otp.on('change', (value) => {});
otp.on('error', (errors) => {});
otp.on('focus', ({ index }) => {});
otp.on('blur', ({ index }) => {});
otp.on('expire', () => {});
otp.on('resend', () => {});
otp.on('verify-start', (value) => {}); // async verification began
otp.on('verified', (value) => {}); // async verification succeeded
otp.on('verify-failed', (message) => {}); // async verification failed
otp.on('sms-pending', () => {}); // Web OTP request started (webOtp)
otp.on('sms-read', (code) => {}); // Web OTP API auto-filled from SMS
otp.on('sms-error', (err) => {}); // Web OTP request failed (not on abort)
otp.on('sms-unsupported', (reason) => {}); // 'no-api' | 'insecure-context'
otp.on('attempt', ({ attempts, max }) => {}); // a failed attempt was counted
otp.on('lock', (secondsRemaining) => {}); // input locked out
otp.on('unlock', () => {}); // lock released
otp.off('complete', handler);
otp.once('complete', handler);<otp-input
length="6"
type="numeric"
direction="rtl"
locale="ar"
native-numerals
secure
auto-focus
auto-submit
haptic
placeholder="·"
theme="rounded"
timer-duration="60"
resend-enabled
resend-cooldown="60"
clipboard-detection
reveal-toggle
lockout-attempts="3"
lockout-duration="30"
keypad
keypad-randomize
confetti
success-animation="bounce"
timer-style="ring"
sound
sound-volume="0.2"
toast-enabled
toast-theme="glass"
toast-position="top-right"
label="Enter verification code"
web-otp
name="otp"
required
validation-message="Please complete the verification code."
></otp-input>const el = document.querySelector('otp-input');
el.addEventListener('otp-complete', (e) => console.log(e.detail));
el.addEventListener('otp-change', (e) => console.log(e.detail));
el.addEventListener('otp-error', (e) => console.log(e.detail));
el.addEventListener('otp-expire', () => {});
el.addEventListener('otp-resend', () => {});
el.addEventListener('otp-verify-start', (e) => {});
el.addEventListener('otp-verified', (e) => console.log(e.detail));
el.addEventListener('otp-failed', (e) => console.log(e.detail));
el.addEventListener('otp-lock', (e) => console.log(e.detail));
el.addEventListener('otp-unlock', () => {});
el.addEventListener('otp-attempt', (e) => console.log(e.detail));
el.addEventListener('otp-sms-read', (e) => console.log(e.detail));
el.addEventListener('otp-sms-unsupported', (e) => console.log(e.detail));Because functions can't be HTML attributes, attach onVerify as a property:
el.onVerify = async (code) => (await fetch(`/verify/${code}`)).ok;<otp-input> is a form-associated custom element, so it behaves like a
native control inside a <form>:
<form id="login">
<otp-input name="otp" length="6" required></otp-input>
<button type="submit">Verify</button>
</form>
<script type="module">
document.getElementById('login').addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(e.target);
console.log(data.get('otp')); // → the entered code
});
</script>- The value is submitted under the element's
name. requiredmakes the form invalid until the code is complete (:invalid,checkValidity(),reportValidity()all work):validity.valueMissingwhile empty,validity.tooShortwhile partially filled. Set a custom message withvalidation-message. Togglingrequiredre-validates in place.- Form reset clears it; browser autofill restore repopulates it; a
disabled
<fieldset>disables it. el.valuecan be set before the element is connected — it is applied once the inputs are built.
This relies on the ElementInternals form APIs (Chrome/Edge 77+, Firefox 98+,
Safari 16.4+). Where they are missing the element still works, it just doesn't
take part in form submission — read el.value in your submit handler instead.
The core is framework-agnostic, but first-class wrappers ship in the box.
import { OtpInput } from 'otp-input-kit/react';
function Login() {
const ref = useRef(null);
const [code, setCode] = useState('');
return (
<OtpInput
ref={ref}
value={code} // optional — controlled value
onChange={setCode}
length={6}
theme="rounded"
onVerify={async (code) => (await api.verify(code)).ok}
onVerified={() => navigate('/home')}
onFailed={(msg) => toast.error(msg)}
/>
);
}
// ref exposes: getValue, setValue, clear, focus, setError,
// setLoading, setSuccess, setTheme, resetTimer, getInstanceEvery core option is a prop (e.g. webOtp, timer, resend). Callback props
always call the latest handler; changing a structural prop (length, type,
theme, …) rebuilds the inputs. The instance is destroyed on unmount.
There's also a lightweight hook:
import { useOtp } from 'otp-input-kit/react';
const [ref, otp] = useOtp({ length: 6, onComplete: console.log });
return <div ref={ref} />; // otp.current is the OTPInput instance<script setup>
import { ref } from 'vue';
import { OtpInput } from 'otp-input-kit/vue';
const otp = ref(null);
const code = ref('');
const verify = async (code) => (await api.verify(code)).ok;
</script>
<template>
<OtpInput
ref="otp"
v-model="code"
:length="6"
theme="pill"
:on-verify="verify"
@complete="onComplete"
@verified="goHome"
@failed="showError"
/>
</template>v-model binds the code (modelValue / update:modelValue). Emits:
change, complete, error, focus, blur, verify-start, verified,
failed, expire, resend, sms-read.
<script>
import { otp } from 'otp-input-kit/svelte';
const options = {
length: 6,
onVerify: async (code) => (await fetch(`/verify/${code}`)).ok,
};
</script>
<div
use:otp={options}
on:complete={(e) => console.log(e.detail)}
on:verified={goHome}
on:failed={(e) => toast(e.detail)}
/>A standalone directive ships as TypeScript source (Angular always compiles TS):
import { Component } from '@angular/core';
import { OtpInputDirective } from 'otp-input-kit/angular';
@Component({
standalone: true,
imports: [OtpInputDirective],
template: `
<div otpInput
[length]="6"
theme="rounded"
[onVerify]="verify"
(verified)="goHome()"
(failed)="showError($event)"
#otp="otpInput"></div>
`,
})
export class LoginComponent {
verify = async (code: string) => (await this.api.verify(code)).ok;
}Prefer zero setup? The <otp-input> Web Component works in Angular out of the
box — just add CUSTOM_ELEMENTS_SCHEMA to your component/module.
react,vue, and@angular/coreare optional peer dependencies — install whichever you use. The Svelte adapter is a plain action with no dependency.
Pass via the theme option or the theme attribute on <otp-input>.
| Value | Preview description |
|---|---|
default |
Bordered boxes |
underline |
Bottom border only |
rounded |
Softly rounded corners |
pill |
Fully rounded pill shape |
ghost |
Transparent background |
filled |
Solid fill background |
soft |
Pastel/muted fill |
neon |
Glowing accent border |
gradient |
Gradient border |
elevated |
Drop-shadow depth |
#my-container {
--otp-input-width: 52px;
--otp-input-height: 60px;
--otp-font-size: 1.5rem;
--otp-gap: 10px;
--otp-radius: 10px;
--otp-border-color: #e2e8f0;
--otp-active-color: #3b82f6;
--otp-error-color: #ef4444;
--otp-success-color: #22c55e;
--otp-bg: #ffffff;
--otp-text: #0f172a;
}// Arabic — right-to-left with Eastern Arabic numerals
OTPInput.create('#ar', {
length: 6, direction: 'rtl',
locale: 'ar', nativeNumerals: true,
});
// Persian / Farsi
OTPInput.create('#fa', {
length: 6, direction: 'rtl',
locale: 'fa', nativeNumerals: true,
});
// Hebrew — RTL, Western numerals
OTPInput.create('#he', {
length: 6, direction: 'rtl', locale: 'he',
});Supported numeral systems: ar (Arabic-Indic ٠١٢٣٤٥٦٧٨٩), fa (Persian ۰۱۲۳۴۵۶۷۸۹), hi (Hindi ०१२३४५६७८९), bn (Bengali ০১২৩৪৫৬৭৮৯), ta (Tamil ௦௧௨௩௪௫௬௭௮௯), th (Thai ๐๑๒๓๔๕๖๗๘๙).
OTPInput.create('#container', {
validate: (value) => {
if (value === '000000') return 'This code is not allowed';
if (!/^\d+$/.test(value)) return 'Numeric only';
return null; // valid
},
onError: (errors) => console.error(errors),
});Pass an onVerify function and the component takes over the full verify lifecycle:
when the code completes it shows a loading spinner (and disables the inputs),
awaits your function, then resolves to a success or error state automatically.
OTPInput.create('#container', {
length: 6,
onVerify: async (code) => {
const res = await fetch('/api/verify', {
method: 'POST',
body: JSON.stringify({ code }),
});
return res.ok; // see "Return values" below
},
onVerified: (code) => location.assign('/dashboard'),
onFailed: (msg) => console.warn('Rejected:', msg),
});onVerify may return (or resolve to) any of:
| Return | Result |
|---|---|
true / undefined |
✅ success — verified event |
false |
❌ failure with the default message |
'Some message' |
❌ failure showing that message |
{ ok: true } |
✅ success |
{ ok: false, message } |
❌ failure with a custom message |
| throws | ❌ failure using the thrown error's message |
On failure the inputs shake, show the error state, and (by default) clear after
loading.clearDelay ms so the user can retry. Tune via the loading option.
Don't want the automatic flow? Drive it yourself:
const otp = OTPInput.create('#container', {
onComplete: async (code) => {
otp.setLoading(true);
try {
await api.verify(code);
otp.setSuccess('Welcome back!');
} catch (e) {
otp.setError('That code is incorrect');
}
},
});OTPInput.create('#container', {
length: 4,
secure: true, // mask digits like a password
revealToggle: true, // adds an 👁 button to peek
onVerify: (code) => code === '4242',
lockout: {
enabled: true,
maxAttempts: 3, // after 3 wrong codes…
duration: 30, // …lock for 30s with a live countdown
onLock: () => analytics.track('otp_locked'),
onUnlock: () => {},
},
});
// Programmatic control
otp.toggleReveal(true); // force-show the digits
otp.lock(); // lock immediately
otp.unlock(); // release earlyOTPInput.create('#container', {
length: 6,
timer: {
enabled: true,
duration: 60,
showProgress: true,
onExpire: () => console.log('Code expired'),
},
resend: {
enabled: true,
cooldown: 60,
label: 'Resend code',
onResend: () => sendNewCode(),
},
});Two complementary mechanisms:
- Keyboard suggestions (iOS Safari, Android keyboards) — always on. The
first cell has
autocomplete="one-time-code"andinputmode="numeric"(textfor non-numeric types), and accepts the whole code so the suggestion is spread across all cells instead of being truncated. - Web OTP API — set
webOtp: true(or theweb-otpattribute) to callnavigator.credentials.get({ otp: { transport: ['sms'] } })and fill the code as soon as the SMS arrives.
const otp = OTPInput.create('#container', {
length: 6,
webOtp: true,
onSmsRead: (code) => console.log('from SMS', code),
onComplete: (code) => verify(code),
});
otp.on('sms-unsupported', (reason) => {}); // 'no-api' | 'insecure-context'The SMS must end with a line binding it to your origin, for example:
Your code is 123456
@example.com #123456
Caveats:
- Support: Chrome on Android (and Chromium-based Android browsers). iOS has
no Web OTP API — it relies on the keyboard suggestion from (1). Elsewhere the
option is a feature-detected no-op that emits
sms-unsupported. - HTTPS only (or
localhost), and the domain after@must match the top-level page's origin. In a cross-origin iframe the SMS needs a second@frame-originand the iframe needsallow="otp-credentials". - The request is started a microtask after creation (so
.on('sms-…')listeners attached right aftercreate()fire), aborted when the code is completed or the instance is destroyed, and started again after Resend. - The browser shows its own consent prompt; a dismissal emits
sms-error. - Received codes are normalised to western digits and filtered through the
configured
type/patternbefore filling.
| Format | File | Use case |
|---|---|---|
| ESM | dist/otp-input.esm.js |
Vite, Webpack, Rollup |
| UMD | dist/otp-input.umd.js |
<script> tag |
| CJS | dist/otp-input.umd.cjs |
Node.js require() |
| Minified UMD | dist/otp-input.umd.min.js |
CDN / production |
Chrome 80+, Firefox 75+, Safari 14+, Edge 80+. No IE11 support.
Requires Node.js 20+.
npm install
npm test # node:test + jsdom unit tests (test/*.test.js)
npm run build # rollup → dist/
npm run demo # serve the demo pages on http://localhost:3000dist/ is committed so the CDN links work straight from the repo — run
npm run build and commit the result when changing anything under src/.
MIT © Fady Ehab
