Skip to content

About

Customizable, framework-agnostic OTP input library with RTL, i18n, accessibility, timers, toasts, and Web Component support.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

otp-input-kit

otp-input-kit preview

A highly customizable, framework-agnostic OTP input component with full RTL support, i18n, accessibility, countdown timer, toast notifications, and Web Component support — zero dependencies.

CI npm version license OpenSSF Scorecard bundle size


Features

  • 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

Installation

npm

npm install otp-input-kit

CDN (no build step)

<script src="https://unpkg.com/otp-input-kit/dist/otp-input.umd.min.js"></script>

Quick Start

ES Module (bundler / Vite / Webpack)

import OTPInput from 'otp-input-kit';

const otp = OTPInput.create('#container', {
  length: 6,
  onComplete: (value) => console.log('OTP:', value),
});

UMD via <script> tag

<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>

Web Component

<script type="module">
  import 'otp-input-kit';
</script>

<otp-input length="6" theme="rounded" direction="rtl" locale="ar" native-numerals></otp-input>

Options

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

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.

keypad

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: true

timer

timer: {
  enabled: true,
  duration: 60,          // seconds
  showProgress: true,    // animated progress bar
  style: 'bar',          // 'bar' | 'ring' (circular countdown)
  onExpire: () => {},    // callback when time runs out
}

sound

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

resend: {
  enabled: true,
  cooldown: 60,                // seconds between resends
  label: 'Resend code',
  onResend: () => {},          // called when user clicks resend
}

toast

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

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

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.


Callbacks

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
});

Methods

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 countdown

Events (EventEmitter API)

otp.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);

Web Component Attributes

<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>

Web Component Events

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;

Form integration

<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.
  • required makes the form invalid until the code is complete (:invalid, checkValidity(), reportValidity() all work): validity.valueMissing while empty, validity.tooShort while partially filled. Set a custom message with validation-message. Toggling required re-validates in place.
  • Form reset clears it; browser autofill restore repopulates it; a disabled <fieldset> disables it.
  • el.value can 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.


Framework Adapters

The core is framework-agnostic, but first-class wrappers ship in the box.

React

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, getInstance

Every 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

Vue 3

<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.

Svelte

<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)}
/>

Angular

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/core are optional peer dependencies — install whichever you use. The Svelte adapter is a plain action with no dependency.


Themes

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

CSS Custom Properties

#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;
}

RTL & Locale Examples

// 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 ๐๑๒๓๔๕๖๗๘๙).


Validation

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),
});

Async Verification

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),
});

Return values

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.

Manual control

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');
    }
  },
});

Secure Mode & Lockout

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 early

Timer + Resend

OTPInput.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(),
  },
});

SMS autofill & Web OTP

Two complementary mechanisms:

  1. Keyboard suggestions (iOS Safari, Android keyboards) — always on. The first cell has autocomplete="one-time-code" and inputmode="numeric" (text for non-numeric types), and accepts the whole code so the suggestion is spread across all cells instead of being truncated.
  2. Web OTP API — set webOtp: true (or the web-otp attribute) to call navigator.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-origin and the iframe needs allow="otp-credentials".
  • The request is started a microtask after creation (so .on('sms-…') listeners attached right after create() 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/pattern before filling.

Package Formats

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

Browser Support

Chrome 80+, Firefox 75+, Safari 14+, Edge 80+. No IE11 support.


Development

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:3000

dist/ is committed so the CDN links work straight from the repo — run npm run build and commit the result when changing anything under src/.


License

MIT © Fady Ehab

About

Customizable, framework-agnostic OTP input library with RTL, i18n, accessibility, timers, toasts, and Web Component support.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages