Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

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';

Last updated on July 24, 2026

Was this page helpful?