Skip to content

OTP Input New in 2.0

The N-cell input for verification codes — 2FA / TOTP from authenticator apps, SMS one-time passwords, claim codes, short PINs. Auto-advances as the user types, jumps back on Backspace, spreads pasted codes across cells, and fires a complete event the moment the last cell fills so you can submit without a button click.

ts
import { CoarOtpInput, CoarFormField } from '@cocoar/vue-ui';

Basic Usage

Wire it up with v-model and listen to @complete for auto-submit. The value is the assembled string — "123456" once all six cells are filled.

Enter the 6-digit code from your authenticator app
Value: empty
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 12px; max-width: 360px;">
    <CoarFormField label="Verification code" hint="Enter the 6-digit code from your authenticator app">
      <CoarOtpInput v-model="value" auto-focus @complete="onComplete" />
    </CoarFormField>
    <div style="font-size: 13px; color: #64748b;">
      <div>Value: <strong>{{ value || 'empty' }}</strong></div>
      <div v-if="submittedValue">Auto-submitted: <strong>{{ submittedValue }}</strong></div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarOtpInput, CoarFormField } from '@cocoar/vue-ui';

const value = ref('');
const submittedValue = ref('');

function onComplete(code: string) {
  submittedValue.value = code;
  // In real apps, call your verify endpoint here.
}
</script>

Length

The default 6 cells match TOTP / SMS codes (RFC 6238). For shorter PINs or longer backup codes, override with the length prop.

4-digit PIN
6-digit TOTP (default)
8-digit backup code
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 16px;">
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">4-digit PIN</div>
      <CoarOtpInput v-model="pin4" :length="4" />
    </div>
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">6-digit TOTP (default)</div>
      <CoarOtpInput v-model="pin6" />
    </div>
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">8-digit backup code</div>
      <CoarOtpInput v-model="pin8" :length="8" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarOtpInput } from '@cocoar/vue-ui';

const pin4 = ref('');
const pin6 = ref('');
const pin8 = ref('');
</script>

Type

type="numeric" (the default) rejects non-digits at the keystroke level and tells mobile browsers to open the numeric keyboard via inputmode="numeric". "alphanumeric" accepts [A-Za-z0-9] for claim / recovery codes. "text" accepts any single character.

mask renders the cells as <input type="password"> — the value is visually hidden while the keyboard interaction stays normal. Useful for PINs or sensitive codes in shared-screen contexts.

numeric (default) — digits only
alphanumeric — letters + digits (claim codes, recovery keys)
mask=true — visually mask digits as ●
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 16px;">
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">numeric (default) — digits only</div>
      <CoarOtpInput v-model="numeric" type="numeric" />
    </div>
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">alphanumeric — letters + digits (claim codes, recovery keys)</div>
      <CoarOtpInput v-model="alpha" type="alphanumeric" :length="8" />
    </div>
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">mask=true — visually mask digits as ●</div>
      <CoarOtpInput v-model="masked" mask :length="4" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarOtpInput } from '@cocoar/vue-ui';

const numeric = ref('');
const alpha = ref('');
const masked = ref('');
</script>

Custom filtering: transform and accept

The built-in type classes (numeric / alphanumeric / text) cover the common cases, but real codes have quirks. Two hooks let you fine-tune per character without losing the rest of the input pipeline:

  • transform(char) => string — runs FIRST. Rewrites a character before it's committed. Return a different char to substitute, return '' to drop. Classic use: c => c.toUpperCase() so the user can type lowercase but the code stays canonical.
  • accept(char) => boolean — runs AFTER type + transform. Reject characters the built-in class would otherwise allow. Classic use: block visually-ambiguous chars in printed claim codes so O (letter) and 0 (number) don't get confused.

Both hooks fire on every keystroke AND on every character of a pasted string — paste-spread runs through the same sanitizer, so a clipboard payload of "abc123" ends up as "ABC123" if transform uppercases.

Auto-uppercase — try typing abc123
Value: empty
Confusable-blocking — rejects O / 0 / l / 1 / I (good for claim codes)
Value: empty
Strip whitespace on paste — try pasting 1 2 3 4 5 6
Value: empty
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 16px;">
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">
        Auto-uppercase — try typing <code>abc123</code>
      </div>
      <CoarOtpInput
        v-model="upper"
        type="alphanumeric"
        :length="6"
        :transform="(c) => c.toUpperCase()"
      />
      <div style="font-size: 12px; color: #64748b; margin-top: 6px;">
        Value: <strong>{{ upper || 'empty' }}</strong>
      </div>
    </div>

    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">
        Confusable-blocking — rejects <code>O / 0 / l / 1 / I</code> (good for claim codes)
      </div>
      <CoarOtpInput
        v-model="claim"
        type="alphanumeric"
        :length="8"
        :transform="(c) => c.toUpperCase()"
        :accept="(c) => !/[O0lI1]/.test(c)"
      />
      <div style="font-size: 12px; color: #64748b; margin-top: 6px;">
        Value: <strong>{{ claim || 'empty' }}</strong>
      </div>
    </div>

    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">
        Strip whitespace on paste — try pasting <code>1 2 3 4 5 6</code>
      </div>
      <CoarOtpInput
        v-model="stripped"
        type="numeric"
        :transform="(c) => (/\s/.test(c) ? '' : c)"
      />
      <div style="font-size: 12px; color: #64748b; margin-top: 6px;">
        Value: <strong>{{ stripped || 'empty' }}</strong>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarOtpInput } from '@cocoar/vue-ui';

