Skip to content

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:

PackageInstallREADME
@rozie-ui/otp-reactnpm i @rozie-ui/otp-reactreact/README
@rozie-ui/otp-vuenpm i @rozie-ui/otp-vuevue/README
@rozie-ui/otp-sveltenpm i @rozie-ui/otp-sveltesvelte/README
@rozie-ui/otp-angularnpm i @rozie-ui/otp-angularangular/README
@rozie-ui/otp-solidnpm i @rozie-ui/otp-solidsolid/README
@rozie-ui/otp-litnpm i @rozie-ui/otp-litlit/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

NameTypeDefaultRuntime-updatable?Description
valueString''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.
lengthNumber6yesNumber of cells.
typeString"numeric"yesAllowed-character class + mobile keyboard hint: 'numeric' (digits, inputmode="numeric"), 'alphanumeric' ([A-Za-z0-9], inputmode="text"), or 'text' (any non-space, inputmode="text").
maskBooleanfalseyesRender cells as masked dots (type="password") — for sensitive codes.
autoFocusBooleanfalseyesFocus the first empty cell on mount.
disabledBooleanfalseyesDisable every cell (also sets the Angular CVA disabled state).
placeholderString''yesPer-cell placeholder character (e.g. '•' or '0').
ariaLabelStringnullyesAccessible name for the whole group (role="group"). Each cell also gets an ordinal aria-label ("Digit 1 of 6").

Events

EventDescription
changeFired 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.
completeFired 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.

MethodDescription
focusMove 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).
clearReset 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:

KeyAction
a digit / characterFiltered 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.
BackspaceDelete 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 / EndMove focus to the first / last cell.
pasteThe 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 the ariaLabel you supply as its aria-label; each cell is a native <input maxlength="1"> carrying an ordinal aria-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 set autocomplete="off".
  • type="numeric" sets inputmode="numeric" for a numeric soft keyboard; 'alphanumeric' / 'text' use inputmode="text". autocapitalize, autocorrect, and spellcheck are all disabled on every cell.
  • mask switches the cells to type="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.

Pre-1.0 — APIs may change between minor versions.