md-otp-field spec card
Identity · when to use / when NOT · decision cues · behavioural contract · do/don't · anti-patterns · full API. Paste into your agent when you're implementing with this component.
One-time-code entry with per-character cells. md-otp-field renders
length real input cells in its own shadow root, so every cell carries full
ARIA. All entry — typing, paste, SMS autofill — flows through one sanitizing
pipeline (whitespace-strip → transform → charset filter → clamp), and the
field is form-associated via ElementInternals with the full
constraint-validation API.
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field label="One-time code" supporting-text="Enter the 6-digit code we sent to your phone"></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField label="One-time code" supportingText="Enter the 6-digit code we sent to your phone"></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field label="One-time code" supporting-text="Enter the 6-digit code we sent to your phone"></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field label="One-time code" supporting-text="Enter the 6-digit code we sent to your phone"></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field label="One-time code" supporting-text="Enter the 6-digit code we sent to your phone"></md-otp-field>
Already installed? See the
Installation guide for one-time package setup
(core + tokens, fonts). Each tab below shows two patterns for using
md-otp-field in your project: Option A registers every AWC UI
component at once (simplest), Option B imports only this component for
tree-shake-friendly bundles.
<!-- ─── Option A: global registration (all components) ─── -->
<script type="module">
import '@awc-ui/core/define';
</script>
<!-- ─── Option B: single import (tree-shake only md-otp-field) ─── -->
<script type="module">
import '@awc-ui/core/components/md-otp-field';
</script>
<md-otp-field></md-otp-field>// ─── Option A: typed React wrapper (registers all components) ───
// Importing from '@awc-ui/react' calls defineCustomElements() as a
// side effect, so every md-* element becomes available in the browser.
import { MdOtpField } from '@awc-ui/react';
export function Example() {
return <MdOtpField></MdOtpField>;
}
// ─── Option B: single import (tree-shake to only md-otp-field) ───
// Skip the wrapper and use the raw custom element. Smallest bundle,
// but you lose typed props/events on JSX.
import '@awc-ui/core/components/md-otp-field';
export function ExampleTreeShaken() {
return <md-otp-field></md-otp-field>;
}// ─── Option A: schema module (any md-* element accepted) ───
// Pair with `defineCustomElements(window)` in main.ts.
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@Component({
standalone: true,
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `<md-otp-field></md-otp-field>`,
})
export class ExampleComponent {}
// ─── Option B: typed directive (tree-shake friendly) ───
// Pair with `import '@awc-ui/core/components/md-otp-field'` in main.ts.
import { Component } from '@angular/core';
import { MdOtpField } from '@awc-ui/angular';
@Component({
standalone: true,
imports: [MdOtpField],
template: `<md-otp-field></md-otp-field>`,
})
export class ExampleTreeShakenComponent {}<!-- ─── Option A: typed Vue wrapper (registers all components) ─── -->
<script setup lang="ts">
import { MdOtpField } from '@awc-ui/vue';
</script>
<template>
<MdOtpField></MdOtpField>
</template>
<!-- ─── Option B: single import (tree-shake to only md-otp-field) ─── -->
<script setup lang="ts">
import '@awc-ui/core/components/md-otp-field';
</script>
<template>
<md-otp-field></md-otp-field>
</template><!-- ─── Option A: global registration (done once in main entry) ─── -->
<!-- main.ts: -->
<!-- import { defineCustomElements } from '@awc-ui/svelte'; -->
<!-- defineCustomElements(window); -->
<md-otp-field></md-otp-field>
<!-- ─── Option B: single import (tree-shake to only md-otp-field) ─── -->
<script lang="ts">
import '@awc-ui/core/components/md-otp-field';
</script>
<md-otp-field></md-otp-field>validation-type="numeric", the default).validation-type="alphanumeric" + transform="uppercase").mask).| Situation | Use instead |
|---|---|
| Free-form text, emails, passwords | md-text-field |
| Variable-length or long codes (more than ~8 characters) | md-text-field with restrict |
| Search-as-you-type | md-search / md-autocomplete |
| Choosing from known options | md-select |
length sets the cell count. group-size chunks the cells visually — a dot
separator sits between groups (swap it via the separator slot or restyle
::part(separator)).
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field length="4" label="4-digit PIN"></md-otp-field>
<md-otp-field group-size="3" label="Grouped 6-digit code"></md-otp-field>
<md-otp-field length="8" group-size="4" label="8-character code"></md-otp-field>
<md-otp-field group-size="2" label="Custom separator"><span slot="separator">—</span></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField length="4" label="4-digit PIN"></MdOtpField>
<MdOtpField groupSize="3" label="Grouped 6-digit code"></MdOtpField>
<MdOtpField length="8" groupSize="4" label="8-character code"></MdOtpField>
<MdOtpField groupSize="2" label="Custom separator"><span slot="separator">—</span></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field length="4" label="4-digit PIN"></md-otp-field>
<md-otp-field group-size="3" label="Grouped 6-digit code"></md-otp-field>
<md-otp-field length="8" group-size="4" label="8-character code"></md-otp-field>
<md-otp-field group-size="2" label="Custom separator"><span slot="separator">—</span></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field length="4" label="4-digit PIN"></md-otp-field>
<md-otp-field group-size="3" label="Grouped 6-digit code"></md-otp-field>
<md-otp-field length="8" group-size="4" label="8-character code"></md-otp-field>
<md-otp-field group-size="2" label="Custom separator"><span slot="separator">—</span></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field length="4" label="4-digit PIN"></md-otp-field>
<md-otp-field group-size="3" label="Grouped 6-digit code"></md-otp-field>
<md-otp-field length="8" group-size="4" label="8-character code"></md-otp-field>
<md-otp-field group-size="2" label="Custom separator"><span slot="separator">—</span></md-otp-field>validation-type filters what the field accepts: numeric (default),
alpha, alphanumeric, or none. Rejected characters never land — they fire
mdInvalidInput with the raw attempted string, which is your hook for an
“invalid input” shake or hint. transform="uppercase" normalizes accepted
characters — a declarative attribute rather than a normalizer callback, so it
is authorable from plain HTML and survives SSR.
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field
length="8"
validation-type="alphanumeric"
transform="uppercase"
label="Recovery code"
supporting-text="Letters are stored uppercase automatically"
></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField
length="8"
validationType="alphanumeric"
transform="uppercase"
label="Recovery code"
supportingText="Letters are stored uppercase automatically"
></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field
length="8"
validation-type="alphanumeric"
transform="uppercase"
label="Recovery code"
supporting-text="Letters are stored uppercase automatically"
></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field
length="8"
validation-type="alphanumeric"
transform="uppercase"
label="Recovery code"
supporting-text="Letters are stored uppercase automatically"
></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field
length="8"
validation-type="alphanumeric"
transform="uppercase"
label="Recovery code"
supporting-text="Letters are stored uppercase automatically"
></md-otp-field>The virtual keyboard hint derives from validation-type (numeric shows the
digit pad); override it with the inputmode attribute when needed.
mask renders the cells as password inputs for shared-screen privacy. The
value itself is never reflected to a DOM attribute in any mode.
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field length="4" mask label="PIN" value="1234"></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField length="4" mask label="PIN" value="1234"></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field length="4" mask label="PIN" value="1234"></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field length="4" mask label="PIN" value="1234"></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field length="4" mask label="PIN" value="1234"></md-otp-field>| Key | Behavior |
|---|---|
| Character | Fills the cell, focus auto-advances (last cell keeps focus) |
Backspace | Clears the current cell if filled; otherwise walks back and clears the previous one |
Delete | Clears the current cell without moving |
ArrowLeft / ArrowRight | Move focus (the row is pinned LTR, so arrows never flip in RTL) |
Home / End | Jump to the first / last cell |
Focusing a cell selects its character, so typing replaces. The value is contiguous: clearing a cell shifts later characters left, and a character typed into an empty cell beyond the fill point lands at the first empty position.
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field
value="4926"
label="One-time code"
supporting-text="Click cell 2, then try Backspace, Delete, arrows, Home and End"
></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField
value="4926"
label="One-time code"
supportingText="Click cell 2, then try Backspace, Delete, arrows, Home and End"
></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field
value="4926"
label="One-time code"
supporting-text="Click cell 2, then try Backspace, Delete, arrows, Home and End"
></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field
value="4926"
label="One-time code"
supporting-text="Click cell 2, then try Backspace, Delete, arrows, Home and End"
></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field
value="4926"
label="One-time code"
supporting-text="Click cell 2, then try Backspace, Delete, arrows, Home and End"
></md-otp-field>Paste anywhere in the group replaces the whole value from position 0 — the
pasted text runs through the same pipeline, and focus lands after the last
filled cell. A complete paste fires mdComplete even when the pasted code
equals the current value (mdInput stays silent then), so re-pasting the same
code still triggers your verification handler.
autocomplete="one-time-code" sits on the first cell, and the cells carry no
maxlength, so an SMS-autofill burst arrives intact and is distributed across
the cells.
4 9 2 6 1 7
paste
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<!-- Paste "4 9 2 6 1 7" into any cell: whitespace is stripped, the value is
replaced from position 0, and focus lands after the last filled cell. -->
<md-otp-field label="One-time code"></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<!-- Paste "4 9 2 6 1 7" into any cell: whitespace is stripped, the value is
replaced from position 0, and focus lands after the last filled cell. -->
<MdOtpField label="One-time code"></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<!-- Paste "4 9 2 6 1 7" into any cell: whitespace is stripped, the value is
replaced from position 0, and focus lands after the last filled cell. -->
<md-otp-field label="One-time code"></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<!-- Paste "4 9 2 6 1 7" into any cell: whitespace is stripped, the value is
replaced from position 0, and focus lands after the last filled cell. -->
<md-otp-field label="One-time code"></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<!-- Paste "4 9 2 6 1 7" into any cell: whitespace is stripped, the value is
replaced from position 0, and focus lands after the last filled cell. -->
<md-otp-field label="One-time code"></md-otp-field>supporting-text is the resting hint; error recolors the cells and swaps
the line to error-text (announced via role="alert"). Set
reserve-supporting-space to keep the line’s height even while it is empty,
so toggling the error never shifts the layout.
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field label="Wrong code" value="492617" error error-text="That code is incorrect — try again"></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField label="Wrong code" value="492617" error errorText="That code is incorrect — try again"></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field label="Wrong code" value="492617" error error-text="That code is incorrect — try again"></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field label="Wrong code" value="492617" error error-text="That code is incorrect — try again"></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field label="Wrong code" value="492617" error error-text="That code is incorrect — try again"></md-otp-field>md-otp-field submits value under name via ElementInternals. An empty
field submits no entry at all. Form reset clears the code — one-time codes
are transient.
required blocks submission while the field is empty
(value-missing-label is the localizable message).required + incomplete-label also blocks a started-but-incomplete
code, with incomplete-label as the message. Without incomplete-label, a
partial code passes constraint validation (validate server-side).auto-submit calls form.requestSubmit() the moment the code completes —
after mdComplete, so your handler runs first and can preventDefault().<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<form id="verify">
<md-otp-field
name="code"
label="One-time code"
required
incomplete-label="Please enter the complete code."
></md-otp-field>
<md-button variant="filled" type="submit">Verify</md-button>
<md-button variant="text" type="reset">Reset</md-button>
</form>import { MdButton, MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<form id="verify">
<MdOtpField
name="code"
label="One-time code"
required
incompleteLabel="Please enter the complete code."
></MdOtpField>
<MdButton variant="filled" type="submit">Verify</MdButton>
<MdButton variant="text" type="reset">Reset</MdButton>
</form>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<form id="verify">
<md-otp-field
name="code"
label="One-time code"
required
incomplete-label="Please enter the complete code."
></md-otp-field>
<md-button variant="filled" type="submit">Verify</md-button>
<md-button variant="text" type="reset">Reset</md-button>
</form><script setup>
import '@awc-ui/core/define';
</script>
<template>
<form id="verify">
<md-otp-field
name="code"
label="One-time code"
required
incomplete-label="Please enter the complete code."
></md-otp-field>
<md-button variant="filled" type="submit">Verify</md-button>
<md-button variant="text" type="reset">Reset</md-button>
</form>
</template><script>
import '@awc-ui/core/define';
</script>
<form id="verify">
<md-otp-field
name="code"
label="One-time code"
required
incomplete-label="Please enter the complete code."
></md-otp-field>
<md-button variant="filled" type="submit">Verify</md-button>
<md-button variant="text" type="reset">Reset</md-button>
</form>Methods — setFocus() focuses the first empty cell (or the last when
complete), clear() empties the code, and getValidity(),
checkValidity(), reportValidity(), setCustomValidity(message) mirror the
native constraint-validation API (setCustomValidity('') clears a
server-side rejection).
| Event | Cancelable | Detail | Fires |
|---|---|---|---|
mdInput | no | string | Every user-driven value change (typing, clearing, paste, autofill) |
mdChange | no | string | Commit: on completion, and when focus leaves the whole group |
mdComplete | no | { value } | Every cell filled; a complete paste re-fires it even when unchanged |
mdInvalidInput | no | { attempted, reason } | Characters rejected by the charset filter (reason: input-change / input-paste) |
mdValidityChange | no | { valid, validationMessage, flags } | Only when validity changes |
mdInput, mdChange and mdComplete all carry the code, so the difference is
not the data — it is when the value is worth acting on.
mdInput is the raw stream: it fires on every user-driven change that actually
moved the value — a keystroke, a Backspace, a Delete, a paste, an autofill
burst. It is the “live” signal, and it is deliberately silent when the value
did not change.
mdChange is the commit. It fires once the code completes, and again when
focus leaves the whole group, deduplicated against the last committed value —
so a user who types, wanders off and comes back does not get a second commit
for the same code.
mdComplete is the verification trigger, and it is the one with a
deliberate exception: a paste of a complete code re-fires it even when the
pasted code equals the current value. mdInput stays silent in that case
(nothing changed), which is exactly why re-pasting a code the server just
rejected still re-runs your verification handler. auto-submit hangs off this
event too — form.requestSubmit() runs after mdComplete, so your handler
sees the code first.
As with the platform’s own controls, none of the three fire on a programmatic
change. Setting otp.value = '123456' or calling clear() emits nothing — if
your own code changed the value, your own code already knows.
| You want to… | Listen to |
|---|---|
| Drive a live character counter or clear an error as the user types | mdInput |
| Persist or sync the code once the user is done with it | mdChange |
| Call your verification API | mdComplete |
| Re-verify a code the user pasted again after a rejection | mdComplete — mdInput stays silent |
| Shake the field / show “digits only” on a rejected character | mdInvalidInput |
| Enable or disable a Verify button from validity | mdValidityChange (not composed — bind it on the element) |
Type a digit, then a letter (rejected), then finish the code — the log shows the order events actually fire in:
<md-otp-field id="otp" label="One-time code" required incomplete-label="Please enter all six digits."></md-otp-field>
<script type="module">
const otp = document.getElementById('otp');
otp.addEventListener('mdInput', (e) => console.log('mdInput', e.detail));
otp.addEventListener('mdChange', (e) => console.log('mdChange', e.detail));
otp.addEventListener('mdComplete', (e) => console.log('mdComplete', e.detail.value));
otp.addEventListener('mdInvalidInput', (e) => console.log('mdInvalidInput', e.detail.attempted, e.detail.reason));
// Not composed — bind it on the element itself, never on a shadow ancestor.
otp.addEventListener('mdValidityChange', (e) => console.log('valid?', e.detail.valid));
</script>import { MdOtpField } from '@awc-ui/react';
export function OtpEvents() {
return (
<MdOtpField
label="One-time code"
required
incompleteLabel="Please enter all six digits."
onMdInput={(e) => console.log('mdInput', e.detail)}
onMdChange={(e) => console.log('mdChange', e.detail)}
onMdComplete={(e) => console.log('mdComplete', e.detail.value)}
onMdInvalidInput={(e) => console.log('mdInvalidInput', e.detail.attempted, e.detail.reason)}
onMdValidityChange={(e) => console.log('valid?', e.detail.valid)}
/>
);
}import { Component } from '@angular/core';
@Component({
selector: 'app-otp-events',
template: `
<md-otp-field
label="One-time code"
required
incomplete-label="Please enter all six digits."
(mdInput)="onInput($event)"
(mdChange)="onChange($event)"
(mdComplete)="onComplete($event)"
(mdInvalidInput)="onInvalidInput($event)"
(mdValidityChange)="onValidityChange($event)"
></md-otp-field>
`,
})
export class OtpEventsComponent {
onInput(e: CustomEvent<string>) {
console.log('mdInput', e.detail);
}
onChange(e: CustomEvent<string>) {
console.log('mdChange', e.detail);
}
onComplete(e: CustomEvent<{ value: string }>) {
console.log('mdComplete', e.detail.value);
}
onInvalidInput(e: CustomEvent<{ attempted: string; reason: string }>) {
console.log('mdInvalidInput', e.detail.attempted, e.detail.reason);
}
onValidityChange(e: CustomEvent<{ valid: boolean }>) {
console.log('valid?', e.detail.valid);
}
}<script setup lang="ts">
function log(name: string, detail: unknown) {
console.log(name, detail);
}
</script>
<template>
<md-otp-field
label="One-time code"
required
incomplete-label="Please enter all six digits."
@mdInput="log('mdInput', $event.detail)"
@mdChange="log('mdChange', $event.detail)"
@mdComplete="log('mdComplete', $event.detail.value)"
@mdInvalidInput="log('mdInvalidInput', $event.detail.attempted)"
@mdValidityChange="log('valid?', $event.detail.valid)"
/>
</template><script lang="ts">
function log(name: string, detail: unknown) {
console.log(name, detail);
}
</script>
<md-otp-field
label="One-time code"
required
incomplete-label="Please enter all six digits."
on:mdInput={(e) => log('mdInput', e.detail)}
on:mdChange={(e) => log('mdChange', e.detail)}
on:mdComplete={(e) => log('mdComplete', e.detail.value)}
on:mdInvalidInput={(e) => log('mdInvalidInput', e.detail.attempted)}
on:mdValidityChange={(e) => log('valid?', e.detail.valid)}
/>The full verify-and-reject flow goes one step further than the preview above — validate the code server-side, show the error, then clear and refocus:
<md-otp-field id="otp" name="code" label="One-time code"></md-otp-field>
<script type="module">
const otp = document.getElementById('otp');
otp.addEventListener('mdComplete', async (e) => {
const ok = await verify(e.detail.value); // your API call
otp.error = !ok;
otp.errorText = ok ? '' : 'That code is incorrect — try again';
if (!ok) { await otp.clear(); await otp.setFocus(); }
});
otp.addEventListener('mdInvalidInput', (e) => {
console.log('rejected:', e.detail.attempted, e.detail.reason);
});
</script>import { MdOtpField } from '@awc-ui/react';
import { useRef, useState } from 'react';
export function VerifyCode() {
const ref = useRef<HTMLMdOtpFieldElement>(null);
const [error, setError] = useState('');
return (
<MdOtpField
ref={ref}
name="code"
label="One-time code"
error={!!error}
errorText={error}
onMdComplete={async (e) => {
const ok = await verify(e.detail.value);
setError(ok ? '' : 'That code is incorrect — try again');
if (!ok) { await ref.current?.clear(); await ref.current?.setFocus(); }
}}
/>
);
}import { Component } from '@angular/core';
@Component({
selector: 'app-verify-code',
template: `
<md-otp-field
name="code"
label="One-time code"
[error]="!!error"
[errorText]="error"
(mdComplete)="onComplete($event)"
></md-otp-field>
`,
})
export class VerifyCodeComponent {
error = '';
async onComplete(e: CustomEvent<{ value: string }>) {
const ok = await verify(e.detail.value);
this.error = ok ? '' : 'That code is incorrect — try again';
}
}<script setup lang="ts">
import { ref } from 'vue';
const error = ref('');
async function onComplete(e: CustomEvent<{ value: string }>) {
const ok = await verify(e.detail.value);
error.value = ok ? '' : 'That code is incorrect — try again';
}
</script>
<template>
<md-otp-field
name="code"
label="One-time code"
:error="!!error"
:error-text="error"
@mdComplete="onComplete"
/>
</template><script lang="ts">
let error = '';
async function onComplete(e: CustomEvent<{ value: string }>) {
const ok = await verify(e.detail.value);
error = ok ? '' : 'That code is incorrect — try again';
}
</script>
<md-otp-field
name="code"
label="One-time code"
error={!!error}
error-text={error}
on:mdComplete={onComplete}
/>| Property | Attribute | Type | Default | Reflects |
|---|---|---|---|---|
length | length | number | 6 | — |
value | value | string | '' | — |
validationType | validation-type | 'numeric' | 'alpha' | 'alphanumeric' | 'none' | 'numeric' | — |
transform | transform | 'none' | 'uppercase' | 'none' | — |
mask | mask | boolean | false | — |
autoSubmit | auto-submit | boolean | false | — |
groupSize | group-size | number | 0 | — |
inputMode | inputmode | string | '' | — |
name | name | string | '' | Yes |
disabled | disabled | boolean | false | Yes |
readOnly | readonly | boolean | false | — |
required | required | boolean | false | — |
error | error | boolean | false | — |
errorText | error-text | string | '' | — |
supportingText | supporting-text | string | '' | — |
label | label | string | 'One-time code' | — |
cellLabelTemplate | cell-label-template | string | 'Character {index} of {length}' | — |
valueMissingLabel | value-missing-label | string | 'Please enter the complete code.' | — |
incompleteLabel | incomplete-label | string | '' | — |
density | density | 0 | -1 | -2 | -3 | -4 | 0 | Yes |
reserveSupportingSpace | reserve-supporting-space | boolean | false | — |
| Method | Parameters |
|---|---|
setFocus() | none |
clear() | none |
getValidity() | none |
setCustomValidity() | message: string |
checkValidity() | none |
reportValidity() | none |
| Slot | Description |
|---|---|
separator | Custom separator content between cell groups (falls back to a |
Override on the host element for per-instance theming:
| Property | Description |
|---|---|
--md-otp-field-cell-width | Cell inline size (48px, tapers with density, 32px floor) |
--md-otp-field-cell-height | Cell block size (56px, tapers with density, 40px floor) |
--md-otp-field-cell-gap | Gap between cells (spacing-gap-sm token) |
--md-otp-field-cell-shape | Cell corner radius (shape-corner-small) |
--md-otp-field-outline-color | Resting cell border colour |
--md-otp-field-focus-color | Focused cell border + caret colour |
--md-otp-field-font-size | Cell glyph size (24px, tapers with density, 16px floor) |
Style internal elements through shadow DOM with ::part():
| Part | Description |
|---|---|
separator | Each group separator wrapper |
cell | Every character input |
cells | The cell row (role="group" container) |
supporting-text | Supporting / error text line |
role="group" named by label — keep it meaningful
(“One-time code”, not “Code”).aria-label from cell-label-template
(Character {index} of {length} by default — the {index} / {length}
placeholders are replaced per cell). Translate the template alongside your
other strings.aria-describedby
(same shadow scope, so the IDREF resolves), and error text is announced via
role="alert".aria-invalid lands on the cells while error is set; aria-required on
the first cell when required.Tab into the group below and listen: each cell announces its own position
(“Character 3 of 6”) plus the shared supporting line, and the group itself
announces the label. Tab moves cell by cell; the arrows move within the row.
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field
label="One-time code"
supporting-text="Enter the 6-digit code we sent to your phone"
required
></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField
label="One-time code"
supportingText="Enter the 6-digit code we sent to your phone"
required
></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field
label="One-time code"
supporting-text="Enter the 6-digit code we sent to your phone"
required
></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field
label="One-time code"
supporting-text="Enter the 6-digit code we sent to your phone"
required
></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field
label="One-time code"
supporting-text="Enter the 6-digit code we sent to your phone"
required
></md-otp-field>RTL — the component (supporting text, layout) follows the document direction, but the cell row is pinned LTR: codes read left-to-right in RTL locales too, which is standard practice. Arrow keys therefore behave physically and logically at once. See RTL.
<div dir="rtl"> <md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field></div><!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field label="One-time code" value="123456"></md-otp-field>
<div dir="rtl">
<!-- Label and supporting text move to the right edge; the six cells do not
move, and typing still fills them left to right. -->
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField label="One-time code" value="123456"></MdOtpField>
<div dir="rtl">
<!-- Label and supporting text move to the right edge; the six cells do not
move, and typing still fills them left to right. -->
<MdOtpField label="رمز لمرة واحدة" value="123456"></MdOtpField>
</div>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field label="One-time code" value="123456"></md-otp-field>
<div dir="rtl">
<!-- Label and supporting text move to the right edge; the six cells do not
move, and typing still fills them left to right. -->
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field label="One-time code" value="123456"></md-otp-field>
<div dir="rtl">
<!-- Label and supporting text move to the right edge; the six cells do not
move, and typing still fills them left to right. -->
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field label="One-time code" value="123456"></md-otp-field>
<div dir="rtl">
<!-- Label and supporting text move to the right edge; the six cells do not
move, and typing still fills them left to right. -->
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>Because the row never flips, the arrow keys need no RTL branch — the same key moves to the same character in both directions:
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field label="One-time code" value="1234"></md-otp-field>
<div dir="rtl">
<!-- ArrowRight still moves to the NEXT character in both rows. -->
<md-otp-field label="رمز لمرة واحدة" value="1234"></md-otp-field>
</div>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField label="One-time code" value="1234"></MdOtpField>
<div dir="rtl">
<!-- ArrowRight still moves to the NEXT character in both rows. -->
<MdOtpField label="رمز لمرة واحدة" value="1234"></MdOtpField>
</div>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field label="One-time code" value="1234"></md-otp-field>
<div dir="rtl">
<!-- ArrowRight still moves to the NEXT character in both rows. -->
<md-otp-field label="رمز لمرة واحدة" value="1234"></md-otp-field>
</div><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field label="One-time code" value="1234"></md-otp-field>
<div dir="rtl">
<!-- ArrowRight still moves to the NEXT character in both rows. -->
<md-otp-field label="رمز لمرة واحدة" value="1234"></md-otp-field>
</div>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field label="One-time code" value="1234"></md-otp-field>
<div dir="rtl">
<!-- ArrowRight still moves to the NEXT character in both rows. -->
<md-otp-field label="رمز لمرة واحدة" value="1234"></md-otp-field>
</div>density is a local override of the inherited data-density. Each rung takes
4px off the cell box (48×56 at 0, down to a 32×40 floor at -4) and tapers
the glyph with it — all five rungs, same code, so the taper is visible in one
place:
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-1" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-2" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-3" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-4" label="One-time code" value="123456"></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField label="One-time code" value="123456"></MdOtpField>
<MdOtpField density="-1" label="One-time code" value="123456"></MdOtpField>
<MdOtpField density="-2" label="One-time code" value="123456"></MdOtpField>
<MdOtpField density="-3" label="One-time code" value="123456"></MdOtpField>
<MdOtpField density="-4" label="One-time code" value="123456"></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-1" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-2" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-3" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-4" label="One-time code" value="123456"></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-1" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-2" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-3" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-4" label="One-time code" value="123456"></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-1" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-2" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-3" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-4" label="One-time code" value="123456"></md-otp-field>The two are independent signals, so they compose without any extra wiring, and
a local density rung beats the inherited data-density one:
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<div dir="rtl" data-density="-2">
<!-- Inherits the -2 rung. -->
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
<!-- A local rung is declared on the host, so it wins over the ancestor. -->
<md-otp-field density="-4" label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<div dir="rtl" data-density="-2">
<!-- Inherits the -2 rung. -->
<MdOtpField label="رمز لمرة واحدة" value="123456"></MdOtpField>
<!-- A local rung is declared on the host, so it wins over the ancestor. -->
<MdOtpField density="-4" label="رمز لمرة واحدة" value="123456"></MdOtpField>
</div>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<div dir="rtl" data-density="-2">
<!-- Inherits the -2 rung. -->
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
<!-- A local rung is declared on the host, so it wins over the ancestor. -->
<md-otp-field density="-4" label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div><script setup>
import '@awc-ui/core/define';
</script>
<template>
<div dir="rtl" data-density="-2">
<!-- Inherits the -2 rung. -->
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
<!-- A local rung is declared on the host, so it wins over the ancestor. -->
<md-otp-field density="-4" label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>
</template><script>
import '@awc-ui/core/define';
</script>
<div dir="rtl" data-density="-2">
<!-- Inherits the -2 rung. -->
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
<!-- A local rung is declared on the host, so it wins over the ancestor. -->
<md-otp-field density="-4" label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>Density — density="-1…-4" locally overrides the inherited data-density
rung, shrinking the cells 4px per rung (48×56 down to a 32×40 floor) and
tapering the glyph size with them. See Density.
i18n — translate label, supporting-text, error-text,
cell-label-template, value-missing-label and incomplete-label. All are
props precisely so you can feed them from your dictionary; the component has
no locale logic of its own.
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<!-- Every user-facing string is a prop — feed them from your dictionary -->
<md-otp-field
label="Code à usage unique"
supporting-text="Saisissez le code à 6 chiffres"
cell-label-template="Caractère {index} sur {length}"
value-missing-label="Veuillez saisir le code complet."
incomplete-label="Code incomplet"
></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<!-- Every user-facing string is a prop — feed them from your dictionary -->
<MdOtpField
label="Code à usage unique"
supportingText="Saisissez le code à 6 chiffres"
cellLabelTemplate="Caractère {index} sur {length}"
valueMissingLabel="Veuillez saisir le code complet."
incompleteLabel="Code incomplet"
></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<!-- Every user-facing string is a prop — feed them from your dictionary -->
<md-otp-field
label="Code à usage unique"
supporting-text="Saisissez le code à 6 chiffres"
cell-label-template="Caractère {index} sur {length}"
value-missing-label="Veuillez saisir le code complet."
incomplete-label="Code incomplet"
></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<!-- Every user-facing string is a prop — feed them from your dictionary -->
<md-otp-field
label="Code à usage unique"
supporting-text="Saisissez le code à 6 chiffres"
cell-label-template="Caractère {index} sur {length}"
value-missing-label="Veuillez saisir le code complet."
incomplete-label="Code incomplet"
></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<!-- Every user-facing string is a prop — feed them from your dictionary -->
<md-otp-field
label="Code à usage unique"
supporting-text="Saisissez le code à 6 chiffres"
cell-label-template="Caractère {index} sur {length}"
value-missing-label="Veuillez saisir le code complet."
incomplete-label="Code incomplet"
></md-otp-field>| Custom property | Purpose | Default |
|---|---|---|
--md-otp-field-cell-width | Cell inline size | 48px, density-tapered, 32px floor |
--md-otp-field-cell-height | Cell block size | 56px, density-tapered, 40px floor |
--md-otp-field-cell-gap | Gap between cells | --md-sys-spacing-gap-sm (8px) |
--md-otp-field-cell-shape | Cell corner radius | --md-sys-shape-corner-small (8px) |
--md-otp-field-outline-color | Resting cell border | --md-sys-color-outline |
--md-otp-field-focus-color | Focused border + caret | --md-sys-color-primary |
--md-otp-field-font-size | Cell glyph size | 24px, density-tapered, 16px floor |
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-otp-field label="Themed code" value="49" style="--md-otp-field-focus-color: var(--md-sys-color-tertiary); --md-otp-field-cell-shape: 999px;"></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<MdOtpField label="Themed code" value="49" style={{ '--md-otp-field-focus-color': 'var(--md-sys-color-tertiary)', '--md-otp-field-cell-shape': '999px' }}></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<md-otp-field label="Themed code" value="49" style="--md-otp-field-focus-color: var(--md-sys-color-tertiary); --md-otp-field-cell-shape: 999px;"></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<md-otp-field label="Themed code" value="49" style="--md-otp-field-focus-color: var(--md-sys-color-tertiary); --md-otp-field-cell-shape: 999px;"></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<md-otp-field label="Themed code" value="49" style="--md-otp-field-focus-color: var(--md-sys-color-tertiary); --md-otp-field-cell-shape: 999px;"></md-otp-field>CSS parts — cells, cell, separator, supporting-text. Reach for them
when a token is not enough: ::part(cell) restyles every character box,
::part(separator) restyles every gap (unlike the separator slot, which only
fills the first one).
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<!-- group-size="2" over the default length of 6 renders TWO separators;
::part(separator) restyles both, where the slot would fill only the first. -->
<md-otp-field
class="otp-filled"
group-size="2"
value="4926"
label="One-time code"
supporting-text="Filled cells, wider gaps, italic supporting line"
></md-otp-field>import { MdOtpField } from '@awc-ui/react';
export function Demo() {
return (
<>
<!-- group-size="2" over the default length of 6 renders TWO separators;
::part(separator) restyles both, where the slot would fill only the first. -->
<MdOtpField
className="otp-filled"
groupSize="2"
value="4926"
label="One-time code"
supportingText="Filled cells, wider gaps, italic supporting line"
></MdOtpField>
</>
);
}// app.module.ts — register the AWC UI elements once
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AwcUiModule } from '@awc-ui/angular';
@NgModule({
imports: [AwcUiModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- app.component.html -->
<!-- group-size="2" over the default length of 6 renders TWO separators;
::part(separator) restyles both, where the slot would fill only the first. -->
<md-otp-field
class="otp-filled"
group-size="2"
value="4926"
label="One-time code"
supporting-text="Filled cells, wider gaps, italic supporting line"
></md-otp-field><script setup>
import '@awc-ui/core/define';
</script>
<template>
<!-- group-size="2" over the default length of 6 renders TWO separators;
::part(separator) restyles both, where the slot would fill only the first. -->
<md-otp-field
class="otp-filled"
group-size="2"
value="4926"
label="One-time code"
supporting-text="Filled cells, wider gaps, italic supporting line"
></md-otp-field>
</template><script>
import '@awc-ui/core/define';
</script>
<!-- group-size="2" over the default length of 6 renders TWO separators;
::part(separator) restyles both, where the slot would fill only the first. -->
<md-otp-field
class="otp-filled"
group-size="2"
value="4926"
label="One-time code"
supporting-text="Filled cells, wider gaps, italic supporting line"
></md-otp-field>.otp-filled::part(cell) { background: var(--md-sys-color-surface-container-highest); border-color: transparent;}
/* Both gaps, unlike the `separator` slot — which fills only the first. */.otp-filled::part(separator) { inline-size: 20px;}
.otp-filled::part(supporting-text) { font-style: italic;}md-text-field ·
md-search ·
md-autocomplete ·
md-button
md-otp-fieldTwo artefacts to give your AI agent so it generates correct UI with this component. The per-component spec answers "how do I use this exact tag?". The main-llm spec answers "which tag should I pick in the first place?".
md-otp-field spec card
Identity · when to use / when NOT · decision cues · behavioural contract · do/don't · anti-patterns · full API. Paste into your agent when you're implementing with this component.
System-prompt preamble · decision matrix · token reference · page recipes · anti-patterns. Paste into the system prompt at the start of a piece of work.