Advanced OTP Input Library for Vanilla JS, React, Vue, Svelte & More – Digito
The library uses one transparent <input> to capture all keyboard and paste events, then mirrors the current slot state into visual <div> elements.
The browser sees a single real field, so native SMS autofill, password manager detection, and screen reader support all work as expected.
● glyphs in slots and sets type="password" on the hidden input.XXX — XXX) with no effect on the returned value string.pattern option accepts a RegExp that overrides type for per-character validation.navigator.vibrate and the Web Audio API on completion and error events.data-* variants and plain CSS attribute selectors.Install with the package manager you prefer.
# npm npm i digitojs # pnpm pnpm add digitojs # yarn yarn add digitojs
CDN (no build step):
<!-- Vanilla JS — exposes window.Digito global --> <script src="https://unpkg.com/digitojs/dist/digito.min.js"></script> <!-- Web Component — auto-registers <digito-input> custom element --> <script src="https://unpkg.com/digitojs/dist/digito-wc.min.js"></script>
Add a <div> wrapper. Set slot count and behavior via data-* attributes, then call initDigito().
<!-- The wrapper div — data-* attributes configure the field -->
<div
class="otp-field"
data-length="6"
data-type="numeric"
data-timer="90"
data-separator-after="3"
data-separator="—"
></div>
<script type="module">
import { initDigito } from 'digitojs'
// Mount on the wrapper selector. Returns an array of DigitoInstance objects.
const [otp] = initDigito('.otp-field', {
onComplete: (code) => submitVerification(code), // fires when all 6 slots are filled
onResend: () => requestNewCode(), // fires when the resend button is clicked
})
</script>
Digito injects the slot elements, countdown badge, and resend button automatically. No additional markup is required.
Instance API (Vanilla)
// Returns the current joined code as a string — e.g., "482917" otp.getCode() // Clears all slots, restarts the countdown timer, and re-focuses the hidden input otp.reset() // Calls reset() and fires onResend — use from a custom resend button otp.resend() // Applies a red error ring to all slots. Pass false to clear. otp.setError(true) // Applies a green success ring to all slots. Pass false to clear. otp.setSuccess(true) // Locks the field during an async verification call. // Keyboard navigation inside the field remains active while locked. otp.setDisabled(true) // Moves browser focus to a specific slot by zero-based index otp.focus(0) // Removes all event listeners, stops the timer, and aborts the Web OTP request otp.destroy()
Custom Timer UI
Pass onTick to suppress the built-in countdown display and drive your own element.
const [otp] = initDigito('.otp-field', {
timer: 90,
// Fires every second with the remaining seconds. Suppresses the built-in badge UI.
onTick: (remaining) => {
const m = Math.floor(remaining / 60)
const s = String(remaining % 60).padStart(2, '0')
timerDisplay.textContent = `Code expires in: ${m}:${s}`
},
onExpire: () => (resendSection.hidden = false),
onResend: () => {
otp.resend() // calls reset() and fires onResend
resendSection.hidden = true
},
})
import { useOTP, HiddenOTPInput } from 'digitojs/react'
export function VerificationInput() {
// Initialize the OTP state machine. All interaction logic is handled internally.
const otp = useOTP({
length: 6,
onComplete: (code) => submitVerification(code),
})
return (
// wrapperProps carries data-complete, data-invalid, etc. for CSS and Tailwind styling
<div {...otp.wrapperProps} style={{ position: 'relative', display: 'inline-flex', gap: 10 }}>
{/* HiddenOTPInput is a forwardRef wrapper. It applies absolute-positioning styles automatically. */}
<HiddenOTPInput {...otp.hiddenInputProps} />
{otp.slotValues.map((_, i) => {
// getSlotProps returns full render metadata for each visual slot
const { char, isActive, isFilled, isError, hasFakeCaret } = otp.getSlotProps(i)
return (
<div
key={i}
className={[
'slot',
isActive ? 'active' : '',
isFilled ? 'filled' : '',
isError ? 'error' : '',
].filter(Boolean).join(' ')}
>
{/* hasFakeCaret is true when the slot is active, empty, and focused */}
{hasFakeCaret && <span className="caret" />}
{char}
</div>
)
})}
</div>
)
}
Controlled mode (e.g., with react-hook-form):
import { useState } from 'react'
import { useOTP, HiddenOTPInput } from 'digitojs/react'
export function ControlledOTP() {
// Track the current code value externally
const [code, setCode] = useState('')
// Pass value and onChange to put the field in controlled mode
const otp = useOTP({ length: 6, value: code, onChange: setCode })
return (
<div {...otp.wrapperProps} style={{ position: 'relative', display: 'inline-flex', gap: 8 }}>
<HiddenOTPInput {...otp.hiddenInputProps} />
{otp.slotValues.map((_, i) => {
const { char, isActive, isFilled } = otp.getSlotProps(i)
return (
<div
key={i}
className={['slot', isActive && 'active', isFilled && 'filled']
.filter(Boolean).join(' ')}
>
{char}
</div>
)
})}
</div>
)
}
React note on
setDisabled: React does not exposesetDisabled()on the hook return. Passdisableddirectly as an option:useOTP({ disabled: isVerifying }). Update it via state — e.g.,const [isVerifying, setIsVerifying] = useState(false).
<script setup lang="ts">
import { useOTP } from 'digitojs/vue'
// useOTP returns reactive Refs and event handler functions for template binding
const otp = useOTP({ length: 6, onComplete: (code) => submitVerification(code) })
</script>
<template>
<div v-bind="otp.wrapperAttrs.value" style="position: relative; display: inline-flex; gap: 10px">
<input
-ref="(el) => (otp.inputRef.value = el as HTMLInputElement)"
v-bind="otp.hiddenInputAttrs"
style="position: absolute; inset: 0; opacity: 0; z-index: 1"
@keydown="otp.onKeydown"
@input="otp.onChange"
@paste="otp.onPaste"
@focus="otp.onFocus"
@blur="otp.onBlur"
/>
<template v-for="(char, i) in otp.slotValues.value" :key="i">
<!-- Insert a visual separator at the configured position -->
<span
v-if="otp.separatorAfter.value && i === otp.separatorAfter.value"
aria-hidden="true"
>{{ otp.separator.value }}</span>
<div
class="slot"
-class="{
'active': i === otp.activeSlot.value && otp.isFocused.value,
'filled': !!char,
'error': otp.hasError.value,
}"
>
{{ char || otp.placeholder }}
</div>
</template>
</div>
</template>
Reactive controlled value in Vue:
import { ref } from 'vue'
import { useOTP } from 'digitojs/vue'
// Pass a Ref<string> to value. Assigning it resets the field reactively.
const code = ref('')
const otp = useOTP({ length: 6, value: code })
// Later: reset the field from outside by clearing the ref
code.value = ''
<script>
import { useOTP } from 'digitojs/svelte'
// Returns a Svelte store. Subscribe with $otp to access reactive state.
const otp = useOTP({ length: 6, onComplete: (code) => submitVerification(code) })
</script>
<div {...$otp.wrapperAttrs} style="position: relative; display: inline-flex; gap: 10px">
<!-- use:otp.action binds all event listeners to the hidden input -->
<input
use:otp.action
style="position: absolute; inset: 0; opacity: 0; z-index: 1"
/>
{#each $otp.slotValues as char, i}
{#if $otp.separatorAfter && i === $otp.separatorAfter}
<span aria-hidden="true">{$otp.separator}</span>
{/if}
<div
class="slot"
class:active={i === $otp.activeSlot}
class:filled={!!char}
class:error={$otp.hasError}
>
{char || otp.placeholder}
</div>
{/each}
</div>
import Alpine from 'alpinejs'
import { DigitoAlpine } from 'digitojs/alpine'
// Register x-digito as an Alpine directive
Alpine.plugin(DigitoAlpine)
Alpine.start()
<!-- x-digito accepts the same options object as initDigito -->
<div x-digito="{
length: 6,
timer: 90,
onComplete(code) { submitVerification(code) },
onResend() { requestNewCode() },
}"></div>
<script>
// Access the DigitoInstance via _digito on the element after Alpine mounts
const el = document.querySelector('[x-digito]')
el._digito.setError(true)
el._digito.getCode()
el._digito.reset()
</script>
<script type="module">
// Importing the module auto-registers the <digito-input> custom element
import 'digitojs/web-component'
</script>
<digito-input
length="6"
type="numeric"
timer="90"
placeholder="·"
separator-after="3"
name="verification_code"
></digito-input>
<script>
const el = document.querySelector('digito-input')
// Options that cannot be set as HTML attributes must be assigned as JS properties
el.pattern = /^[A-Z0-9]$/
el.pasteTransformer = (s) => s.toUpperCase()
el.onComplete = (code) => submitVerification(code)
el.onResend = () => requestNewCode()
// The Web Component dispatches DOM CustomEvents that bubble and are composed
el.addEventListener('complete', (e) => console.log('Submitted:', e.detail.code))
el.addEventListener('expire', () => showResendOption())
el.addEventListener('change', (e) => console.log('Current value:', e.detail.code))
// Programmatic API
el.reset()
el.setError(true)
el.getCode()
</script>
For advanced use cases, like custom rendering targets, SSR pre-computation, or unit testing, use createDigito directly.
import { createDigito } from 'digitojs'
// Pure state machine — no DOM, no framework required
const otp = createDigito({ length: 6, type: 'numeric' })
// Subscribe to state snapshots (XState-style)
const unsub = otp.subscribe((state) => render(state))
unsub() // unsubscribe when done
// Input actions — dispatch these in response to DOM events
otp.inputChar(slotIndex, char) // fill a slot with a validated character
otp.deleteChar(slotIndex) // backspace: clear slot and step back if empty
otp.clearSlot(slotIndex) // delete: clear slot, focus stays in place
otp.pasteString(cursorSlot, text) // paste: distribute characters from cursor forward
otp.moveFocusLeft(pos)
otp.moveFocusRight(pos)
otp.moveFocusTo(index)
// State control
otp.setError(bool)
otp.resetState()
otp.setDisabled(bool)
otp.setReadOnly(bool)
otp.cancelPendingComplete() // cancel the deferred onComplete without clearing slots
// Read state
otp.getCode() // joined code string
otp.getSnapshot() // full DigitoState snapshot
otp.getState() // alias for getSnapshot() — Zustand-style
import { createTimer } from 'digitojs'
const timer = createTimer({
totalSeconds: 90,
onTick: (remaining) => updateTimerDisplay(remaining),
onExpire: () => showResendButton(),
})
timer.start() // begin countdown — idempotent, calling twice never double-ticks
timer.stop() // pause the countdown
timer.reset() // restore to totalSeconds without restarting
timer.restart() // reset() + start()
length (number): Number of input slots. Defaults to 6.type ('numeric' | 'alphabet' | 'alphanumeric' | 'any'): Character class for per-character validation. Defaults to 'numeric'.pattern (RegExp): Per-character regex that overrides type for validation.pasteTransformer ((raw: string) => string): Transforms clipboard text before character filtering runs.onComplete ((code: string) => void): Fires when all slots are filled. Fires after DOM sync and is cancellable via cancelPendingComplete().onExpire (() => void): Fires when the countdown timer reaches zero.onResend (() => void): Fires when the user triggers a resend action.onTick ((remaining: number) => void): Fires every second during countdown. Passing this option suppresses the built-in timer badge UI in the Vanilla adapter.onInvalidChar ((char: string, index: number) => void): Fires when a typed character fails the type or pattern check.onChange ((code: string) => void): Fires on every user interaction (keypress, paste, delete).onFocus (() => void): Fires when the hidden input gains browser focus.onBlur (() => void): Fires when the hidden input loses browser focus.timer (number): Countdown duration in seconds. Set to 0 to disable. Defaults to 0.resendAfter (number): Resend button cooldown in seconds (Vanilla adapter only). Defaults to 30.autoFocus (boolean): Focuses the hidden input on mount. Defaults to true.blurOnComplete (boolean): Blurs the input on completion for auto-advance to the next field. Defaults to false.selectOnFocus (boolean): Enables select-and-replace behavior when focusing a filled slot. Defaults to false.placeholder (string): Character displayed in empty slots (e.g., '○', '_'). Defaults to ''.masked (boolean): Renders maskChar glyphs in slots and sets type="password" on the hidden input. Defaults to false.maskChar (string): Glyph used in masked mode. Defaults to '●'.name (string): Sets the name attribute on the hidden input for native <form> and FormData submission.separatorAfter (number | number[]): One-based slot index or array of indices after which a visual separator is inserted.separator (string): Separator character rendered between groups. Defaults to '—'.disabled (boolean): Disables all input on mount. Defaults to false.readOnly (boolean): Blocks mutations while keeping the field focusable and copyable. Defaults to false.defaultValue (string): Uncontrolled pre-fill applied once on mount. Does not trigger onComplete.haptic (boolean): Calls navigator.vibrate(10) on completion and error. Defaults to true.sound (boolean): Plays an 880 Hz tone via the Web Audio API on completion. Defaults to false.Digito exposes CSS custom properties on the wrapper element. Set them on .digito-wrapper (Vanilla) or digito-input (Web Component) to theme all slots at once.
.digito-wrapper {
/* Dimensions */ --digito-size: 56px; /* slot width and height */ --digito-gap: 12px; /* gap between slots */ --digito-radius: 10px; /* slot border radius */ --digito-font-size: 24px; /* digit font size */
/* Colors */ --digito-color: #0A0A0A; /* digit text color */ --digito-bg: #FAFAFA; /* empty slot background */ --digito-bg-filled: #FFFFFF; /* filled slot background */ --digito-border-color: #E5E5E5; /* default slot border */ --digito-active-color: #3D3D3D; /* active border and ring */ --digito-error-color: #FB2C36; /* error border and ring */ --digito-success-color:#00C950; /* success border and ring */ --digito-caret-color: #3D3D3D; /* fake caret color */ --digito-timer-color: #5C5C5C; /* footer text color */
/* Placeholder, separator, and mask */ --digito-placeholder-color: #D3D3D3;
--digito-placeholder-size: 16px;
--digito-separator-color: #A1A1A1;
--digito-separator-size: 18px;
--digito-masked-size: 16px;
}
CSS classes (Vanilla and Web Component):
.digito-slot: Applied to every visual slot <div>..digito-slot.is-active: The slot currently at visual focus..digito-slot.is-filled: The slot contains a character..digito-slot.is-error: Error state is active..digito-slot.is-success: Success state is active..digito-slot.is-disabled: The field is disabled..digito-caret: The blinking caret inside the active, empty slot..digito-timer: The countdown row injected below the slots..digito-timer-badge: The red pill badge showing remaining seconds..digito-resend: The “Didn’t receive the code?” row..digito-resend-btn: The resend chip button..digito-separator: The visual separator between slot groups.Data attribute state hooks
/* Plain CSS */.digito-wrapper[data-complete] { border-color: var(--digito-success-color); }
.digito-wrapper[data-invalid] { animation: shake 0.2s; }
<!-- Tailwind data-* variant syntax --> <div class="digito-wrapper data-[complete]:ring-2 data-[complete]:ring-green-500 data-[invalid]:ring-red-500"></div>
The post Advanced OTP Input Library for Vanilla JS, React, Vue, Svelte & More – Digito appeared first on CSS Script.
Robotics I’ve Covered Robots for Years. This One Is DifferentWill Knight | Wired ($) “Eka’s…
Today's links The prehistory of the Democratic Nuremberg Caucus: Do bounties for ICE whistleblowers next!…
After four years of reflection and artistic evolution, India Shawn returns with Subject To Change a…
An Osprey brings in a branch to build their nest. | Bill Schiess, EastIdahoNews.com Watching…
EastIdahoNews.com file photo, Oct. 2025 The following is a news release from the city of…
ST. ANTHONY – A biker was injured in a traffic accident along U.S. Highway 20…
This website uses cookies.