Skip to content
  1. Inputs
  2. Code input

Code input

One box per character, for a verification code pasted or typed in one go.

'use client'

import { CodeInput } from '@/components/code-input'
import { useState } from 'react'

export default function Example() {
  let [code, setCode] = useState('')

  return <CodeInput value={code} onChange={setCode} />
}
<script setup lang="ts">
import CodeInput from '@/components/code-input.vue'
import { ref } from 'vue'

const code = ref('')
</script>

<template>
  <CodeInput v-model="code" />
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { useId, useRef, useState } from 'react'

const cell = {
  layout: 'relative block size-12 sm:size-11',
  background: 'before:absolute before:inset-px before:bg-white before:shadow-sm before:rounded-[calc(var(--radius-lg)-1px)] dark:before:hidden',
  focus: [
    'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset',
    'sm:focus-within:after:ring-accent-500 sm:focus-within:after:ring-2',
  ],
  disabled: ['has-data-disabled:opacity-50', 'has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none'],
}

const box = {
  layout: 'relative block size-full appearance-none rounded-lg focus:outline-hidden',
  typography: 'text-center font-mono text-lg/6 sm:text-base/6 text-zinc-950',
  background: 'bg-transparent',
  border: 'border border-zinc-950/10 data-hover:border-zinc-950/20',
  invalid: 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
  disabled: 'data-disabled:border-zinc-950/20',
  dark: [
    'dark:scheme-dark dark:text-white dark:bg-white/5 dark:border-white/10 dark:data-hover:border-white/20',
    'dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
    'dark:data-disabled:bg-white/2.5 dark:data-disabled:border-white/15 dark:data-hover:data-disabled:border-white/15',
  ],
}

function toSlots(value: string, length: number) {
  return Array.from({ length }, (_, index) => value[index] ?? '')
}

export function CodeInput({
  value,
  onChange,
  length = 6,
  type = 'digits',
  name,
  disabled,
  invalid,
  className,
  ...props
}: {
  value: string
  onChange: (value: string) => void
  length?: number
  type?: 'digits' | 'alphanumeric'
  name?: string
  disabled?: boolean
  invalid?: boolean
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange' | 'children'>) {
  let id = useId()
  let boxes = useRef<(HTMLInputElement | null)[]>([])
  let [slots, setSlots] = useState(() => toSlots(value, length))
  let chars = slots.length === length && slots.join('') === value ? slots : toSlots(value, length)

  let pattern = type === 'digits' ? /[^0-9]/g : /[^a-z0-9]/gi

  function write(next: string[]) {
    setSlots(next)
    onChange(next.join(''))
  }

  function focusBox(index: number) {
    boxes.current[Math.min(Math.max(index, 0), length - 1)]?.focus()
  }

  function fill(index: number, text: string) {
    let cleaned = text.replace(pattern, '')
    if (cleaned === '') return

    let next = [...chars]
    for (let offset = 0; offset < cleaned.length && index + offset < length; offset++) {
      next[index + offset] = cleaned[offset]!
    }
    write(next)
    focusBox(index + cleaned.length)
  }

  function handleChange(index: number, event: React.ChangeEvent<HTMLInputElement>) {
    let raw = event.target.value
    let caret = event.target.selectionStart ?? raw.length
    fill(index, raw.length === 2 && chars[index] ? raw.charAt(caret - 1) : raw)
  }

  function handlePaste(index: number, event: React.ClipboardEvent<HTMLInputElement>) {
    event.preventDefault()
    fill(index, event.clipboardData.getData('text'))
  }

  function handleKeyDown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
    if (event.key === 'Backspace') {
      event.preventDefault()
      let target = chars[index] ? index : index - 1
      if (target < 0) return
      let next = [...chars]
      next[target] = ''
      write(next)
      focusBox(target)
    } else if (event.key === 'ArrowLeft') {
      event.preventDefault()
      focusBox(index - 1)
    } else if (event.key === 'ArrowRight') {
      event.preventDefault()
      focusBox(index + 1)
    }
  }

  return (
    <div
      role="group"
      data-slot="control"
      aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Verification code')}
      {...props}
      className={clsx(className, 'flex gap-2')}
    >
      {name && <input type="hidden" name={name} value={value} />}
      {chars.map((char, index) => (
        <span key={index} className={clsx(Object.values(cell))}>
          <Headless.Input
            ref={(element: HTMLInputElement | null) => {
              boxes.current[index] = element
            }}
            id={index === 0 ? undefined : `${id}-${index}`}
            type="text"
            inputMode={type === 'digits' ? 'numeric' : 'text'}
            autoComplete={index === 0 ? 'one-time-code' : 'off'}
            maxLength={length}
            value={char}
            disabled={disabled}
            invalid={invalid}
            aria-label={`Character ${index + 1}`}
            onChange={(event: React.ChangeEvent<HTMLInputElement>) => handleChange(index, event)}
            onPaste={(event: React.ClipboardEvent<HTMLInputElement>) => handlePaste(index, event)}
            onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => handleKeyDown(index, event)}
            onFocus={(event: React.FocusEvent<HTMLInputElement>) => event.target.select()}
            className={clsx(Object.values(box))}
          />
        </span>
      ))}
    </div>
  )
}
<script setup lang="ts">
import { computed, ref, useAttrs, useId } from 'vue'
import { useDisabled, useField } from './fields'
import { vInteractions } from './interactions'

