Navigation

v0.0.4
Forms

OTP

A short code typed one character to a slot.

On this page

An OTP field takes a short code, one character to a slot: the six digits from a text message or an authenticator. Each slot is a native input drawn as a small well, the same material as a text field, so it rises to the sheet with an ink contour while it has focus. On a touch screen it can be filled from a keypad drawn on the page.

OTP

<fieldset class="otp" aria-label="Verification code">  <input class="otp-slot" inputmode="numeric" pattern="[0-9]" maxlength="1" autocomplete="one-time-code" aria-label="Digit 1" value="4">  <input class="otp-slot" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Digit 2" value="2">  <input class="otp-slot" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Digit 3">  <span class="otp-separator" aria-hidden="true"></span>  <input class="otp-slot" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Digit 4">  <input class="otp-slot" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Digit 5">  <input class="otp-slot" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Digit 6"></fieldset><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>
Type a digit and focus moves on. Backspace goes back, and a pasted code spreads over the slots.

#The behavior

Without script every slot is an ordinary input: the reader types a character and presses Tab. The otp behavior makes the slots one field to the hand. It takes the root, the element with the otp class, and returns the function that detaches it.

app.js
import { otp } from '@master/ui/behaviors/otp.js'const root = document.querySelector('.otp')otp(root)root.addEventListener('change', (event) => {  if (event.detail) verify(event.detail.value)})
  • Typing. After a character, focus moves to the next slot. A character the slot does not allow is refused. A slot that takes focus has its character selected, so typing replaces it.
  • Going back. Backspace clears the slot; in an empty slot it moves to the slot before and clears that one.
  • Pasting and autofill. A pasted or autofilled code is spread over the slots: from the first slot when it is a whole code, from the slot it landed in when it is part of one.
  • Events. A slot the script writes to dispatches input, as it does when typed in. When every slot is filled, the root dispatches a bubbling change with the code in detail.value. A slot's own change bubbles through the root too and has no detail, which is why the listener above checks for it.
OptionWhat it sets
patternA regular expression one character must match. By default the pattern attribute of the first slot, and a digit when it has none

Autofill is why the first slot carries autocomplete="one-time-code". A phone that offers the code from a text message fills one field with all of it, and maxlength="1" would cut that code to one character before the script saw it. While attached, the behavior removes maxlength from the slots and keeps each to one character itself; detaching gives the attribute back. Without the script the attribute stays, and only the first character of an offered code arrives, which is a reason to prefer one field when codes arrive by message.

#Invalid

Mark the slots aria-invalid="true" when the code is wrong, and say why under the group.

Invalid

<fieldset class="otp" aria-label="Verification code" aria-describedby="otp-error">  <input class="otp-slot" inputmode="numeric" maxlength="1" aria-label="Digit 1" aria-invalid="true" value="4">  <input class="otp-slot" inputmode="numeric" maxlength="1" aria-label="Digit 2" aria-invalid="true" value="2">  <input class="otp-slot" inputmode="numeric" maxlength="1" aria-label="Digit 3" aria-invalid="true" value="0">  <input class="otp-slot" inputmode="numeric" maxlength="1" aria-label="Digit 4" aria-invalid="true" value="9"></fieldset><p class="m(0) text-xs fg-danger" id="otp-error">That code has expired. Request a new one.</p>

#One field is enough

Slots are a visual convention, not a requirement. A single input with autocomplete="one-time-code", inputmode="numeric" and maxlength="6" takes the same code with no script, pastes naturally and is easier for a screen reader. Use slots when the product's voice calls for them.

#Keyboard

KeysAction
A characterFills the slot and moves focus to the next one; a character the slot does not allow is refused
BackspaceClears the slot; in an empty slot, moves focus to the slot before and clears it
DeleteClears the slot
ArrowLeft, ArrowRightMove focus to the slot before or after; the other way around from right to left
Home, EndMove focus to the first or the last slot
PasteSpreads a pasted code across the slots: a whole code from the first slot, a part of one from the slot that has focus

#Accessibility

  • Group the slots in a <fieldset> with a label, and name each slot, since a slot has no visible label.
  • inputmode="numeric" brings up the number pad without making the slot a number field.
  • Do not clear the slots on an error. Mark them invalid and let the reader correct or paste again.

#React, Svelte and Vue

The otp component renders the slots, attaches the behavior, passes the code in and out and says when it is complete. Copy it into the project with npx master-ui add otp; React, Svelte and Vue says how the components are built and used.

