OTP input
A one-time-code field is a single native <input> styled to look segmented —
wrap it in .re-otp-field (which is the visible box: it draws the cell dividers
and owns the border + focus ring) and add .re-otp to the input. Keeping it one
input means native paste, mobile SMS autofill (autocomplete="one-time-code"),
IME, and form submission all just work, with no JavaScript. N separate cells
would break all of those.
Set the cell count with --re-otp-length on the wrapper and maxlength on the
input; pattern (e.g. [0-9]{6}) is the submit-time guard. Every cell is the
same width and each digit is centered in its cell.
<form>
<label class="re-field">
<span class="re-field__label">One-time code</span>
<span class="re-otp-field" style="--re-otp-length: 6">
<input
class="re-otp"
type="text"
name="code"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
maxlength="6"
required
data-re-otp
data-re-otp-numeric
/>
</span>
<span class="re-field__hint">Enter the 6-digit code we texted you.</span>
</label>
</form> Lengths & sizes
Section titled “Lengths & sizes”--re-otp-length sets the cell count; data-size="sm|lg" on the wrapper scales
the cells. Use inputmode="numeric" for digits or inputmode="text" +
autocapitalize="characters" for alphanumeric codes.
<label class="re-field">
<span class="re-field__label">4-digit PIN (small)</span>
<span class="re-otp-field" data-size="sm" style="--re-otp-length: 4">
<input
class="re-otp"
type="text"
name="pin"
inputmode="numeric"
pattern="[0-9]{4}"
maxlength="4"
data-re-otp
data-re-otp-numeric
/>
</span>
</label>
<label class="re-field">
<span class="re-field__label">Alphanumeric (large)</span>
<span class="re-otp-field" data-size="lg" style="--re-otp-length: 6">
<input
class="re-otp"
type="text"
name="ref"
inputmode="text"
autocapitalize="characters"
pattern="[A-Za-z0-9]{6}"
maxlength="6"
data-re-otp
/>
</span>
</label> Enhancement (optional)
Section titled “Enhancement (optional)”enhanceOtp is optional polish — it never splits the field or breaks autofill:
import { enhanceOtp } from "@relements/core/behaviors/otp";enhanceOtp(document);It tracks the caret to expose an active-cell hook (--re-otp-active-index), and
with data-re-otp-numeric it strips non-digits on input.
States
Section titled “States”<label class="re-field">
<span class="re-field__label">Invalid</span>
<span class="re-otp-field" style="--re-otp-length: 6">
<input class="re-otp" type="text" value="12" maxlength="6" aria-invalid="true" />
</span>
</label>
<label class="re-field">
<span class="re-field__label">Disabled</span>
<span class="re-otp-field" style="--re-otp-length: 6">
<input class="re-otp" type="text" value="123456" maxlength="6" disabled />
</span>
</label> Accessibility
Section titled “Accessibility”Because the field is one native <input>, accessibility is the platform’s — the
segmented look is pure CSS and never reaches assistive tech.
- Keyboard — fully native: Tab moves focus to the single input; typing,
caret movement, selection, and paste all behave like a normal text field. The
optional
enhanceOtpadds no keys — it never auto-advances between cells or auto-submits, so there are no surprise key behaviors to learn. - Focus — the
.re-otp-fieldwrapper shows the visible:focus-withinring (the inner input’s own outline is suppressed so the ring frames the whole box); an invalid field tints that ring and the border with the danger color. - Semantics — a screen reader announces one text input with its label (the
wrapping
<label>),autocomplete="one-time-code"for SMS autofill, andinputmode/pattern/maxlength/requiredas native constraints.aria-invalid="true"(or native:user-invalid) marks the error state; the cell dividers are a decorative::beforegradient with no role. - Notes — one input is the accessible choice on purpose: N separate cells
would fragment the value for AT and break paste and autofill. Style the dividers
with
--re-color-border-strongfor sufficient contrast against the surface. See the accessibility guide and pair this with the field wrapper for label and hint markup.