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.
Also known as: OTP input, one-time-code input, verification code.
Basic HTML
Section titled “Basic HTML”<div class="hc-inputotp" data-length="6"> <input class="hc-inputotp__input" type="text" name="otp" inputmode="numeric" autocomplete="one-time-code" aria-label="One-time code"></div>import { installInputOtp } from '@hypermedia-components/core';installInputOtp(); // idempotent; returns an uninstallerThe 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).
Configuration
Section titled “Configuration”| Attribute | Purpose |
|---|---|
data-length |
Number of slots (default 6); also sets the input’s maxlength. |
data-pattern |
Allowed 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-groups |
Visually 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.
Group separators
Section titled “Group separators”data-groups="3-3" renders a separator between each group of slots — common
for formatted codes like 123-456:
<div class="hc-inputotp" data-length="6" data-groups="3-3"> <input class="hc-inputotp__input" type="text" aria-label="One-time code"></div>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).
States
Section titled “States”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.
<div class="hc-inputotp" data-length="4"> <input class="hc-inputotp__input" type="text" aria-label="Code" aria-invalid="true"></div>
<div class="hc-inputotp" data-variant="success" data-length="4"> <input class="hc-inputotp__input" type="text" aria-label="Code (valid)"></div>
<div class="hc-inputotp" data-length="4"> <input class="hc-inputotp__input" type="text" aria-label="Code" disabled value="12"></div>Active slot & caret
Section titled “Active slot & caret”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.
Events
Section titled “Events”Both bubble from the container:
hc:otpchange—detail { 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);});htmx usage
Section titled “htmx usage”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.
Submit when the code fills
Section titled “Submit when the code fills”hx-trigger="hc:otpcomplete" makes the event the trigger, which
means the form no longer submits on a plain submit — a visible
Verify button or the no-JS post stop working. To keep the form’s own
submit path and still post the moment the code fills, put
data-hc-submit-on-change on the container with the event as its value:
<form method="post" action="/verify" data-hx-post="/verify"> <div class="hc-inputotp" data-length="6" data-hc-submit-on-change="hc:otpcomplete"> <input class="hc-inputotp__input" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" aria-label="Verification code"> </div> <button class="hc-button" data-variant="primary" type="submit">Verify</button></form>installSubmitOnChange() listens for the named event instead of
change and calls form.requestSubmit(), so the request goes through
the form’s submit — htmx (and hx-sync, the CSRF header, …) take it
from there, the Verify button still works, and without JavaScript the
user presses it. The bare attribute (no value) listens for change; see
Switch → The switch is the action.
Hyperscript
Section titled “Hyperscript”The auto-submit above needs no helper — data-hc-submit-on-change
covers it. For other reactions to hc:otpchange / hc:otpcomplete
(showing a hint, enabling a button), _hyperscript’s on hc:… reads
well: Hyperscript → Reacting to component events.
Accessibility
Section titled “Accessibility”- One real, labelled
<input>carries the value; the visual slots arearia-hiddenso 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.
Theming tokens
Section titled “Theming tokens”| Token path | Purpose |
|---|---|
inputotp.gap |
Space between slots. |
inputotp.slot-size |
Slot box size (square). |
inputotp.radius / border / bg / fg |
Slot chrome. |
inputotp.font-size / font-weight |
The digit. |
inputotp.active-border / caret-color |
Active slot + caret (track data-color). |
inputotp.error-border |
aria-invalid / data-variant="error" border. |
inputotp.success-border / warning-border |
data-variant="success" / "warning" borders. |
inputotp.disabled-bg / disabled-fg |
Disabled slots. |
CSS variables
Section titled “CSS variables”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 | -success-border | -warning-border--hc-inputotp-disabled-bg | -disabled-fg--hc-inputotp-separator /* the data-groups separator glyph; default "–" */Out of scope
Section titled “Out of scope”RTL fine-tuning is deferred. Per-slot click caret placement (see Active slot & caret) and group separators (see Group separators) are supported.