Skip to content

Input OTP

hc-inputotp is a segmented one-time-code field — the shadcn InputOTP equivalent. It uses the accessible single-input approach: one real <input autocomplete="one-time-code"> captures all typing, paste, SMS autofill, and selection, and installInputOtp overlays it transparently and renders the familiar boxes. Screen readers interact with one labelled input; the slots are decorative.

This avoids the screen-reader and paste problems of the common “one input per digit” pattern.

import { installInputOtp } from '@hypermedia-components/core';
installInputOtp(); // idempotent; returns an uninstaller

The zero-config @hypermedia-components/core/behaviors entry installs it automatically. The behavior renders the data-length slots and sets maxlength; it also fills in inputmode="numeric", autocomplete="one-time-code", and type="text" if you omit them (type="text", not number, so leading zeros and paste work).

AttributePurpose
data-lengthNumber of slots (default 6); also sets the input’s maxlength.
data-patternAllowed characters as a CSS-style class, default [0-9]. Non-matching characters are stripped on input. Use e.g. [0-9a-zA-Z] for alphanumeric codes.
data-groupsVisually split the slots into groups — "3-3" (also "3 3" / "2,2,2"). A decorative separator is rendered between groups; ignored unless the group sizes sum to data-length.

The <input> carries the real value and the accessible name — always give it an aria-label or a <label>, and a name if it should submit.

data-groups="3-3" renders a separator between each group of slots — common for formatted codes like 123-456:

The separator is aria-hidden (the input value is the source of truth). Set --hc-inputotp-separator to any CSS content value to restyle the glyph (e.g. "·", or "" for a plain wider gap).

aria-invalid="true" on the input (or data-invalid on the container) draws the error border; disabled on the input mutes the slots. data-variant="success" | "warning" | "error" on the container recolors the slot borders with the same validation vocabulary as the other form fields.

The slot the caret is in carries data-active and renders a blinking caret (CSS, so it respects prefers-reduced-motion: reduce — the caret stays steady instead of blinking). The active slot follows the caret: as you type it advances to the next empty slot, and arrow keys move it within the value.

Clicking a slot moves the caret into it so you can edit that position — clamped to the typed length, so clicking an empty slot past the end just parks the caret at the end (you can’t open a gap). Style the active slot via data-active and the caret color via --hc-inputotp-caret-color.

Both bubble from the container:

  • hc:otpchangedetail { value, input } on every edit.
  • hc:otpcomplete — same detail, fired when the value fills every slot.
otp.addEventListener('hc:otpcomplete', (e) => {
verify(e.detail.value);
});

Auto-submit the code the moment it completes:

<form data-hx-post="/verify" data-hx-trigger="hc:otpcomplete">
<div class="hc-inputotp" data-length="6">
<input class="hc-inputotp__input" type="text" name="code"
inputmode="numeric" autocomplete="one-time-code"
aria-label="Verification code">
</div>
</form>

Because the value lives in a single named <input>, it serialises normally — no extra hidden fields.

Auto-submit the form when the code fills up, with no helper:

<form data-hx-post="/verify">
<div class="hc-inputotp" data-length="6"
_="on hc:otpcomplete call closest <form/> then call it.requestSubmit()">
<input class="hc-inputotp__input" type="text" name="code" aria-label="Code">
</div>
</form>

More patterns: Hyperscript → Reacting to component events.

  • One real, labelled <input> carries the value; the visual slots are aria-hidden so assistive tech announces a single field, not N empty boxes.
  • autocomplete="one-time-code" lets iOS / password managers offer the SMS code; inputmode="numeric" shows the numeric keypad.
  • The active slot’s border doubles as the focus indicator; the caret blink respects prefers-reduced-motion: reduce. Clicking a slot moves the caret into it for editing.
Token pathPurpose
inputotp.gapSpace between slots.
inputotp.slot-sizeSlot box size (square).
inputotp.radius / border / bg / fgSlot chrome.
inputotp.font-size / font-weightThe digit.
inputotp.active-border / caret-colorActive slot + caret (track data-color).
inputotp.error-borderaria-invalid border.
inputotp.disabled-bg / disabled-fgDisabled slots.
Show the generated CSS variables
--hc-inputotp-gap | -slot-size | -radius | -border | -bg | -fg
--hc-inputotp-font-size | -font-weight
--hc-inputotp-active-border | -caret-color
--hc-inputotp-error-border | -disabled-bg | -disabled-fg
--hc-inputotp-separator /* the data-groups separator glyph; default "–" */

RTL fine-tuning is deferred. Per-slot click caret placement (see Active slot & caret) and group separators (see Group separators) are supported.

  • Input — the standard text field.
  • Field — wrap with a label and validation message.