GrOtpInput

  • core
  • forms
  • API · 18 props
  • ⌨ keys

A field for a code of known length: an SMS confirmation, a one-time password, a PIN.

import { GrOtpInput } from '@feugene/granularity/components/GrOtpInput'

Overview

When to take it

  • the code comes from outside — from an SMS, an email or an app: the length is known in advance, and the field shows it;
  • the check runs as soon as it is filled in — complete fires without a “Send” button;
  • a PIN on a lock screen — masked hides the characters, the behaviour stays the same;
  • the code is read aloud or copied by eye — groups splits it into parts, like a card number.

When to take something else

NeedTake
A string of arbitrary lengthGrInput
A password that is typed, not receivedGrInput with type="password"
A number with steps and boundsGrNumberInput
The user makes up the values themselvesGrInputTag

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 3 each with a live demo and its code

  • A sign-in code from a text message

    groups splits the code the way the message prints it, and complete checks it with no «Submit» button. one-time-code lets the phone offer the code from the message. A refused code clears the cells through clear() and keeps the focus, so the next attempt starts at the first cell.

    Messages · nowNorthwind: 482 913 is your sign-in code. Do not share it.
    Check your phoneWe sent a code to •• 4821.
    The code works for 10 minutes.
  • A PIN for a new card

    masked shows dots where a second pair of eyes is likely, while the model keeps the digits. invalid marks the whole repeated PIN — one attempt, not four wrong cells.

    Set a PIN for your new cardVisa Business •••• 7720
    New PIN
    Repeat the PIN
  • A TV pairing code

    A code read off a screen avoids look-alikes: pattern takes one character, so O, 0, I and 1 never reach the model, and a pasted «k7pm-w2qx» keeps only what fits. The cell slot shows letters in capitals as the TV prints them, and separator puts a dot between the groups.

    Connect a TVType the code shown on the TV screen, e.g. K7PM·W2QX.

API generated from the package · cannot drift

Props

