Skip to content
ShineUI
Install

InputOtp

Six boxes, zero friction.

<sui-input-otp>Text inputsStable

About

One-time-code boxes, the MFA pattern. A character fills its box and moves to the next, Backspace on an empty box steps back, and a paste fills the rest. Digits only unless alphanumeric is set. Form-associated: it posts the joined code under its name. The boxes read left to right in either direction, as a number does.

Usage

<sui-input-otp label="Verification code" name="code" help="Box one offers the SMS code."></sui-input-otp> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-input-otp"); el.addEventListener("suiChange", (e) => console.log(e.detail)); </script>
import { SuiInputOtp } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { return <SuiInputOtp label="Verification code" name="code" help="Box one offers the SMS code." onSuiChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiInputOtp } from "@shineui/vue"; import "@shineui/core/shineui.css"; const onChange = (e) => console.log(e.detail); </script> <template> <SuiInputOtp label="Verification code" name="code" help="Box one offers the SMS code." @sui-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiInputOtp } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiInputOtp], template: `<sui-input-otp label="Verification code" name="code" help="Box one offers the SMS code." (suiChange)="onChange($event)"></sui-input-otp>`, }) export class Example { onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The boxes form a group named by the label, and each one is labeled "Digit n". Box one carries autocomplete="one-time-code", so a phone can offer the SMS code, and a paste into any box fills the rest.

<div style="max-width:340px"> <sui-input-otp label="Verification code" name="code" help="Box one offers the SMS code."></sui-input-otp> </div>

Alphanumeric

Letters keep the case they are typed in, and the boxes ask for a text keyboard rather than a numeric pad.

<div style="display:grid;gap:14px;max-width:340px"> <sui-input-otp label="Backup code" alphanumeric length="8" value="k3q9zt"></sui-input-otp> <sui-input-otp label="Recovery code" alphanumeric length="8" help="Paste the whole code into the first box."></sui-input-otp> </div>

Sizes

md is the default, as on every text field. lg widens the boxes and the digit for a sign-in page; sm shrinks them for a compact dialog or a side panel.

<div style="display:grid;gap:14px;max-width:340px"> <sui-input-otp label="Large" size="lg" length="4" value="4281"></sui-input-otp> <sui-input-otp label="Medium" size="md" length="4" value="4281"></sui-input-otp> <sui-input-otp label="Small" size="sm" length="4" value="4281"></sui-input-otp> </div>

Masked and states

masked renders password boxes. The error line replaces help and carries role="alert"; a disabled field posts nothing.

<div style="display:grid;gap:14px;max-width:340px"> <sui-input-otp label="PIN" masked length="4" value="4281"></sui-input-otp> <sui-input-otp label="Verification code" error="That code has expired."></sui-input-otp> <sui-input-otp label="Short code" length="4" disabled value="4281"></sui-input-otp> </div>

Read-only

readonly keeps the code readable and in the tab order, refuses typing, Backspace and paste, and still posts it.

<sui-input-otp label="Code on file" name="code-ro" value="482913" readonly></sui-input-otp>

In a form

The joined code posts under the host name. required holds the form while any box is empty, and Reset restores the code the field mounted with.

Enter six digits, then submit.
<form id="otp-form" style="display:grid;gap:14px;max-width:340px"> <sui-input-otp label="Verification code" name="code" required></sui-input-otp> <div style="display:flex;gap:8px"> <sui-button type="submit" variant="brand" label="Verify"></sui-button> <sui-button type="reset" variant="outline" label="Reset"></sui-button> </div> <output id="otp-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Enter six digits, then submit.</output> </form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#otp-form'); const out = root.querySelector('#otp-out'); form.addEventListener('submit', (e) => { e.preventDefault(); out.textContent = [...new FormData(form)].map(([k, v]) => `${k}=${v || '(empty)'}`).join(' '); }); form.addEventListener('reset', () => { out.textContent = 'Reset. Enter six digits, then submit.'; }); }

Events

suiInput fires on every edit with the code so far. suiChange waits for the commit, Enter or focus leaving the boxes, so a host that saves on it saves once. suiComplete fires when the last box fills, the moment to start the lookup. suiFocus and suiBlur mark focus entering and leaving the boxes, not a move between them.

Idle.
<div style="display:grid;gap:10px;max-width:340px"> <sui-input-otp id="otp-events" label="Verification code" name="code"></sui-input-otp> <span id="otp-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const log = root.querySelector('#otp-log'); const el = root.querySelector('#otp-events'); el.addEventListener('suiFocus', () => { log.textContent = 'suiFocus'; }); el.addEventListener('suiBlur', () => { log.textContent = 'suiBlur'; }); el.addEventListener('suiInput', (e) => { log.textContent = `suiInput ${e.detail || '(empty)'}`; }); el.addEventListener('suiChange', (e) => { log.textContent = `suiChange ${e.detail || '(empty)'}`; }); el.addEventListener('suiComplete', (e) => { log.textContent = `suiComplete ${e.detail}`; }); }

Keyboard

KeyAction
Any accepted characterFills the box, replacing the character it held, and moves to the next one.
Any other characterIgnored: the box keeps what it held.
BackspaceClears the box. On an empty box, clears the one before it and moves there.
ArrowLeft ArrowRightMoves to the previous or the next box. The boxes read left to right in either direction, as a number does.
Home EndMoves to the first or the last box.
EnterCommits the code, which is what suiChange reports.

API

<sui-input-otp>

Properties

PropertyAttributeTypeDefaultDescription
alphanumericalphanumericbooleanfalseAccept letters as well as digits, for alphanumeric backup and recovery codes (PrimeVue's integerOnly={false}). Off by default: an SMS or authenticator code is digits, and the boxes ask the phone keyboard for a numeric pad accordingly. Letters keep the case they are typed in.
disableddisabledbooleanfalseDisables every box.
errorerrorstringstring | undefinedError message; paints every box in the danger tone and sets aria-invalid.
helphelpstringstring | undefinedHelp text under the boxes. Replaced by error when set.
invalidinvalidbooleanfalseDanger borders without a message.
labellabelstringstring | undefinedGroup label. Without one the group takes the host's aria-label or aria-labelledby as its name, and "One-time code" without either; a visible label wins over both. Each box stays "Digit n".
lengthlengthnumber6Number of boxes.
maskedmaskedbooleanfalseRenders password boxes, for a PIN that should not be read over a shoulder.
namenamestringstring | undefinedForm control name. Reflected: form entry construction reads the content attribute, while all three framework bindings set properties, so an unreflected name posts nothing at all.
readOnlyreadonlybooleanfalseDisplay only: the code can be read and copied but not edited; the boxes still post it.
requiredrequiredbooleanfalseMarks the field required: shows the asterisk and sets valueMissing while any box is empty.
sizesize"lg" | "md" | "sm"'md'Box size: md 42 by 48 with an 18px digit (the default, as on every text field), sm 36 by 40 for a compact dialog, lg 48 by 56 with a 22px digit for a sign-in page.
valuevaluestring''The code typed so far. Mutable.

Events

EventDetailReactVueDescription
suiBlurvoidonSuiBlur@sui-blurFires when focus leaves the boxes, after the suiChange an edit owes; no detail. Does not bubble.
suiChangestringonSuiChange@sui-changeFires on commit (Enter, or focus leaving the boxes after an edit); detail is the code. The split matches sui-input-text and sui-input-mask, so a host that saves on suiChange saves once, not once per character.
suiCompletestringonSuiComplete@sui-completeFires when every box is filled; detail is the full code.
suiFocusvoidonSuiFocus@sui-focusFires when focus enters the boxes from outside them; no detail. A move from one box to the next fires nothing, so the boxes read as one field, the way suiChange treats them. Does not bubble.
suiInputstringonSuiInput@sui-inputFires on every edit; detail is the code typed so far.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the first empty box (or the last one when full).

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also