Anatomy

X-ray the component: what the DOM really looks like, and how a single text input drives a row of slots.

Everything this library does follows from one decision: keep a real text input, and stop trying to make it look like anything. The input is still there, still focusable, still holding the value — it is simply painted out of existence and laid over the top of your slots.

See it#

Three views of the same field, in three registers. Assembly builds it up one decision at a time. Isometric tilts the stack apart so you can see what sits on what. X-ray lets you undo the five hiding techniques individually and watch what each one was responsible for.

Every view is a live field — amber is the real <input>, cyan is the container. Click in and type, arrow around, select a range. What you see in amber is the native caret and the native selection; the slots are following it.

Build the field one decision at a time

1 / 7
4
8
2
01

A positioned container

One relatively positioned box. It takes your containerClassName, and it is what a password manager measures against — but on its own it renders nothing at all.

position: relative; pointer-events: none

The DOM#

Three elements, in this order. The order matters — the input is painted after your slots so it wins the hit test, and the container is pointer-events: none so clicks fall through your decorative markup and land on the field.

<div data-input-otp-container style="position: relative; pointer-events: none">
  <!-- 1 — your markup, from render() or from children -->
  <div class="flex">
    <div></div>   <!-- slot 0 -->
    <div></div>   <!-- slot 1 -->

  </div>
 
  <!-- 2 — the real field, stretched across the container -->
  <div style="position: absolute; inset: 0; pointer-events: none">
    <input
      data-input-otp
      autocomplete="one-time-code"
      inputmode="numeric"
      maxlength="6"
      value="482"
      data-input-otp-mss="3"
      data-input-otp-mse="3"
      style="
        position: absolute; inset: 0;
        width: 100%; height: 100%;
        color: transparent;          /* the characters are there, just unseen */
        caret-color: transparent;    /* you draw the caret */
        background: transparent;
        opacity: 1;                  /* mandatory — iOS won't paste into 0 */
        letter-spacing: -.5em;       /* collapse the text into a narrow band */
        font-size: var(--root-height);
        pointer-events: all;         /* the one thing that is clickable */
      "
    />
  </div>
</div>
  • The container is position: relative and user-select: none. It takes your containerClassName, and it is the element a password manager measures against.
  • Your slots are ordinary children. The library never touches them; it only hands you state.
  • The input fills the container absolutely. It is the only node with pointer-events: all, which is why one click anywhere in the field focuses it and puts the caret in the right place.

Why font-size: var(--root-height)#

A ResizeObserver writes the input's pixel height into --root-height on the container, and the input's font size is set from it. The point is to make the native caret and the native selection highlight the same height as your slots — so when the browser draws its own UI (a selection band, a drag handle, the iOS bubble), it lines up with the boxes the user can see, instead of hugging a 16px line in the middle.

Selection is mirrored, not owned#

The library never stores "the active slot" as its own idea of truth. It listens to document for selectionchange in the capture phase, reads selectionStart, selectionEnd and selectionDirection off the input, sometimes rewrites them, and mirrors the result into React state. Your slots render from the mirror.

Mirroring rather than owning is what keeps every native gesture working. Shift-arrow ranges, -delete, double-click word select, drag-select, the Android clipboard bar, middle-click paste — none of them are implemented here. They all just move the selection, and the mirror follows.

The rewrite#

There is one thing a slotted UI cannot represent: a collapsed caret. A caret at index 3 sits between slot 2 and slot 3, so "which slot is active" has no answer. The fix is to widen it into a one-character range, which also makes typing overwrite the slot you are standing on — exactly what people expect from an OTP field.

// Runs on every 'selectionchange', in the capture phase.
const isSingleCaret = start === end
const isInsertMode = start === value.length && value.length < maxLength
 
if (isSingleCaret && !isInsertMode) {
  const c = start
 
  if (c === 0) {
    // At the very start there is nothing to the left — claim slot 0.
    [start, end, direction] = [0, 1, 'forward']
  } else if (c === maxLength) {
    // At the very end there is nothing to the right — claim the last slot.
    [start, end, direction] = [c - 1, c, 'backward']
  } else if (maxLength > 1 && value.length > 1) {
    // In the middle, a caret at index c sits between slot c-1 and slot c.
    // Which one the user meant depends on the direction they arrived from.
    let offset = 0
    direction = c < prevEnd ? 'backward' : 'forward'
 
    const wasPreviouslyInserting = prevStart === prevEnd && prevStart < maxLength
    if (direction === 'backward' && !wasPreviouslyInserting) {
      offset = -1
    }
 
    [start, end] = [offset + c, offset + c + 1]
  }
 
  input.setSelectionRange(start, end, direction)
}

Three details in there earn their keep:

  1. Insert mode is exempt. When the caret is collapsed at the end of a code that isn't full yet, that is a meaningful position — it's where the next character goes. Widening it would select the last typed character and the next keystroke would replace it instead of appending.
  2. The edges are special-cased. At index 0 there is no slot to the left, and at index maxLength there is none to the right, so those two clamp outward instead of guessing.
  3. Direction is inferred from the previous selection. Comparing the new caret against the stored [prevStart, prevEnd] tells the algorithm whether the user is moving left or right, and a backward move needs the extra offset = -1 to land on the slot they were aiming at. Without it, pressing appears to skip a slot.

Watch it run#

Type, arrow, select a range, cut, paste. The readout is the input's real selection after the rewrite, next to the mirrored values your render function receives.

1
2
3
4
value
""
selectionStart / End
— (blurred)
selectionDirection
data-input-otp-mss / mse
/
branch
blurred — no active slot

Notice that a bare caret never survives: whenever the value isn't full-and-at-the-end, the library widens the collapsed caret into a one-character range so exactly one slot can be “active”.

From selection to slots#

Once the mirror is settled, deriving the render state is almost uninteresting — which is the point:

slots = Array.from({ length: maxLength }).map((_, i) => {
  const isActive =
    isFocused &&
    mirrorSelectionStart !== null &&
    mirrorSelectionEnd !== null &&
    // a collapsed caret parked on this index (insert mode) …
    ((mirrorSelectionStart === mirrorSelectionEnd && i === mirrorSelectionStart) ||
      // … or this index falling inside the selected range
      (i >= mirrorSelectionStart && i < mirrorSelectionEnd))
 
  const char = value[i] ?? null
 
  return {
    char,
    placeholderChar: value[0] !== undefined ? null : placeholder?.[i] ?? null,
    isActive,
    // A caret only makes sense where there is no character to sit next to.
    hasFakeCaret: isActive && char === null,
  }
})

Note hasFakeCaret: a caret is only reported for an active slot that has no character. A slot with a character in it is drawn selected instead, because that is what typing will replace.

The parts that aren't the algorithm#

Three supporting mechanisms run alongside the selection mirror. Each has its own page, because each exists to absorb a specific piece of platform reality.