Appearance
Otp — the cross-framework headless one-time-code input
Otp is a headless, fully-accessible one-time-code / PIN input with no third-party engine behind it. It covers the whole behaviour surface: per-cell typing, paste-to-distribute, backspace/arrow/Home/End navigation, focus choreography, role="group", ordinal aria-labels, masking, and autocomplete="one-time-code" SMS autofill. The same component ships for React, Vue, Svelte, Angular, Solid, and Lit.
The foundation is the platform itself: N native <input> cells. Browser focus, the keyboard, the clipboard, and one-time-code SMS autofill all come from the platform. The component is fully controlled with no local state: the assembled code string is value (the sole model: true prop), and each cell's displayed character is derived from it (value[i]). There is no draft buffer and no value↔cells echo guard to maintain; entry is sequential (left → right), so value is always a contiguous string. Rozie owns the author-side API: the two-way r-model:value, the sanitize/distribute logic, the focus choreography (via one container ref, never per-cell refs), and the token-themed skin.
Every visual value is a CSS custom property, so the input re-skins to any design system, with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5.
The @rozie-ui/otp packages
Otp ships as six pre-compiled, per-framework packages. Install the one for your framework; there is no build step and no Rozie toolchain to set up:
| Package | Install | README |
|---|---|---|
@rozie-ui/otp-react | npm i @rozie-ui/otp-react | react/README |
@rozie-ui/otp-vue | npm i @rozie-ui/otp-vue | vue/README |
@rozie-ui/otp-svelte | npm i @rozie-ui/otp-svelte | svelte/README |
@rozie-ui/otp-angular | npm i @rozie-ui/otp-angular | angular/README |
@rozie-ui/otp-solid | npm i @rozie-ui/otp-solid | solid/README |
@rozie-ui/otp-lit | npm i @rozie-ui/otp-lit | lit/README |
Each package carries only its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common + @angular/forms, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core).
Quick start
Two-way bind value and set length / type to get a segmented code input. The assembled code is always a contiguous string; @complete fires when the last cell is filled:
rozie
<components>
{
Otp: './Otp.rozie',
}
</components>
<data>
{
code: '',
}
</data>
<template>
<!-- 6-digit numeric code -->
<Otp r-model:value="$data.code" :length="6" type="numeric" ariaLabel="Verification code" @complete="onComplete" />
<!-- masked 4-digit PIN -->
<Otp r-model:value="$data.code" :length="4" mask ariaLabel="PIN" />
</template>r-model:value is Rozie's two-way bind: the consumer hands Otp a string, Otp writes the new contiguous code back on every edit (type, paste, backspace), and the framework reconciler picks it up with no onChange → setState wiring. Because value is the component's sole model: true prop, the Angular output additionally implements ControlValueAccessor, so an Otp is a form control ([formControl] / [(ngModel)] bind directly).
API
Props
| Name | Type | Default | Runtime-updatable? | Description |
|---|---|---|---|---|
value | String | '' | yes (via r-model) | The assembled code — the sole model: true prop, so Angular emits a ControlValueAccessor. Always a contiguous string of 0..length characters. |
length | Number | 6 | yes | Number of cells. |
type | String | "numeric" | yes | Allowed-character class + mobile keyboard hint: 'numeric' (digits, inputmode="numeric"), 'alphanumeric' ([A-Za-z0-9], inputmode="text"), or 'text' (any non-space, inputmode="text"). |
mask | Boolean | false | yes | Render cells as masked dots (type="password") — for sensitive codes. |
autoFocus | Boolean | false | yes | Focus the first empty cell on mount. |
disabled | Boolean | false | yes | Disable every cell (also sets the Angular CVA disabled state). |
placeholder | String | '' | yes | Per-cell placeholder character (e.g. '•' or '0'). |
ariaLabel | String | null | yes | Accessible name for the whole group (role="group"). Each cell also gets an ordinal aria-label ("Digit 1 of 6"). |
Events
| Event | Description |
|---|---|
change | Fired on every edit (type, paste, backspace, or a programmatic clear) that actually changes the code — a write that produces the same value does not re-emit. Payload { value } — the new contiguous code string (0..length chars). Funneled through one commitValue wrapper so the React prop-destructure hoists exactly once. |
complete | Fired on the not-full → full transition (the code reaches length characters). Editing a cell of an already-complete code does not re-fire it, and clear() never fires it. Payload { value } — the complete code. Use it to auto-submit a verification flow. |
Imperative handle
Declared once in the source via $expose; obtained through each framework's native ref mechanism.
| Method | Description |
|---|---|
focus | Move DOM focus to the first empty cell (clamped to the last cell when the code is full). Deliberately named focus, overriding the inherited HTMLElement.focus on the Lit custom element; the override is intentional, and the compiler accepts it with a warning. This mirrors the slider precedent; listbox took the other branch (focusControl). |
clear | Reset the code to the empty string (emits change with { value: "" }) and move focus to the first cell. Collision-safe — not a host-element member. |
Slots
Otp declares no slots — the cells are native <input> elements rendered by the component, and the surface is fully covered by props, events, and the imperative handle.
Theming
Every value the component renders is a --rozie-otp-* CSS custom property with a built-in fallback, so it works with zero configuration yet is completely re-skinnable. Override tokens at any ancestor scope:
css
.rozie-otp {
--rozie-otp-accent: #16a34a;
--rozie-otp-cell-size: 3rem;
--rozie-otp-radius: 0.75rem;
--rozie-otp-gap: 0.75rem;
}Only cosmetic values flow through tokens; the structural rules (the inline-flex cell row, the per-cell box model, the focus ring) compile per-leaf and are not consumer-overridable.
The complete token table and the design-system bridges live on the dedicated theming page.
Keyboard
Focus a cell (Tab or click), then drive the input from the keyboard. Typing a character advances focus; backspace deletes and retreats; arrows / Home / End navigate without editing:
| Key | Action |
|---|---|
| a digit / character | Filtered by type, then written at the first empty cell — clicking a cell past the fill point and typing lands the character there rather than leaving a hole. A full code stays editable in place at any cell (the clamp is a no-op when there is no empty cell left), so overwriting a filled cell works. |
| multi-character input (SMS autofill, swipe, IME commit) | The whole string is filtered by type and distributed across cells from the same clamped position paste uses — one routine serves single-char typing, autofill, swipe, and IME commit alike. |
Backspace | Delete the current cell's character; if it is already empty, delete the previous cell's character and move focus back. |
← / → | Move focus to the previous / next cell (no edit). |
Home / End | Move focus to the first / last cell. |
| paste | The pasted text is filtered by type and distributed across the cells from the clamped position (the first empty cell, or the paste target when the code is already full); focus lands after the last written cell. |
Accessibility
- The container is a
role="group"with theariaLabelyou supply as itsaria-label; each cell is a native<input maxlength="1">carrying an ordinalaria-label("Digit 1 of 6"). - The first cell sets
autocomplete="one-time-code", so mobile browsers offer to autofill a code received over SMS; the remaining cells setautocomplete="off". type="numeric"setsinputmode="numeric"for a numeric soft keyboard;'alphanumeric'/'text'useinputmode="text".autocapitalize,autocorrect, andspellcheckare all disabled on every cell.maskswitches the cells totype="password"so a sensitive code renders as dots while keeping the same keyboard and ARIA behaviour.- Focus choreography reads a single container ref and walks
root.querySelectorAll('input')— which reaches the cells inside Lit's shadow root too — and runs only in post-mount handlers, so it is identical on all six targets.