const upper = ref('');
const claim = ref('');
const stripped = ref('');
</script>

Validation

Drop the OTP input inside CoarFormField and the error state flows in automatically — the field's red ring + error message work the same as any other input. The @complete event is your "user finished typing" signal; verify against your backend and surface the result via error.

Try 123456 — anything else triggers an error
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 16px; max-width: 360px;">
    <CoarFormField
      label="Verification code"
      :error="error"
      hint="Try 123456 — anything else triggers an error"
    >
      <CoarOtpInput v-model="value" auto-focus @complete="verify" />
    </CoarFormField>
    <div v-if="verified" style="font-size: 13px; color: var(--coar-text-semantic-success-bold, #16a34a);">
      ✓ Code accepted
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarOtpInput, CoarFormField } from '@cocoar/vue-ui';

const value = ref('');
const error = ref('');
const verified = ref(false);

function verify(code: string) {
  if (code === '123456') {
    error.value = '';
    verified.value = true;
  } else {
    error.value = 'Invalid code. Try 123456.';
    verified.value = false;
  }
}
</script>

Sizes

Four sizes match the rest of the form-input family (CoarTextInput, CoarNumberInput, etc.) — xs / s / m (default) / l. Cell width tracks the size token, so the OTP input lines up vertically with neighbouring inputs in a form.

xs
s
m (default)
l
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 16px;">
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">xs</div>
      <CoarOtpInput v-model="xs" size="xs" />
    </div>
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">s</div>
      <CoarOtpInput v-model="s" size="s" />
    </div>
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">m (default)</div>
      <CoarOtpInput v-model="m" size="m" />
    </div>
    <div>
      <div style="font-size: 12px; color: #64748b; margin-bottom: 6px;">l</div>
      <CoarOtpInput v-model="l" size="l" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarOtpInput } from '@cocoar/vue-ui';

const xs = ref('');
const s = ref('');
const m = ref('');
const l = ref('');
</script>

Behavior

InteractionResult
Type a character in cell iCell fills, focus auto-advances to i + 1.
Backspace on a filled cellClears the cell (stays put).
Backspace on an empty cellJumps to i − 1 and clears it.
Delete on a filled cellClears the cell (stays put).
Arrow Left / RightMoves focus between cells.
Home / EndFirst / last cell.
Type into a filled cellReplaces its content (selection ensures overwrite).
Paste "123456" in cell 0Spreads across cells 0–5. Strips chars that don't match type.
Paste a 6-char code in cell 2Spreads from cell 2 onward, stopping at the last cell.
All cells filledFires @complete with the assembled string.
TabMoves focus out of the group from any cell — the entire OTP input is one tab-stop in practice.

Mobile SMS autofill

The first cell carries autocomplete="one-time-code" by default, so iOS and Android offer the SMS autofill chip when a verification text arrives. Override via the autocomplete prop if your app uses a different signal (autocomplete="off" to opt out entirely).

API

Props

PropTypeDefaultDescription
modelValue (v-model)string''The assembled code. Partial fills work — "12" means cells 0–1 are filled, the rest empty.
lengthnumber6Number of cells.
type'numeric' | 'alphanumeric' | 'text''numeric'Character class accepted per cell.
maskbooleanfalseRender cells as type="password".
autoFocusbooleanfalseFocus the first cell on mount.
size'xs' | 's' | 'm' | 'l''m'Cell size — matches the form-input family.
disabledbooleanfalseDisable all cells.
readonlybooleanfalseAll cells read-only.
requiredbooleanfalseMarks the group as required (picked up by CoarFormField).
errorbooleanfalseError state. Auto-injected from CoarFormField.
placeholderstring''Single-char placeholder shown in empty cells.
idstringautoHTML id for the first cell (and aria reference target).
namestring''HTML name prefix — each cell gets ${name}-${i}.
autocompletestring'one-time-code'First cell's autocomplete value.
transform(char: string) => stringnoneRewrite a character before commit. Return '' to drop. Runs before type + accept.
accept(char: string) => booleannonePer-character accept predicate ANDed with type. Return false to reject.

Events

EventPayloadNotes
update:modelValuestringStandard v-model emit.
completestringAll cells filled — assembled string. Wire to your verify call.
focusedFocusEventA cell gained focus.
blurredFocusEventA cell lost focus.

Accessibility

  • The component is a role="group" with aria-label="Verification code, N digits".
  • Each cell is a real <input> with aria-label="Digit i of N" so screen readers announce position.
  • aria-invalid propagates to every cell when the error state is set.
  • Cell focus is keyboard-driven (Tab into the first cell, arrows / auto-advance inside, Tab out at any cell).
  • Mobile screen readers respect the per-cell input semantics — the SMS-autofill chip on iOS Safari also targets the first cell correctly.

Released under the Apache-2.0 License.