Theme
On this page (10)
Component Skill — components/otp-input/component-skill.md

Native basis

A .otp-input around one <input>; the script adds the aria-hidden slots, maxlength, inputmode and autocomplete="one-time-code" (unless you set them).

Web Platform APIs

autocomplete="one-time-code"inputmodesetSelectionRange():has()aria-invalid

Classes

.otp-input.otp-input-slots.otp-input-slot.otp-input-separator

Data attributes

data-length (default 6), data-pattern (digits, alphanumeric), data-group-size, data-mask; --otp-input-slot-size; event otp-complete (detail.value).

§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 code
  • filled - 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:

StateTypeValuesDefaultDescription
valuestring—""The code; set through the State API (setState('default', { value })), read from the input.
filledbooleantrue, falsefalseComplete - entered automatically when the last character lands.
invalidbooleantrue, falsefalseRejected: 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