Input OTP
A segmented one-time-code entry field.
A segmented one-time-code field — the kind used for SMS and authenticator codes. A single real input owns typing, paste and the native mobile keyboard; the visible slots mirror the value and blink a caret on the active slot. iOS renders grouped surface-container boxes with a primary ring on the active slot; Android renders outlined slots whose active slot gains a 2px primary edge.
Because a single real <input> backs the slots, the field gets paste-of-a-full-code, the numeric keyboard (inputmode="numeric"), and — via autocomplete="one-time-code" — the iOS SMS-code suggestion above the keyboard, all for free. The input carries the label and aria; the slots are aria-hidden presentation, so a screen reader treats the field as one text input.
Installation
npx touchcn add input-otp
Usage
<tcn-input-otp [(value)]="code" ariaLabel="One-time code" (completed)="verify($event)" />import { TcnInputOtp } from '@/components/ui/input-otp';
<TcnInputOtp value={code} onValueChange={setCode} ariaLabel="One-time code" onComplete={verify} /><script setup lang="ts">
import { TcnInputOtp } from '@/components/ui/input-otp';
</script>
<template>
<TcnInputOtp v-model="code" aria-label="One-time code" @complete="verify" />
</template>Grouped slots
groupSize inserts a dash separator between groups of slots — e.g. 3 renders a 3 + 3 layout.
<tcn-input-otp [(value)]="code" [groupSize]="3" /><TcnInputOtp value={code} onValueChange={setCode} groupSize={3} /><TcnInputOtp v-model="code" :group-size="3" />Alphanumeric codes
By default only digits are accepted. Pass a pattern (a regular-expression source matching a single allowed character) and switch the keyboard hint to accept letters.
<tcn-input-otp [(value)]="code" pattern="[a-zA-Z0-9]" inputMode="text" /><TcnInputOtp value={code} onValueChange={setCode} pattern="[a-zA-Z0-9]" inputMode="text" /><TcnInputOtp v-model="code" pattern="[a-zA-Z0-9]" input-mode="text" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string (model, Angular) |
'' |
Two-way bound value (plain string). |
onValueChange |
(value: string) => void |
— | Value callback (React; required). |
length |
number |
6 |
Number of slots / maximum value length. |
pattern |
string |
'[0-9]' |
Regex source matching a single allowed character. |
groupSize |
number |
0 |
Insert a separator between groups of this many slots. |
inputMode |
'numeric' | 'text' |
'numeric' |
Virtual-keyboard hint. |
ariaLabel |
string |
— | Accessible label for the field. |
disabled |
boolean |
false |
Disable the field. |
error |
boolean |
false |
Render the destructive error state. |
completed / onComplete |
(value: string) => void |
— | Fires once every slot is filled. |
Source · Angular
export * from './tcn-input-otp';import { booleanAttribute, Component, input, model, output } from '@angular/core';
import { TcnInputOtpDirective } from '@touchcn/angular/input-otp';
/**
* Input OTP — a segmented one-time-code field. A single real `<input>`
* (transparent, covering the slot row) owns typing, paste and the native mobile
* keyboard, carries the label/aria and — with `autocomplete="one-time-code"` —
* the iOS SMS-code suggestion. The visible slots are `aria-hidden` presentation
* mirroring the value plus a caret indicator on the active slot.
*
* iOS renders grouped surface-container boxes with a hairline border and a
* primary ring on the active slot; MD renders outlined slots whose active slot
* gets a 2px primary border. Both share this markup; the cascade decides the
* look. `groupSize` inserts a separator between groups (e.g. 3 + 3).
*
* Behaviour (sanitisation, paste, caret/selection, completed) lives in the
* `TcnInputOtpDirective` engine primitive on the hidden input.
*/
@Component({
selector: 'tcn-input-otp',
imports: [TcnInputOtpDirective],
template: `
<div class="tcn-input-otp" [attr.data-error]="error() || null" [attr.data-disabled]="disabled() || null">
<input
#otp="tcnInputOtp"
tcnInputOtp
class="tcn-input-otp-input"
[length]="length()"
[pattern]="pattern()"
[(value)]="value"
(completed)="completed.emit($event)"
[attr.inputmode]="inputMode()"
[attr.maxlength]="length()"
[attr.aria-label]="ariaLabel() || null"
[disabled]="disabled()"
autocomplete="one-time-code"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
<div class="tcn-input-otp-slots" aria-hidden="true">
@for (slot of otp.slots(); track $index; let i = $index) {
@if (groupSize() && i > 0 && i % groupSize() === 0) {
<div class="tcn-input-otp-separator"></div>
}
<div class="tcn-input-otp-slot" [attr.data-active]="slot.isActive || null" [attr.data-filled]="slot.char !== null || null">
<span class="tcn-input-otp-char">{{ slot.char }}</span>
@if (slot.hasFakeCaret) {
<span class="tcn-input-otp-caret"></span>
}
</div>
}
</div>
</div>
`,
host: { class: 'inline-block' },
})
export class TcnInputOtp {
readonly value = model('');
readonly length = input(6);
readonly pattern = input('[0-9]');
readonly groupSize = input(0);
readonly inputMode = input('numeric');
readonly ariaLabel = input('');
readonly disabled = input(false, { transform: booleanAttribute });
readonly error = input(false, { transform: booleanAttribute });
readonly completed = output<string>();
}Source · React
export * from './tcn-input-otp';import { Fragment } from 'react';
import { useInputOtp } from '@touchcn/react';
export interface TcnInputOtpProps {
/** Controlled value — a plain string. */
value: string;
/** Called with the sanitised, clamped value on every change. */
onValueChange(value: string): void;
/** Number of slots / maximum value length. */
length?: number;
/** Regular-expression source matching a single allowed character (default digits). */
pattern?: string;
/** Insert a separator between groups of this many slots (e.g. `3` for 3 + 3). */
groupSize?: number;
/** Virtual-keyboard hint for the hidden input. */
inputMode?: 'numeric' | 'text';
/** Accessible label for the field. */
ariaLabel?: string;
/** Disable the field. */
disabled?: boolean;
/** Render the destructive error state. */
error?: boolean;
/** Called with the completed value once every slot is filled. */
onComplete?(value: string): void;
}
/**
* Input OTP — a segmented one-time-code field. A single real `<input>`
* (transparent, covering the slot row) owns typing, paste and the native mobile
* keyboard, carries the label/aria and — with `autocomplete="one-time-code"` —
* the iOS SMS-code suggestion. The visible slots are `aria-hidden` presentation
* mirroring the value plus a caret indicator on the active slot.
*
* iOS renders grouped surface-container boxes with a hairline border and a
* primary ring on the active slot; MD renders outlined slots whose active slot
* gets a 2px primary border. Both share this markup; the cascade decides the
* look. `groupSize` inserts a separator between groups (e.g. 3 + 3).
*
* Behaviour (sanitisation, paste, caret/selection, completed) lives in the
* `useInputOtp` engine hook on the hidden input.
*/
export function TcnInputOtp({
value,
onValueChange,
length = 6,
pattern = '[0-9]',
groupSize = 0,
inputMode = 'numeric',
ariaLabel,
disabled,
error,
onComplete,
}: TcnInputOtpProps) {
const { inputRef, slots, handlers } = useInputOtp({ value, onValueChange, length, pattern, onComplete });
return (
<div className="tcn-input-otp" data-error={error || undefined} data-disabled={disabled || undefined}>
<input
ref={inputRef}
className="tcn-input-otp-input"
value={value}
inputMode={inputMode}
maxLength={length}
aria-label={ariaLabel}
disabled={disabled}
autoComplete="one-time-code"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
{...handlers}
/>
<div className="tcn-input-otp-slots" aria-hidden="true">
{slots.map((slot, index) => (
<Fragment key={index}>
{groupSize > 0 && index > 0 && index % groupSize === 0 && <div className="tcn-input-otp-separator" />}
<div
className="tcn-input-otp-slot"
data-active={slot.isActive || undefined}
data-filled={slot.char !== null || undefined}
>
<span className="tcn-input-otp-char">{slot.char}</span>
{slot.hasFakeCaret && <span className="tcn-input-otp-caret" />}
</div>
</Fragment>
))}
</div>
</div>
);
}Source · Vue
<script setup lang="ts">
import { useInputOtp } from '@touchcn/vue';
/**
* Input OTP — a segmented one-time-code field. A single real `<input>`
* (transparent, covering the slot row) owns typing, paste and the native mobile
* keyboard, carries the label/aria and — with `autocomplete="one-time-code"` —
* the iOS SMS-code suggestion. The visible slots are `aria-hidden` presentation
* mirroring the value plus a caret indicator on the active slot.
*
* iOS renders grouped surface-container boxes with a hairline border and a
* primary ring on the active slot; MD renders outlined slots whose active slot
* gets a 2px primary border. Both share this markup; the cascade decides the
* look. `groupSize` inserts a separator between groups (e.g. 3 + 3).
*
* Behaviour (sanitisation, paste, caret/selection, completed) lives in the
* `useInputOtp` engine composable on the hidden input. Two-way bound with
* `v-model`; emits `complete` once every slot is filled.
*/
const props = withDefaults(
defineProps<{
/** Number of slots / maximum value length. */
length?: number;
/** Regular-expression source matching a single allowed character (default digits). */
pattern?: string;
/** Insert a separator between groups of this many slots (e.g. `3` for 3 + 3). */
groupSize?: number;
/** Virtual-keyboard hint for the hidden input. */
inputMode?: 'numeric' | 'text';
/** Accessible label for the field. */
ariaLabel?: string;
/** Disable the field. */
disabled?: boolean;
/** Render the destructive error state. */
error?: boolean;
}>(),
{ length: 6, pattern: '[0-9]', groupSize: 0, inputMode: 'numeric' },
);
const model = defineModel<string>({ default: '' });
const emit = defineEmits<{ complete: [value: string] }>();
const { setInput, slots, handlers } = useInputOtp({
value: () => model.value,
onValueChange: (value) => {
model.value = value;
},
length: () => props.length,
pattern: () => props.pattern,
onComplete: (value) => emit('complete', value),
});
</script>
<template>
<div class="tcn-input-otp" :data-error="error || undefined" :data-disabled="disabled || undefined">
<input
:ref="setInput"
class="tcn-input-otp-input"
:value="model"
:inputmode="inputMode"
:maxlength="length"
:aria-label="ariaLabel"
:disabled="disabled"
autocomplete="one-time-code"
autocapitalize="off"
autocorrect="off"
:spellcheck="false"
@input="handlers.onInput"
@focus="handlers.onFocus"
@blur="handlers.onBlur"
@select="handlers.onSelect"
@keyup="handlers.onKeyup"
@click="handlers.onClick"
/>
<div class="tcn-input-otp-slots" aria-hidden="true">
<template v-for="(slot, index) in slots" :key="index">
<div
v-if="groupSize > 0 && index > 0 && index % groupSize === 0"
class="tcn-input-otp-separator"
/>
<div
class="tcn-input-otp-slot"
:data-active="slot.isActive || undefined"
:data-filled="slot.char !== null || undefined"
>
<span class="tcn-input-otp-char">{{ slot.char }}</span>
<span v-if="slot.hasFakeCaret" class="tcn-input-otp-caret" />
</div>
</template>
</div>
</div>
</template>export { default as TcnInputOtp } from './TcnInputOtp.vue';