Categories: CSSScriptWeb Design

Advanced OTP Input Library for Vanilla JS, React, Vue, Svelte & More – Digito

Digito is a framework-agnostic OTP input library that creates one-time password fields across React, Vue 3, Svelte, Alpine.js, Vanilla JS, and Web Components.

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.

Features:

  • Built-in timer and resend UI.
  • Distributes valid characters from the cursor slot forward across remaining slots.
  • Detects overlay badges from LastPass, 1Password, Dashlane, Bitwarden, and Keeper.
  • Masked mode: Displays glyphs in slots and sets type="password" on the hidden input.
  • readOnly mode: Blocks all mutations while keeping the field focusable and copyable.
  • Visual separator: Groups slots visually (e.g., XXX — XXX) with no effect on the returned value string.
  • The pattern option accepts a RegExp that overrides type for per-character validation.
  • Uses navigator.vibrate and the Web Audio API on completion and error events.
  • Works with Tailwind data-* variants and plain CSS attribute selectors.
  • readOnly mode: Blocks all mutations while keeping the field focusable and copyable.

Table of Contents:

Installation:

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>

Vanilla JS Usage.

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

React Usage

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 expose setDisabled() on the hook return. Pass disabled directly as an option: useOTP({ disabled: isVerifying }). Update it via state — e.g., const [isVerifying, setIsVerifying] = useState(false).

Vue 3 Usage

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

Svelte Usage

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

Alpine.js Usage

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>

Web Component Usage

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

Core State Machine (Headless)

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

Standalone Timer

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

All Configuration Options

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

Styling

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.

rssfeeds-admin

Share
Published by
rssfeeds-admin

Recent Posts

This Week’s Awesome Tech Stories From Around the Web (Through May 2)

Robotics I’ve Covered Robots for Years. This One Is DifferentWill Knight | Wired ($) “Eka’s…

4 hours ago

Pluralistic: The prehistory of the Democratic Nuremberg Caucus (02 May 2026)

Today's links The prehistory of the Democratic Nuremberg Caucus: Do bounties for ICE whistleblowers next!…

4 hours ago

India Shawn Embraces Growth and Grace on New EP “Subject To Change”

After four years of reflection and artistic evolution, India Shawn returns with Subject To Change a…

4 hours ago

The city tore down their nest. The ospreys came back anyway

An Osprey brings in a branch to build their nest. | Bill Schiess, EastIdahoNews.com Watching…

4 hours ago

Need a pet license? Pocatello offering May discounts at City Hall pop-ups

EastIdahoNews.com file photo, Oct. 2025 The following is a news release from the city of…

4 hours ago

Biker flown to hospital following crash near St. Anthony

ST. ANTHONY – A biker was injured in a traffic accident along U.S. Highway 20…

4 hours ago

This website uses cookies.