const props = withDefaults(
  defineProps<{
    length?: number
    type?: 'digits' | 'alphanumeric'
    name?: string
    disabled?: boolean
    invalid?: boolean
  }>(),
  { length: 6, type: 'digits' },
)

const cell = {
  layout: 'relative block size-12 sm:size-11',
  background: 'before:absolute before:inset-px before:bg-white before:shadow-sm before:rounded-[calc(var(--radius-lg)-1px)] dark:before:hidden',
  focus: [
    'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset',
    'sm:focus-within:after:ring-accent-500 sm:focus-within:after:ring-2',
  ],
  disabled: ['has-data-disabled:opacity-50', 'has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none'],
}

const box = {
  layout: 'relative block size-full appearance-none rounded-lg focus:outline-hidden',
  typography: 'text-center font-mono text-lg/6 sm:text-base/6 text-zinc-950',
  background: 'bg-transparent',
  border: 'border border-zinc-950/10 data-hover:border-zinc-950/20',
  invalid: 'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
  disabled: 'data-disabled:border-zinc-950/20',
  dark: [
    'dark:scheme-dark dark:text-white dark:bg-white/5 dark:border-white/10 dark:data-hover:border-white/20',
    'dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
    'dark:data-disabled:bg-white/2.5 dark:data-disabled:border-white/15 dark:data-hover:data-disabled:border-white/15',
  ],
}

function toSlots(value: string, length: number) {
  return Array.from({ length }, (_, index) => value[index] ?? '')
}

const model = defineModel<string>({ default: '' })
const attrs = useAttrs()
const field = useField()
const id = useId()
const isDisabled = useDisabled(() => props.disabled)

const boxes: (HTMLInputElement | null)[] = []
const slots = ref(toSlots(model.value, props.length))
const chars = computed(() =>
  slots.value.length === props.length && slots.value.join('') === model.value ? slots.value : toSlots(model.value, props.length),
)

const pattern = computed(() => (props.type === 'digits' ? /[^0-9]/g : /[^a-z0-9]/gi))

function write(next: string[]) {
  slots.value = next
  model.value = next.join('')
}

function focusBox(index: number) {
  boxes[Math.min(Math.max(index, 0), props.length - 1)]?.focus()
}

function fill(index: number, text: string) {
  const cleaned = text.replace(pattern.value, '')
  if (cleaned === '') return

  const next = [...chars.value]
  for (let offset = 0; offset < cleaned.length && index + offset < props.length; offset++) {
    next[index + offset] = cleaned[offset]!
  }
  write(next)
  focusBox(index + cleaned.length)
}

function handleInput(index: number, event: Event) {
  const target = event.target as HTMLInputElement
  const raw = target.value
  const caret = target.selectionStart ?? raw.length
  fill(index, raw.length === 2 && chars.value[index] ? raw.charAt(caret - 1) : raw)
  target.value = chars.value[index] ?? ''
}

function handlePaste(index: number, event: ClipboardEvent) {
  event.preventDefault()
  fill(index, event.clipboardData?.getData('text') ?? '')
}

function handleKeyDown(index: number, event: KeyboardEvent) {
  if (event.key === 'Backspace') {
    event.preventDefault()
    const target = chars.value[index] ? index : index - 1
    if (target < 0) return
    const next = [...chars.value]
    next[target] = ''
    write(next)
    focusBox(target)
  } else if (event.key === 'ArrowLeft') {
    event.preventDefault()
    focusBox(index - 1)
  } else if (event.key === 'ArrowRight') {
    event.preventDefault()
    focusBox(index + 1)
  }
}
</script>

