Input OTP
ui-input-otp shows one native <input> per character of a verification code. Typing moves to the next box,
Backspace moves back, and pasting a whole code fills every box at once. Characters that do not match pattern are
ignored. The boxes use autocomplete="one-time-code", so browsers can offer a code received by SMS.
It is form-associated. The code is the element's value and is submitted under its name.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input OTP demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-otp.html"></import-element>
<ui-input-otp></ui-input-otp>
</body>
</html>Examples
Length and separator
length sets the number of boxes (1 to 12). separator adds a gap every that many boxes, for codes read in groups.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input OTP demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-otp.html"></import-element>
<ui-input-otp length="6" separator="3"></ui-input-otp>
<ui-input-otp length="4"></ui-input-otp>
</body>
</html>Pattern
pattern is a regular expression for a single character. The default accepts digits. This one accepts letters and
digits, for a recovery code.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input OTP demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-otp.html"></import-element>
<ui-input-otp length="8" separator="4" pattern="[A-Za-z0-9]"></ui-input-otp>
</body>
</html>Disabled
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input OTP demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-otp.html"></import-element>
<ui-input-otp value="1234" length="4" disabled></ui-input-otp>
</body>
</html>Complete event
complete fires once every box is filled, with the code in event.detail.value. change fires after every edit.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input OTP demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-otp.html"></import-element>
<import-element from="./input-otp-verify-demo.html"></import-element>
<input-otp-verify-demo></input-otp-verify-demo>
</body>
</html><custom-element name="input-otp-verify-demo">
<script>
const verify = useState({ status: "Enter the code. Try 424242." });
function complete(event) {
verify.status = event.detail.value === "424242" ? "Code verified." : "That code is not valid.";
}
</script>
<style>
:host {
display: grid;
gap: calc(var(--spacing) * 3);
justify-items: center;
}
p {
margin: 0;
font-size: var(--text-sm);
color: var(--muted-foreground);
}
</style>
<ui-input-otp length="6" separator="3" oncomplete="complete(event)"></ui-input-otp>
<p role="status"><slot name="#verify.status"></slot></p>
</custom-element>In a form
The whole code is submitted as one value, even while it is incomplete. Reset
restores the value attribute.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input OTP demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-otp.html"></import-element>
<import-element from="./input-otp-form-demo.html"></import-element>
<input-otp-form-demo></input-otp-form-demo>
</body>
</html><custom-element name="input-otp-form-demo">
<script>
const result = useState({ text: "Submit the form to see its data." });
function submit(event) {
event.preventDefault();
result.text = [...new FormData(event.target)].map(([name, value]) => `${name}=${value}`).join("\n");
}
function reset() {
result.text = "The form was reset.";
}
</script>
<style>
:host {
display: grid;
gap: calc(var(--spacing) * 4);
width: 100%;
max-width: calc(var(--spacing) * 80);
}
form {
display: grid;
gap: calc(var(--spacing) * 3);
justify-items: start;
}
.actions {
display: flex;
gap: calc(var(--spacing) * 2);
}
output {
display: block;
padding: calc(var(--spacing) * 3);
border-radius: var(--radius-md);
background: var(--muted);
font-family: var(--font-mono);
font-size: var(--text-xs);
white-space: pre-wrap;
}
</style>
<form onsubmit="submit(event)" onreset="reset()">
<ui-input-otp name="code" length="6" separator="3"></ui-input-otp>
<div class="actions">
<ui-button type="submit">Verify</ui-button>
<ui-button type="reset" variant="outline">Clear</ui-button>
</div>
</form>
<output><slot name="#result.text"></slot></output>
</custom-element>API
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value |
string | "" |
The code entered so far. The attribute is the initial and reset value. |
length |
number | 6 |
Number of boxes. An integer from 1 to 12. |
pattern |
string | "[0-9]" |
Regular expression that a single character must match. |
separator |
number | 0 |
Adds a gap before every box whose index is a multiple of this number. 0 means no gaps. |
disabled |
boolean | false |
Disables every box. Also true inside a disabled <fieldset>. |
required |
boolean | false |
The form is invalid until all boxes are filled. |
Properties
| Property | Type | Description |
|---|---|---|
value |
string | The current code. Setting it fills the boxes. |
Events
| Event | Detail | Description |
|---|---|---|
change |
none | Fired after each typed, pasted or deleted character. |
complete |
{ value: string } |
Fired when the code reaches length characters. |
complete is a custom event name. Handle it with oncomplete inside a component's markup, as in the example above,
or with addEventListener("complete", …).
CSS parts
| Part | Description |
|---|---|
group |
The row of boxes (role="group"). |
slot |
Each character box. Boxes that start a new group carry data-gap. |
Forms
ui-input-otp is form-associated and submits value under its name, even when it is incomplete. With required
it reports valueMissing with the message "Enter the full code." until all boxes are filled. The validation bubble
points at the first empty box, which takes focus. form.reset() restores the value attribute.
Accessibility
Each box is a native text input labelled "Character N of M" and uses inputmode="numeric", so phones show a number
pad. The boxes are wrapped in an element with role="group".
| Key | Action |
|---|---|
| Character | Fills the box and moves to the next one. |
| Backspace | Clears the box, or the previous box when this one is empty, and focuses it. |
| Arrow Left / Arrow Right | Moves to the previous or next box. |
| Paste | Fills the boxes from the focused one onward with the matching characters. |