PropTypedefaultDescription
disabledboolean | undefinedfalseDisable the field: the code cannot be entered and is not submitted with the form. It adds up with `GrFormField`.
lengthnumber | undefinedundefinedThe number of cells.
type"text" | "numeric" | "alphanumeric" | undefined"numeric"The allowed alphabet and the layout of the mobile keyboard.
modelValuev-modelstring | undefined""The code as a whole, without separators.
readonlyboolean | undefinedfalseRead-only: the code is visible but cannot be changed.
invalidboolean | undefinedfalseAn error — for example, the server rejected the code: the border of the cells and `aria-invalid`. It adds up with `GrFormField`.
requiredboolean | undefinedfalseA required field (`aria-required`). It adds to the `required` of `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size of the cells and the type size of the characters. Unset — from `GrConfigProvider`, otherwise `md`.
placeholderstring | undefined""The sign in an empty cell.
ariaLabelstring | undefinedundefinedThe accessible name outside `GrFormField`: the cells are hidden from a screen reader, and a single field on top of them is read.
namestring | undefinedundefinedThe name of the field for a native form submission.
formstring | undefinedundefinedThe `id` of the form, if the field lies outside it.
patternstring | undefinedundefinedAn alphabet of your own as a regex for **one character**. Overrides `type`.
groupsnumber[] | undefinedundefinedSplitting into groups: `[3, 3]` draws `123 – 456`. The sum is obliged to equal `length`.

Slots

SlotTypeDescription
cell
{ char: string; index: number; active: boolean; filled: bool{ char: string; index: number; active: boolean; filled: boolean; masked: boolean; }
Styling of your own for a cell. The size and the border are still held by the component.
separator{ index: number; }The sign between groups. By default — a dash.

Events

EventSignatureDescription
update:modelValue(event: "update:modelValue", value: string): void—
change(event: "change", value: string): void—
focus(event: "focus", event: FocusEvent): void—
blur(event: "blur", event: FocusEvent): void—
complete(event: "complete", value: string): void—

Methods

MethodsSignatureDescription
focus() => voidFocus the code field.
blur() => voidRemove the focus from the code field.
clear() => voidErase the code and return the focus to the first cell — when the server rejected the code and expects a new one.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrOtpInputConfigurableProps, GrOtpInputProps, GrOtpInputEmits } from '@feugene/granularity/components/GrOtpInput'

Filedist/types/components/GrOtpInput/config.d.ts

grOtpInputConfigconst
const grOtpInputConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrOtpInput">;

Filedist/types/components/GrOtpInput/defaults.d.ts

GrOtpInputConfigurablePropsinterface
interface GrOtpInputConfigurableProps {
    size: GrComponentSize;
    length: number;
    masked: boolean;
}

Filedist/types/components/GrOtpInput/safelist.d.ts

grOtpInputSafelistconst
const grOtpInputSafelist: string[];

Filedist/types/components/GrOtpInput/GrOtpInput.vue.d.ts

GrOtpInputPropsinterface
interface GrOtpInputProps {
    modelValue?: string;
    length?: number;
    type?: GrOtpInputType;
    pattern?: string;
    groups?: number[];
    masked?: boolean;
    placeholder?: string;
    size?: GrOtpInputSize;
    autofocus?: boolean;
    oneTimeCode?: boolean;
    webOtp?: boolean;
    name?: string;
    form?: string;
    disabled?: boolean;
    readonly?: boolean;
    invalid?: boolean;
    required?: boolean;
    ariaLabel?: string;
}
GrOtpInputEmitsinterface
interface GrOtpInputEmits {
    (e: 'update:modelValue', value: string): void;
    (e: 'change', value: string): void;
    (e: 'focus', event: FocusEvent): void;
    (e: 'blur', event: FocusEvent): void;
    (e: 'complete', value: string): void;
}

Filedist/types/components/GrOtpInput/grOtpInputStyles.d.ts

GrOtpInputSizetype
type GrOtpInputSize = GrComponentSize;

Filedist/types/components/GrOtpInput/otpValue.d.ts

GrOtpInputTypetype
type GrOtpInputType = typeof GR_OTP_INPUT_TYPES[number];

Filedist/types/components/GrOtpInput/index.d.ts

GrOtpInputInstancetype
type GrOtpInputInstance = ComponentExposed<typeof GrOtpInputComponent>;

Accessibility

APG pattern

—

Usage from the library prose

Many cells, one field

The component draws a row of cells, but in the DOM it is a single <input>, stretched over the row and invisible: the text, the caret and the selection highlight are transparent. The cells are decoration with aria-hidden.

This is the load-bearing decision, and almost all the behaviour of the field follows from it:

heresix separate fields
Pasting a codenativedistribute across the cells by hand
A code from an SMS on iOSautocomplete="one-time-code" worksis filled into the first cell only
Selection, Ctrl+A, undonativenone
Arrows, Home/Endthe native caretwrite by hand
Tab ordera single stop by constructionroving tabindex by hand
Screen reader“edit field, 123456”six unnamed fields in a row

The package’s requirement “a composite widget is a single Tab stop” (../keyboard.md) is met here by the construction, not on top of it.

The cost is a single one: the active cell is not stored but derived from the caret position of the real field.

Typing replaces rather than shifts

The only place where the behaviour diverges from an ordinary field. The length is fixed, so a character typed into an occupied cell replaces its content, and the tail of the code stays in place.

The normalisation hangs on the input event, not on beforeinput, and that is not an implementation detail: mobile keyboards and autofill send a whole string in one event. The handler must accept alike a single character from the keyboard and six from a message — they cannot be told apart by the event.

Characters outside the alphabet are dropped silently rather than blocking the input: 123-456 copied from a messenger gives 123456. That is the main paste scenario, and it must not require the user to clean the string by hand.

A click puts the caret into the first empty cell

Rather than where the cursor landed. The cells are decoration, a mouse hit on invisible text means nothing meaningful, and “continue from where you stopped” is the only thing expected of a code. Editing the middle is left to the arrows and Backspace.

The code from a message is filled in by itself

Two channels, and together they cover both mobile platforms:

PropPlatformState
oneTimeCodeiOS, macOSon: autocomplete="one-time-code" costs nothing and works by itself
webOtpAndroidoff

webOtp is off not out of caution: WebOTP requires HTTPS and a special tail in the text of the message (@example.com #123456), which the backend adds. On by default, it would silently fail to work for everyone who does not know about that.

The request lives in onMounted and is withdrawn when the code is filled in and on unmount: navigator in the body of setup crashes server rendering (../ssr.md), and an unclosed request outlives leaving the page. A missing API, the user’s refusal and a cancellation are not errors: the field simply waits for manual input.

The alphabet
typeWhat it acceptsinputmode
numericdigitsnumeric
alphanumericdigits and Latin letters, converted to upper casetext
textany non-whitespace charactertext

pattern sets its own alphabet with a regex for a single character and overrides type entirely — together with the case conversion, because a custom regex decides for itself which case is allowed. A broken regex does not crash the render: the component warns in dev and falls back to the type.

The mask is visual

masked draws dots, but the value of the field remains the real code. That is how it should be: type="password" would break one-time-code, and there is nobody to hide the value from — the text of the real field is transparent anyway.

Appearance

size — xs…lg; the side of a cell repeats the height of GrInput at the same step, so that the code sits in one line with the fields of a form. The points of adjustment are the --gr-otp-input-cell-size, --gr-otp-input-active-brd and --gr-otp-input-caret hooks (../tokens.md).

The caret is drawn as its own, because the real one is hidden together with the text. The blinking is a CSS animation, so it obeys the package’s global motion clamp (../motion.md): under prefers-reduced-motion the caret stops visible, that is, exactly what it should be in a focused field.

The cell and separator slots change the appearance without touching the behaviour: the size, the border and the caret are still held by the component.

Limits
  • the resend timer — the state of the screen, not of the field;
  • checking the code — the application listens to complete and decides for itself;
  • the SMS format for WebOTP — the @domain #code tail is added by the backend;
  • a general-purpose input mask — a phone number and a date are a different task.