<template>
  <div
    role="group"
    data-slot="control"
    :aria-label="attrs['aria-labelledby'] ? undefined : 'Verification code'"
    class="flex gap-2"
  >
    <input v-if="name" type="hidden" :name="name" :value="model" />
    <span v-for="(char, index) in chars" :key="index" :class="Object.values(cell)">
      <input
        :ref="(element) => (boxes[index] = element as HTMLInputElement | null)"
        v-interactions
        :id="index === 0 ? field?.controlId : `${id}-${index}`"
        type="text"
        :inputmode="type === 'digits' ? 'numeric' : 'text'"
        :autocomplete="index === 0 ? 'one-time-code' : 'off'"
        :maxlength="length"
        :value="char"
        :disabled="isDisabled"
        :data-disabled="isDisabled ? '' : undefined"
        :aria-invalid="invalid ? 'true' : undefined"
        :data-invalid="invalid ? '' : undefined"
        :aria-label="`Character ${index + 1}`"
        :aria-describedby="field?.describedBy.value"
        :class="Object.values(box)"
        @input="handleInput(index, $event)"
        @paste="handlePaste(index, $event)"
        @keydown="handleKeyDown(index, $event)"
        @focus="($event.target as HTMLInputElement).select()"
      />
    </span>
  </div>
</template>

Component API

PropDefaultDescription
CodeInputrenders a <div> element
value-The code, as a single string. Shorter than `length` while it is being typed.
onChange-Called with the new code on every keystroke.
length6Number of boxes.
type'digits'`digits` for a numeric code, `alphanumeric` to accept letters.
name-Adds a hidden input carrying the code, for a plain form post.
disabledfalseDisables every box. Also follows the `Field`.
invalidfalseRed borders and `aria-invalid` on every box.
PropDefaultDescription
CodeInputrenders a <div> element
v-model-The code, as a single string. Shorter than `length` while it is being typed.
length6Number of boxes.
type'digits'`digits` for a numeric code, `alphanumeric` to accept letters.
name-Adds a hidden input carrying the code, for a plain form post.
disabledfalseDisables every box. Also follows the `Field`.
invalidfalseRed borders and `aria-invalid` on every box.

Examples

Basic example

Focus moves forward on its own as you type, and backspace walks back. Pasting a whole code fills every box at once:

Value: —

'use client'

import { CodeInput } from '@/components/code-input'
import { useState } from 'react'

export default function Example() {
  let [code, setCode] = useState('')

  return <CodeInput value={code} onChange={setCode} />
}
<script setup lang="ts">
import CodeInput from '@/components/code-input.vue'
import { ref } from 'vue'

const code = ref('')
</script>

<template>
  <CodeInput v-model="code" />
</template>

Length

Set how many characters the code has with length:

<CodeInput value={code} onChange={setCode} length={4} />
<template>
  <CodeInput v-model="code" :length="4" />
</template>

Letters and digits

Switch type to alphanumeric for codes that mix letters and digits, recovery codes for example:

<CodeInput value={code} onChange={setCode} type="alphanumeric" length={5} />
<template>
  <CodeInput v-model="code" type="alphanumeric" :length="5" />
</template>

With a label

Wrap it in a Field for the label and description. The label points at the first box, and every box reads the label and the description:

We sent a six-digit code to daniel@example.com.

'use client'

import { CodeInput } from '@/components/code-input'
import { Description, Field, Label } from '@/components/fieldset'
import { useState } from 'react'

export default function Example() {
  let [code, setCode] = useState('')

  return (
    <Field>
      <Label>Verification code</Label>
      <Description>We sent a six-digit code to daniel@example.com.</Description>
      <CodeInput value={code} onChange={setCode} name="code" />
    </Field>
  )
}
<script setup lang="ts">
import CodeInput from '@/components/code-input.vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import { ref } from 'vue'

const code = ref('')
</script>

<template>
  <Field>
    <Label>Verification code</Label>
    <Description>We sent a six-digit code to daniel@example.com.</Description>
    <CodeInput v-model="code" name="code" />
  </Field>
</template>

Outside a Field, each box is labelled on its own, so a screen reader announces which character it is on.