🔥 Oxide UI v0.1.0 — Dark mode only, copy-paste ready. Get started →
Skip to content

PIN Input

<input maxlength="1"> × N

A row of individual character inputs for entering verification codes, PINs, and OTPs. Auto-advances on entry, goes back on Backspace, and supports paste.

Overview

Preview

Enter the 6-digit code sent to your email.

Basic 6-digit

vue
<script setup>
import { ref, computed } from 'vue'

const inputs = ref([])
const pin = ref(Array(6).fill(''))

const isComplete = computed(() => pin.value.every(v => v !== ''))

function onInput(i, e) {
  const val = e.target.value.replace(/\D/g, '') // digits only
  pin.value[i] = val.slice(-1)
  if (val && i < 5) inputs.value[i + 1]?.focus() // advance
}

function onKeydown(i, e) {
  if (e.key === 'Backspace' && !pin.value[i] && i > 0) {
    inputs.value[i - 1]?.focus() // retreat
  }
  if (e.key === 'ArrowLeft'  && i > 0) inputs.value[i - 1]?.focus()
  if (e.key === 'ArrowRight' && i < 5) inputs.value[i + 1]?.focus()
}

function onPaste(e) {
  const text = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6)
  text.split('').forEach((c, i) => { if (i < 6) pin.value[i] = c })
  inputs.value[Math.min(text.length, 5)]?.focus()
  e.preventDefault()
}
</script>

<template>
  <div class="flex gap-2" role="group" aria-label="6-digit verification code">
    <input
      v-for="(_, i) in pin"
      :key="i"
      :ref="el => { if (el) inputs[i] = el }"
      v-model="pin[i]"
      type="text"
      inputmode="numeric"
      maxlength="1"
      class="h-12 w-11 rounded-lg border bg-zinc-900 text-center text-lg font-semibold
             text-zinc-100 caret-transparent outline-none transition-all"
      :class="pin[i]
        ? 'border-[#D40C37] ring-2 ring-[#D40C37]/15'
        : 'border-zinc-700 focus:border-[#D40C37] focus:ring-2 focus:ring-[#D40C37]/15'"
      @input="onInput(i, $event)"
      @keydown="onKeydown(i, $event)"
      @paste="onPaste"
      @focus="$event.target.select()"
    />
  </div>

  <p class="mt-2 text-xs text-emerald-400" v-if="isComplete">
    Code entered: {{ pin.join('') }}
  </p>
</template>

Sizes

html
<!-- Small (4-digit PIN) -->
<input class="h-10 w-10 text-base ..." />

<!-- Default (6-digit code) -->
<input class="h-12 w-11 text-lg ..." />

<!-- Large -->
<input class="h-14 w-12 text-xl ..." />

Masked PIN (password type)

html
<input
  type="password"
  inputmode="numeric"
  maxlength="1"
  class="h-12 w-11 rounded-lg border border-zinc-700 bg-zinc-900 text-center text-lg
         text-zinc-100 caret-transparent outline-none focus:border-[#D40C37]"
  ...
/>

Error state

html
<input
  class="h-12 w-11 rounded-lg border border-red-500 bg-zinc-900 text-center
         text-lg text-zinc-100 ring-2 ring-red-500/15 outline-none ..."
/>
html
<p class="mt-2 text-xs text-red-400" role="alert">
  Invalid code. Please try again.
</p>

4-digit variant

vue
<script setup>
const pin = ref(Array(4).fill(''))
// same onInput / onKeydown — just change bounds from 5 to 3
</script>

Accessibility notes

  • Wrap the inputs in a <div role="group" aria-label="Verification code"> so screen readers announce the group
  • Use inputmode="numeric" to show the numeric keyboard on mobile
  • caret-transparent hides the cursor — the single-char box makes the caret position obvious anyway
  • @focus="$event.target.select()" selects any existing character so typing immediately replaces it
  • After successful submission, announce the result with role="status" or aria-live="polite"

Released under the MIT License.