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.
Without script every slot is an ordinary input: the reader types a character and presses Tab. The otpbehavior 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.
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.
Option
What it sets
pattern
A 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.
Mark the slots aria-invalid="true" when the code is wrong, and say why under the group.
Invalid
<fieldsetclass="otp"aria-label="Verification code"aria-describedby="otp-error"><inputclass="otp-slot"inputmode="numeric"maxlength="1"aria-label="Digit 1"aria-invalid="true"value="4"><inputclass="otp-slot"inputmode="numeric"maxlength="1"aria-label="Digit 2"aria-invalid="true"value="2"><inputclass="otp-slot"inputmode="numeric"maxlength="1"aria-label="Digit 3"aria-invalid="true"value="0"><inputclass="otp-slot"inputmode="numeric"maxlength="1"aria-label="Digit 4"aria-invalid="true"value="9"></fieldset><pclass="m(0)text-xs fg-danger"id="otp-error">That code has expired. Request a new one.</p>
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.
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'exportinterfaceOtpPropsextendsOmit<ComponentProps<'fieldset'>,'defaultValue'|'onChange'>{/** How many characters the code has. */length?:number/** The characters typed so far. */value?:stringdefaultValue?:stringonValueChange?:(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?:stringinputMode?:'numeric'|'text'/** The name of a slot, which has no visible label. */slotLabel?:(index:number)=>stringinvalid?:boolean}/** A one-time code. Name the group with `aria-label` or `aria-labelledby`; `disabled` disables every slot. */exportfunctionOtp({ 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 asCustomEvent<{value:string}>).detail.value)}},})const[first]=useState(value ?? defaultValue ??'')return(<fieldsetclassName={className ?`otp ${className}`:'otp'}ref={ref}{...root}>{Array.from({ length },(_, index)=>(<inputclassName="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
<scriptlang="ts"module>importtype{ HTMLFieldsetAttributes }from'svelte/elements'exportinterfaceOtpPropsextendsOmit<HTMLFieldsetAttributes,'children'|'inputmode'>{/** How many characters the code has. */length?:number/** The characters typed so far. Bind it, or leave it out. */value?:stringonValueChange?:(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?:stringinputmode?:'numeric'|'text'/** The name of a slot, which has no visible label. */slotLabel?:(index:number)=>stringinvalid?:boolean}</script><scriptlang="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 asCustomEvent<{value:string}>).detail.value)}},})</script><!--@component A one-time code. Name the group with `aria-label` or `aria-labelledby`; `disabled` disables every slot.--><fieldsetclass={['otp', className]}{@attach attach}{...root}>{#each{ length }, index}<inputclass="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
<scriptsetuplang="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?:stringinputMode?:'numeric'|'text'/** The name of a slot, which has no visible label. */slotLabel?:(index:number)=>stringinvalid?: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 asCustomEvent<{value:string}>).detail.value)},})</script><template><fieldsetclass="otp"ref="root"v-bind="$attrs"><inputv-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>
/* * 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/1var(--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:inset000.5pxvar(--color-line-mark),000.5pxvar(--color-line-mark),0003.5pxvar(--color-focus-halo);}&[aria-invalid="true"]{box-shadow:var(--shadow-pressed),inset000.5pxvar(--color-danger),000.5pxcolor-mix(in oklab,var(--color-danger)45%,transparent);&:focus{box-shadow:inset000.5pxvar(--color-danger),000.5pxvar(--color-danger),0003.5pxcolor-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:1pxsolid FieldText;outline-offset:-1px;&:focus{outline:2pxsolid 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;}}}