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.

Previewindex.html
<!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.

Previewindex.html
<!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.

Previewindex.html
<!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

Previewindex.html
<!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.

Previewindex.htmlinput-otp-verify-demo.html

<!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.

Previewindex.htmlinput-otp-form-demo.html
Verify Clear
<!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.

Edit this page on GitHub · View as Markdown