PIN input

One box per character for short codes, with paste, autofill and arrow-key support.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add pin-input
pnpm dlx ermineui add pin-input
yarn dlx ermineui add pin-input
bunx ermineui add pin-input

That copies 2 files into src/components/ui/pin-input/. Files you already have are kept.

Use

import { PinInput } from '@/components/ui/pin-input'
<PinInput v-model="code" :length="6" name="code" @complete="verify" />

<Rating v-model="score" label="Deploy experience" />
<Rating :model-value="3.5" readonly label="Average rating" />

<Tree v-model:expanded="open" v-model:selected="file" :nodes="[
  { id: 'app', label: 'app', children: [{ id: 'app/app.vue', label: 'app.vue' }] },
  { id: 'package.json', label: 'package.json' },
]" label="Project files" />

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
PinInput.vuesrc/components/ui/pin-input/PinInput.vue
index.tssrc/components/ui/pin-input/index.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

PinInput.vue 158 lines
<script setup lang="ts">
import { computed, ref } from 'vue'

// id and name go on the first box (so a label can point at it); the rest is shared by every box.
defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    /** How many characters the code has. */
    length?: number
    /** "numeric" accepts digits only and shows the number keypad on phones. */
    type?: 'numeric' | 'alphanumeric'
    /** Show dots instead of the characters. */
    mask?: boolean
    disabled?: boolean
    /** Submit the whole code with a form under this name. */
    name?: string
    /** Accessible name of the group of boxes. */
    label?: string
  }>(),
  { length: 6, type: 'numeric', label: 'Verification code' },
)

const emit = defineEmits<{
  /** Every box is filled. */
  complete: [code: string]
}>()

/** The code so far. Shorter than `length` until every box is filled. */
const model = defineModel<string>({ default: '' })

const root = ref<HTMLElement | null>(null)
const allowed = computed(() => (props.type === 'numeric' ? /[^0-9]/g : /[^0-9a-z]/gi))
const chars = computed(() => Array.from({ length: props.length }, (_box, index) => model.value[index] ?? ''))

const box = (index: number) => root.value?.querySelectorAll<HTMLInputElement>('input:not([type="hidden"])')[index]

function focusBox(index: number) {
  const target = box(Math.max(0, Math.min(index, props.length - 1)))
  target?.focus()
  target?.select()
}

/** Writes `text` into the boxes starting at `index` — one typed character, or a whole pasted code. */
function write(index: number, text: string) {
  const clean = text.replace(allowed.value, '')
  if (!clean) return
  const next = chars.value.slice()
  for (let offset = 0; offset < clean.length && index + offset < props.length; offset++) next[index + offset] = clean[offset]!
  // A code has no holes: it ends at the first empty box.
  const firstGap = next.indexOf('')
  const code = (firstGap === -1 ? next : next.slice(0, firstGap)).join('')
  model.value = code
  focusBox(index + clean.length)
  if (code.length === props.length) emit('complete', code)
}

function onInput(index: number, event: Event) {
  const input = event.target as HTMLInputElement
  const typed = input.value
  const existing = chars.value[index] ?? ''
  // The box shows what the model says, whatever was typed (a letter in a numeric code, say).
  input.value = existing
  // Typing into a filled box arrives as old + new; only the new part counts.
  write(index, existing && typed.length > 1 && typed.startsWith(existing) ? typed.slice(1) : typed)
}

function onKeydown(index: number, event: KeyboardEvent) {
  if (event.key === 'Backspace') {
    event.preventDefault()
    // Clears this box, or steps back and clears the one before when this one is already empty.
    const target = chars.value[index] ? index : index - 1
    if (target >= 0) model.value = model.value.slice(0, target)
    focusBox(target)
  } else if (event.key === 'ArrowLeft') {
    event.preventDefault()
    focusBox(index - 1)
  } else if (event.key === 'ArrowRight') {
    event.preventDefault()
    focusBox(Math.min(index + 1, model.value.length))
  }
}

function onPaste(index: number, event: ClipboardEvent) {
  event.preventDefault()
  write(index, event.clipboardData?.getData('text') ?? '')
}
</script>

<template>
  <div ref="root" role="group" class="er-pin-input" :aria-label="label" :data-disabled="disabled ? '' : undefined">
    <input
      v-for="(char, index) in chars"
      :key="index"
      v-bind="index === 0 ? $attrs : { 'aria-invalid': $attrs['aria-invalid'] as 'true' | undefined, 'aria-describedby': $attrs['aria-describedby'] as string | undefined }"
      :type="mask ? 'password' : 'text'"
      :inputmode="type === 'numeric' ? 'numeric' : 'text'"
      :autocomplete="index === 0 ? 'one-time-code' : 'off'"
      autocapitalize="off"
      spellcheck="false"
      :maxlength="length"
      :aria-label="`Character ${index + 1} of ${length}`"
      :disabled="disabled"
      :value="char"
      @input="onInput(index, $event)"
      @keydown="onKeydown(index, $event)"
      @paste="onPaste(index, $event)"
      @focus="($event.target as HTMLInputElement).select()"
    />
    <input v-if="name" type="hidden" :name="name" :value="model" :disabled="disabled" />
  </div>
</template>

<style>
@layer components {
  .er-pin-input {
    display: inline-flex;
    gap: 0.5rem;
    max-width: 100%;
  }

  .er-pin-input input:not([type='hidden']) {
    width: 2.5rem;
    min-width: 0;
    height: 2.75rem;
    padding: 0;
    border: 1px solid var(--er-input);
    border-radius: var(--er-radius-md);
    background: var(--er-input-background);
    color: var(--er-foreground);
    font-family: inherit;
    font-size: 1.125rem;
    font-weight: var(--er-weight-medium);
    font-variant-numeric: tabular-nums;
    text-align: center;
    box-shadow: var(--er-shadow-xs);
    caret-color: transparent;
    transition:
      border-color var(--er-duration),
      outline-color var(--er-duration);
  }

  .er-pin-input input:focus-visible {
    border-color: var(--er-ring);
    outline: 2px solid color-mix(in oklab, var(--er-ring) 45%, transparent);
    outline-offset: 0;
  }

  .er-pin-input input[aria-invalid='true'] {
    border-color: var(--er-destructive);
  }

  .er-pin-input[data-disabled] {
    opacity: 0.5;
  }
}
</style>
index.ts 3 lines
/** PIN input — one box per character for short codes, with paste, autofill and arrow-key support. */
export { default as PinInput } from './PinInput.vue'