OTP InputMOL
A short code typed once - SMS verification, email confirmation, a PIN. One real <input> covers a row of slots instead of one input per digit: paste, one-time-code autofill, selection, IME and the form value all stay the browser's, and a screen reader announces one labelled field rather than six. The slots only mirror the value.
On this page (10)
§Verification code
One real <input> under six slots: type, paste or let the phone autofill the SMS code (autocomplete='one-time-code' and inputmode='numeric' are set for you) - the slots mirror the value, the active slot stands in for the caret, and a screen reader hears one labelled field.
§Grouped
data-group-size='3' draws a separator every three slots - a 3-3 code reads faster.
§Letters allowed
data-length='4' and data-pattern='alphanumeric' - anything else is dropped as it arrives, with the caret kept where typing left it.
§Masked PIN
data-mask draws a dot instead of each character; the value underneath is unchanged.
§Disabled
disabled on the input: the surface dims but the digits keep their contrast - no opacity wash.
§Verify on complete
otp-complete fires with the value the moment the last character lands - typed, pasted or autofilled. Here it checks the code (try 424242): a wrong code enters the invalid state (aria-invalid, not just a red border), a right one submits.
§Slot size
The public --otp-input-slot-size sizes every slot - a large PIN pad or a compact inline code.
§States
Named states via the shared State API, driven per instance through the bound api:
default- editable;{ value }presets the codefilled- complete; entered automatically the moment the last character lands ({ value }optional)invalid- the code was rejected:aria-invalid="true"and destructive slots
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/otp-input-{state}.png.
Machine contract - verified against otp-input.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | string | — | "" | The code; set through the State API (setState('default', { value })), read from the input. |
filled | boolean | true, false | false | Complete - entered automatically when the last character lands. |
invalid | boolean | true, false | false | Rejected: aria-invalid and destructive slots. |
§CSS view file
/* -- OTP Input -------------------------------------------------- *//* One real <input> under a row of slots. The input keeps every native *//* behaviour (paste, one-time-code autofill, selection, form value); *//* the slots are a mirror of its value. */@layer components { .otp-input { --otp-input-slot-size: 2.75rem; position: relative; display: inline-flex; width: max-content; /* The field covers the slots so clicks, drags and taps land on the real control. Its own text is invisible — the slots do the drawing — but it stays a normal input to the browser and to assistive technology. */ & > input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: transparent; caret-color: transparent; font-size: 1rem; /* >=16px keeps iOS from zooming on focus */ text-align: center; outline: none; cursor: text; /* the selection highlight would show through the transparent text */ &::selection { background: transparent; } &:disabled { cursor: not-allowed; } } } .otp-input-slots { display: flex; align-items: center; gap: 0.5rem; pointer-events: none; /* the field beneath owns every pointer event */ } .otp-input-slot { /* --otp-input-slot-size is the outer size, border included */ box-sizing: border-box; display: grid; place-items: center; width: var(--otp-input-slot-size); height: var(--otp-input-slot-size); border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); color: var(--foreground); font-family: var(--font-mono); font-size: 1.125rem; font-variant-numeric: tabular-nums; transition: border-color 150ms, box-shadow 150ms; &[data-filled] { border-color: var(--ring); } /* the slot the caret sits in — the field's own caret is hidden */ &[data-active], &[data-caret] { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 25%, transparent); } &[data-caret]::after { content: ""; width: 1px; height: 1.125rem; background: var(--foreground); animation: otp-input-caret 1s steps(2, start) infinite; } } /* Gap between groups, e.g. 3-3 for a six digit code. */ .otp-input-separator { width: 0.75rem; height: 1px; background: var(--border); flex-shrink: 0; } /* -- Invalid ------------------------------------------------ */ .otp-input[data-invalid] .otp-input-slot { border-color: var(--destructive); &[data-active], &[data-caret] { box-shadow: 0 0 0 3px color-mix(in oklch, var(--destructive) 25%, transparent); } } /* -- Disabled ----------------------------------------------- */ /* Deliberately not a blanket opacity fade: the digits must stay readable, so only the surface dims while the border and text keep their contrast. */ .otp-input:has(> input:disabled) .otp-input-slot { background: var(--muted); color: var(--muted-foreground); border-color: var(--border); } @keyframes otp-input-caret { 0% { opacity: 1; } 100% { opacity: 0; } }}@media (prefers-reduced-motion: reduce) { @layer components { .otp-input, .otp-input *, .otp-input::before, .otp-input::after, .otp-input *::before, .otp-input *::after, .otp-input-slot, .otp-input-slot::before, .otp-input-slot::after { transition: none; animation: none; } }}@media (prefers-contrast: more) { @layer components { .otp-input-slot { border-width: 2px; border-color: var(--foreground); } .otp-input[data-invalid] .otp-input-slot { border-color: var(--destructive); } }}@media (forced-colors: active) { @layer components { .otp-input-slot { border-color: ButtonText; color: ButtonText; &[data-active], &[data-caret] { border-color: Highlight; } } .otp-input[data-invalid] .otp-input-slot { border-color: LinkText; } .otp-input:has(> input:disabled) .otp-input-slot { border-color: GrayText; color: GrayText; } }}§JS view file
// -- OTP Input ------------------------------------------------// One real <input> under a row of slots. The input is the control — native// paste, one-time-code autofill, selection, IME and form submission all keep// working — and the slots are a mirror of its value, marked aria-hidden so the// field is announced once, not once per digit. Plus the named-state API// (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const otpInputStates = ['default', 'filled', 'invalid'];/** Characters a field accepts, by data-pattern. */const PATTERNS = { digits: /[^0-9]/g, alphanumeric: /[^a-zA-Z0-9]/g,};const lengthOf = (otp) => Math.max(1, parseInt(otp.dataset.length || '6', 10) || 6);/** Strips anything the pattern disallows and trims to the field length. */function clean(otp, raw) { const strip = PATTERNS[otp.dataset.pattern] ?? PATTERNS.digits; return raw.replace(strip, '').slice(0, lengthOf(otp));}/** * Paints the slots from the input's value and caret. Slots carry no text of * their own — they are a view, so a repaint can never disagree with the value * the form will submit. */function paint(otp) { const field = otp._field; if (!field) return; const value = field.value; const focused = document.activeElement === field; // the caret sits at selectionStart; past the end it belongs to the last slot const caret = Math.min(field.selectionStart ?? value.length, lengthOf(otp) - 1); otp._slots.forEach((slot, i) => { const char = value[i] ?? ''; slot.textContent = otp.hasAttribute('data-mask') && char ? '•' : char; slot.toggleAttribute('data-filled', char !== ''); // the active slot stands in for the caret, which is invisible on the field slot.toggleAttribute('data-active', focused && i === caret && value.length < lengthOf(otp)); slot.toggleAttribute('data-caret', focused && i === value.length && value.length < lengthOf(otp)); });}/** Fires when the field reaches its full length, so a form can submit itself. */function announceComplete(otp) { otp.dispatchEvent( new CustomEvent('otp-complete', { bubbles: true, detail: { value: otp._field.value } }), );}/** * UI side of setState. 'default' and 'filled' both accept `{ value }`; the * difference is what they mean to a reader, and 'filled' fills the field when * given no value of its own. 'invalid' marks the field aria-invalid. */function triggerStateChange(otp, stateName, config) { const field = otp._field; switch (stateName) { case 'default': otp.removeAttribute('data-invalid'); field.removeAttribute('aria-invalid'); if (typeof config.value === 'string') field.value = clean(otp, config.value); break; case 'filled': otp.removeAttribute('data-invalid'); field.removeAttribute('aria-invalid'); if (typeof config.value === 'string') { field.value = clean(otp, config.value); } else if (field.value.length < lengthOf(otp)) { // asked to *show* a complete field with nothing to show — stand in with // zeros. Never overwrite a value that is already complete: this state // is also entered automatically the moment the user finishes typing. field.value = '0'.repeat(lengthOf(otp)); } break; case 'invalid': otp.setAttribute('data-invalid', ''); field.setAttribute('aria-invalid', 'true'); if (typeof config.value === 'string') field.value = clean(otp, config.value); break; } paint(otp);}/** Registry-level API; pass the otp-input element explicitly. Unknown names throw. */export const otpInputApi = { setState(otp, stateName, config = {}) { if (!otpInputStates.includes(stateName)) { throw new Error( `otp-input: unknown state "${stateName}" (supported: ${otpInputStates.join(', ')})`, ); } triggerStateChange(otp, stateName, config); // state lives on the ELEMENT, not the module: a page may hold several // fields, each in a different state otp.dataset.stateName = stateName; otp._stateConfig = config; }, getState(otp) { return { name: otp.dataset.stateName || 'default', config: otp._stateConfig ?? {} }; },};df$.otpInputApi = otpInputApi;df$.otpInputStates = otpInputStates;function init() { document.querySelectorAll('.otp-input:not([data-init])').forEach((otp) => { otp.dataset.init = ''; const field = otp.querySelector('input'); if (!field) return; // the input is authored, not generated — nothing to drive otp._field = field; const length = lengthOf(otp); field.maxLength = length; if (!field.getAttribute('inputmode')) { field.setAttribute('inputmode', otp.dataset.pattern === 'alphanumeric' ? 'text' : 'numeric'); } if (!field.getAttribute('autocomplete')) field.setAttribute('autocomplete', 'one-time-code'); // slots mirror the value; aria-hidden so the field is announced once const groupSize = parseInt(otp.dataset.groupSize || '0', 10) || 0; otp._slots = []; const shell = document.createElement('div'); shell.className = 'otp-input-slots'; shell.setAttribute('aria-hidden', 'true'); for (let i = 0; i < length; i++) { if (groupSize && i > 0 && i % groupSize === 0) { const sep = document.createElement('div'); sep.className = 'otp-input-separator'; shell.appendChild(sep); } const slot = document.createElement('div'); slot.className = 'otp-input-slot'; shell.appendChild(slot); otp._slots.push(slot); } otp.appendChild(shell); const sync = () => { const cleaned = clean(otp, field.value); if (cleaned !== field.value) { const at = field.selectionStart; field.value = cleaned; // keep the caret where the typing left it, not at the end if (at !== null) field.setSelectionRange(Math.min(at, cleaned.length), Math.min(at, cleaned.length)); } paint(otp); if (field.value.length === length) { // carry the value through, so the state change reports what the user // actually entered rather than re-deriving it otpInputApi.setState(otp, otp.hasAttribute('data-invalid') ? 'invalid' : 'filled', { value: field.value, }); announceComplete(otp); } }; field.addEventListener('input', sync); field.addEventListener('focus', () => paint(otp)); field.addEventListener('blur', () => paint(otp)); // arrows/clicks move the caret without firing input field.addEventListener('keyup', () => paint(otp)); field.addEventListener('click', () => paint(otp)); field.addEventListener('select', () => paint(otp)); otp.api = { setState: (stateName, config) => otpInputApi.setState(otp, stateName, config), getState: () => otpInputApi.getState(otp), }; otpInputApi.setState(otp, field.value.length === length ? 'filled' : 'default', {}); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub