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
| Prop | Default | Description |
|---|---|---|
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. |
length | 6 | Number 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. |
disabled | false | Disables every box. Also follows the `Field`. |
invalid | false | Red borders and `aria-invalid` on every box. |
| Prop | Default | Description |
|---|---|---|
CodeInputrenders a <div> element | ||
v-model | - | The code, as a single string. Shorter than `length` while it is being typed. |
length | 6 | Number 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. |
disabled | false | Disables every box. Also follows the `Field`. |
invalid | false | Red 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:
'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.