ui/otp.tsx
'use client'import { useState, type ComponentProps } from 'react'import { otp } from '@master/ui/behaviors/otp.js'import { useBehavior } from './use-behavior'export interface OtpProps extends Omit<ComponentProps<'fieldset'>, 'defaultValue' | 'onChange'> {  /** How many characters the code has. */  length?: number  /** The characters typed so far. */  value?: string  defaultValue?: string  onValueChange?: (value: string) => void  /** Called with the code once every slot holds a character. */  onComplete?: (code: string) => void  /** What one character must match, as the `pattern` of each slot. A digit by default. */  pattern?: string  inputMode?: 'numeric' | 'text'  /** The name of a slot, which has no visible label. */  slotLabel?: (index: number) => string  invalid?: boolean}/** A one-time code. Name the group with `aria-label` or `aria-labelledby`; `disabled` disables every slot. */export function Otp({ length = 6, value, defaultValue, onValueChange, onComplete, pattern = '[0-9]', inputMode = 'numeric', slotLabel = index => `Digit ${index + 1}`, invalid, className, ...root }: OtpProps) {  const ref = useBehavior(otp, undefined, {    value,    defaultValue,    onValueChange,    // The root's own change is the behavior's: every slot is filled. A slot's change bubbles through it too.    on: { change: (event) => { if (event.target === event.currentTarget) onComplete?.((event as CustomEvent<{ value: string }>).detail.value) } },  })  const [first] = useState(value ?? defaultValue ?? '')  return (    <fieldset className={className ? `otp ${className}` : 'otp'} ref={ref} {...root}>      {Array.from({ length }, (_, index) => (        <input className="otp-slot" key={index} inputMode={inputMode} pattern={pattern} maxLength={1} autoComplete={index === 0 ? 'one-time-code' : 'off'} aria-label={slotLabel(index)} aria-invalid={invalid || undefined} defaultValue={first[index] ?? ''} />      ))}    </fieldset>  )}
ui/Otp.svelte
<script lang="ts" module>  import type { HTMLFieldsetAttributes } from 'svelte/elements'  export interface OtpProps extends Omit<HTMLFieldsetAttributes, 'children' | 'inputmode'> {    /** How many characters the code has. */    length?: number    /** The characters typed so far. Bind it, or leave it out. */    value?: string    onValueChange?: (value: string) => void    /** Called with the code once every slot holds a character. */    onComplete?: (code: string) => void    /** What one character must match, as the `pattern` of each slot. A digit by default. */    pattern?: string    inputmode?: 'numeric' | 'text'    /** The name of a slot, which has no visible label. */    slotLabel?: (index: number) => string    invalid?: boolean  }</script><script lang="ts">  import { untrack } from 'svelte'  import { otp } from '@master/ui/behaviors/otp.js'  import { behavior } from './behavior.svelte.js'  let { length = 6, value = $bindable(), onValueChange, onComplete, pattern = '[0-9]', inputmode = 'numeric', slotLabel = index => `Digit ${index + 1}`, invalid, class: className, ...root }: OtpProps = $props()  const first = untrack(() => value) ?? ''  const attach = behavior(otp, undefined, {    value: () => value,    change: (next) => { value = next; onValueChange?.(next) },    // The root's own change is the behavior's: every slot is filled. A slot's change bubbles through it too.    on: { change: (event) => { if (event.target === event.currentTarget) onComplete?.((event as CustomEvent<{ value: string }>).detail.value) } },  })</script><!--  @component  A one-time code. Name the group with `aria-label` or `aria-labelledby`; `disabled` disables every slot.--><fieldset class={['otp', className]} {@attach attach} {...root}>  {#each { length }, index}    <input class="otp-slot" {inputmode} {pattern} maxlength={1} autocomplete={index === 0 ? 'one-time-code' : 'off'} aria-label={slotLabel(index)} aria-invalid={invalid || undefined} value={first[index] ?? ''} />  {/each}</fieldset>
ui/Otp.vue
<script setup lang="ts">import { useTemplateRef } from 'vue'import { otp } from '@master/ui/behaviors/otp.js'import { useBehavior } from './use-behavior'defineOptions({ inheritAttrs: false })const { length = 6, pattern = '[0-9]', inputMode = 'numeric', slotLabel = (index: number) => `Digit ${index + 1}` } = defineProps<{  /** How many characters the code has. */  length?: number  /** What one character must match, as the `pattern` of each slot. A digit by default. */  pattern?: string  inputMode?: 'numeric' | 'text'  /** The name of a slot, which has no visible label. */  slotLabel?: (index: number) => string  invalid?: boolean}>()/** The characters typed so far: `v-model`. */const model = defineModel<string>()const emit = defineEmits<{  /** The code, once every slot holds a character. */  complete: [code: string]}>()/* A one-time code. Name the group with `aria-label` or `aria-labelledby`; `disabled` disables every slot. */const first = model.value ?? ''const root = useTemplateRef<HTMLFieldSetElement>('root')useBehavior(root, otp, undefined, model, {  // The root's own change is the behavior's: every slot is filled. A slot's change bubbles through it too.  change: (event) => { if (event.target === event.currentTarget) emit('complete', (event as CustomEvent<{ value: string }>).detail.value) },})</script><template>  <fieldset class="otp" ref="root" v-bind="$attrs">    <input v-for="index in length" :key="index" class="otp-slot" :inputmode="inputMode" :pattern="pattern" maxlength="1" :autocomplete="index === 1 ? 'one-time-code' : 'off'" :aria-label="slotLabel(index - 1)" :aria-invalid="invalid || undefined" :value.attr="first[index - 1]">  </fieldset></template>

#Classes

ClassDescription
otpThe group of slots, on a fieldset with a label
otp-slotOne character, on an input drawn as a small well; it has no visible label, so it needs a name such as "Digit 1"
otp-separatorA short dash between groups of slots, such as three and three; hide it with aria-hidden="true"

#Custom properties

PropertyDefaultDescription
--otp-width2.5remSlot width. On touch screens a slot is 2.75rem wide and the property is not read
--otp-height2.75remSlot height. On touch screens a slot is 3rem tall and the property is not read

#Source

@master/ui/components/otp.css
/* * OTP: a short code typed one character to a slot. * Each slot is a native input drawn as a small well, the same material as a text field, so a slot rises * to the sheet with an ink contour while it has focus. Moving on after a character and pasting a whole code * belong to a script; without one, every slot is still an input the keys can reach. */@layer components {  .otp {    --otp-width: 2.5rem;    --otp-height: 2.75rem;    display: inline-flex;    align-items: center;    gap: .5rem;    min-width: 0;    margin: 0;    padding: 0;    border: 0;  }  .otp-slot {    width: var(--otp-width);    height: var(--otp-height);    margin: 0;    padding: 0;    border: 0;    border-radius: calc(var(--radius-control) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: var(--color-surface-inset);    color: var(--color-text-strong);    box-shadow: var(--shadow-pressed);    font: var(--font-weight-control) 1.125rem/1 var(--font-family-sans);    font-feature-settings: inherit;    font-variant-numeric: tabular-nums;    text-align: center;    caret-color: var(--color-text-strong);    appearance: textfield;    transition-property: box-shadow, background-color;    transition-duration: var(--duration-quick);    transition-timing-function: var(--easing-glide);    /* A slot holds one character: a number field's steppers have nothing to step. */    &::-webkit-inner-spin-button, &::-webkit-outer-spin-button {      display: none;    }    &::placeholder {      color: var(--color-text-disabled);      opacity: 1;    }    @media (hover: hover) {      &:hover:not(:focus, :disabled) {        background: var(--color-surface-inset-hover);      }    }    &:focus {      outline: none;      background: var(--color-surface-raised);      box-shadow: inset 0 0 0 .5px var(--color-line-mark), 0 0 0 .5px var(--color-line-mark), 0 0 0 3.5px var(--color-focus-halo);    }    &[aria-invalid="true"] {      box-shadow: var(--shadow-pressed), inset 0 0 0 .5px var(--color-danger), 0 0 0 .5px color-mix(in oklab, var(--color-danger) 45%, transparent);      &:focus {        box-shadow: inset 0 0 0 .5px var(--color-danger), 0 0 0 .5px var(--color-danger), 0 0 0 3.5px color-mix(in oklab, var(--color-danger) 15%, transparent);      }    }    &:disabled {      color: var(--color-text-disabled);      cursor: not-allowed;    }    @media (pointer: coarse) {      width: 2.75rem;      height: 3rem;    }    @media (forced-colors: active) {      outline: 1px solid FieldText;      outline-offset: -1px;      &:focus {        outline: 2px solid Highlight;        outline-offset: -2px;      }    }  }  /* A short dash between groups of slots, such as three and three. */  .otp-separator {    flex: none;    width: .5rem;    height: 1.5px;    border-radius: 999px;    background: var(--color-line-mark);    @media (forced-colors: active) {      background: CanvasText;    }  }}