Skip to content
Gx
GitHub

Input OTP

A one-time code input with one slot for each character.

<inputotp.InputOTP id="otp-empty" name="code" label="One-time code" />

Installation

Run the command in the app module.

Terminal
gx add input-otp

The command writes these files.

File Path in the app
InputOTP.gx ui/input-otp/InputOTP.gx
InputOTP.fixtures.go ui/input-otp/InputOTP.fixtures.go
InputOTPSlots.ts ui/input-otp/InputOTPSlots.ts
styles.go ui/input-otp/styles.go

Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.

ui/input-otp/InputOTP.gx
package inputotp

props {
  // Id is the id of the input. A label points at it.
  Id       string
  // Name is the form field name. The form sends the code under this name.
  Name     string = ""
  // Length is the count of characters of the code. A zero value is 6.
  Length   int = 6
  // Group is the count of slots before a separator. A zero value is no separator.
  Group    int = 0
  // Value is the code of the first render.
  Value    string = ""
  // Label is the accessible name of the input.
  Label    string = ""
  // Disabled disables the input.
  Disabled bool = false
  // Class adds classes to the root element.
  Class    string = ""
  // Attrs adds HTML attributes to the input.
  Attrs    gx.Attrs = nil
}

<div data-slot="input-otp" class={gx.Cx(rootClass, p.Class)}>
  <input id={p.Id} name={p.Name} value={p.Value} type="text" inputmode="numeric" pattern={p.pattern()} maxlength={p.length()} autocomplete="one-time-code" aria-label={p.Label} disabled={p.Disabled} class={inputClass} {...p.Attrs} />
  <InputOTPSlots input={p.Id} length={p.length()} group={p.Group} slotClass={slotClass} activeClass={activeClass} caretClass={caretClass} separatorClass={separatorClass} overlayClass={overlayClass} load="eager" />
</div>
ui/input-otp/InputOTP.fixtures.go
package inputotp

import "github.com/alternayte/gx"

var InputOTPFixtures = gx.Fixtures[InputOTPProps]{
	"Empty":    {Id: "otp-empty", Name: "code", Label: "One-time code"},
	"Filled":   {Id: "otp-filled", Name: "code", Label: "One-time code", Value: "123"},
	"Grouped":  {Id: "otp-grouped", Name: "code", Label: "One-time code", Group: 3},
	"Four":     {Id: "otp-four", Name: "pin", Label: "PIN", Length: 4},
	"Disabled": {Id: "otp-disabled", Name: "code", Label: "One-time code", Value: "12", Disabled: true},
}
ui/input-otp/InputOTPSlots.ts
import type { Mount } from "./InputOTPSlots.props";

// The island shows the value of one native input as a row of slots. The
// input stays the control: it takes the keys, the pointer and a paste, and
// a form sends its value. With no script the page shows the plain input.
const mount: Mount = (el, props, ctx) => {
  const input = document.getElementById(props.input);
  if (!(input instanceof HTMLInputElement)) {
    throw new Error(`no input with the id ${props.input}`);
  }
  const before = input.className;
  input.className = props.overlayClass;
  el.className = "flex items-center";
  el.setAttribute("aria-hidden", "true");

  const slots: HTMLElement[] = [];
  for (let i = 0; i < props.length; i++) {
    if (props.group > 0 && i > 0 && i % props.group === 0) {
      const separator = el.appendChild(document.createElement("div"));
      separator.className = props.separatorClass;
      separator.setAttribute("data-otp-separator", "");
    }
    const slot = el.appendChild(document.createElement("div"));
    slot.className = props.slotClass;
    slot.setAttribute("data-otp-slot", String(i));
    const first = i === 0 || (props.group > 0 && i % props.group === 0);
    const last = i === props.length - 1 || (props.group > 0 && (i + 1) % props.group === 0);
    slot.setAttribute("data-first", String(first));
    slot.setAttribute("data-last", String(last));
    slots.push(slot);
  }
  const active = props.activeClass.split(" ").filter(Boolean);

  const render = (): void => {
    // The code is digits only, also after a paste.
    const digits = input.value.replace(/\D/g, "").slice(0, props.length);
    if (digits !== input.value) input.value = digits;
    const focused = document.activeElement === input;
    // The active slot is the one at the caret, or the last one of a full
    // code.
    const caret = Math.min(input.selectionStart ?? digits.length, props.length - 1);
    slots.forEach((slot, i) => {
      const isActive = focused && i === caret;
      for (const name of active) slot.classList.toggle(name, isActive);
      slot.setAttribute("data-active", String(isActive));
      slot.replaceChildren();
      if (i < digits.length) {
        slot.textContent = digits[i];
      } else if (isActive) {
        slot.appendChild(document.createElement("div")).className = props.caretClass;
      }
    });
  };

  const options = { signal: ctx.abort };
  for (const type of ["input", "focus", "blur", "keyup", "click", "select"]) {
    input.addEventListener(type, render, options);
  }
  // The browser cuts a pasted text at maxlength before this code sees it,
  // so "12-34 56" would lose digits. The island takes the digits of the
  // whole text and puts them at the caret.
  input.addEventListener(
    "paste",
    (event) => {
      const text = event.clipboardData?.getData("text") ?? "";
      event.preventDefault();
      const start = input.selectionStart ?? input.value.length;
      const end = input.selectionEnd ?? start;
      const pasted = text.replace(/\D/g, "");
      const value = (input.value.slice(0, start) + pasted + input.value.slice(end)).slice(0, props.length);
      input.value = value;
      const caret = Math.min(start + pasted.length, value.length);
      input.setSelectionRange(caret, caret);
      input.dispatchEvent(new Event("input", { bubbles: true }));
    },
    options,
  );
  // A reset of the form changes the value with no input event.
  input.form?.addEventListener("reset", () => setTimeout(render), options);
  document.addEventListener("selectionchange", render, options);
  render();

  return () => {
    input.className = before;
    el.replaceChildren();
  };
};

export default mount;
ui/input-otp/styles.go
// Package inputotp is a one-time code input: one native input that the
// InputOTPSlots island shows as a row of slots.
package inputotp

import "strconv"

// InputOTPSlotsProps are the props of the island that draws the slots. The
// island reads the value and the caret of the input with the id Input.
type InputOTPSlotsProps struct {
	// Input is the id of the native input.
	Input string `json:"input"`
	// Length is the count of slots.
	Length int `json:"length"`
	// Group is the count of slots before a separator; 0 is no separator.
	Group int `json:"group"`
	// SlotClass, ActiveClass, CaretClass, SeparatorClass and OverlayClass
	// are the classes of the parts. They are Go constants, so the
	// stylesheet build sees them.
	SlotClass      string `json:"slotClass"`
	ActiveClass    string `json:"activeClass"`
	CaretClass     string `json:"caretClass"`
	SeparatorClass string `json:"separatorClass"`
	OverlayClass   string `json:"overlayClass"`
}

// rootClass is the classes of the root. The input and the slots share one
// box, so the island can put the input over the slots.
const rootClass = "relative inline-flex items-center gap-2 has-disabled:opacity-50"

// inputClass is the classes of the native input with no script: a plain
// input with wide letter spacing.
const inputClass = "h-9 w-48 rounded-md border border-input bg-transparent px-3 font-mono text-base tracking-[0.5em] shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed md:text-sm dark:bg-input/30"

// overlayClass replaces the look of the input when the island runs: the
// input covers the slots and shows nothing itself. It still takes the keys,
// the pointer and a paste.
const overlayClass = "absolute inset-0 z-10 h-full w-full cursor-text border-0 bg-transparent p-0 text-transparent caret-transparent opacity-0 shadow-none outline-none disabled:cursor-not-allowed"

// slotClass is the classes of one slot.
const slotClass = "relative -ml-px flex size-9 items-center justify-center border border-input bg-transparent font-mono text-sm shadow-xs transition-[color,box-shadow] first:ml-0 first:rounded-l-md last:rounded-r-md data-[first=true]:ml-0 data-[first=true]:rounded-l-md data-[last=true]:rounded-r-md dark:bg-input/30"

// activeClass is added to the slot that takes the next character while the
// input has the focus.
const activeClass = "z-10 border-ring ring-[3px] ring-ring/50"

// caretClass is the classes of the caret in an empty active slot.
const caretClass = "pointer-events-none h-4 w-px animate-pulse bg-foreground motion-reduce:animate-none"

// separatorClass is the classes of the separator between two groups.
const separatorClass = "mx-1 h-px w-3 bg-muted-foreground"

// length returns the count of characters; a zero value is 6.
func (p InputOTPProps) length() int {
	if p.Length <= 0 {
		return 6
	}
	return p.Length
}

// pattern returns the pattern attribute: exactly length digits.
func (p InputOTPProps) pattern() string {
	return "[0-9]{" + strconv.Itoa(p.length()) + "}"
}

The theme must define these tokens: --input, --ring, --foreground, --muted-foreground.

Usage

<label for="code">Verification code</label>
<inputotp.InputOTP id="code" name="code" />
<inputotp.InputOTP id="pin" name="pin" length={4} label="PIN" />
<inputotp.InputOTP id="code" name="code" group={3} label="Verification code" />

The component is one native <input> and an island that shows its value as slots. A form sends the value of the input. A paste and the code from a text message fill the slots. With no script the page shows the plain input.

Examples

Empty

<inputotp.InputOTP id="otp-empty" name="code" label="One-time code" />

Filled

<inputotp.InputOTP id="otp-filled" name="code" label="One-time code" value="123" />

Grouped

<inputotp.InputOTP id="otp-grouped" name="code" label="One-time code" group={3} />

Four

<inputotp.InputOTP id="otp-four" name="pin" label="PIN" length={4} />

Disabled

<inputotp.InputOTP id="otp-disabled" name="code" label="One-time code" value="12" disabled />

API reference

A tag sets a prop by its name with a lower-case first letter: Class is class.

inputotp.InputOTP

Prop Type Default Description
Id string Required Id is the id of the input. A label points at it.
Name string "" Name is the form field name. The form sends the code under this name.
Length int 6 Length is the count of characters of the code. A zero value is 6.
Group int 0 Group is the count of slots before a separator. A zero value is no separator.
Value string "" Value is the code of the first render.
Label string "" Label is the accessible name of the input.
Disabled bool false Disabled disables the input.
Class string "" Class adds classes to the root element.
Attrs gx.Attrs nil Attrs adds HTML attributes to the input.

Do and do not

Do

  • Give the input a <label> or a Label.
  • Set Length to the length of the code that you send.
  • Use Group for a long code, for example 3 for a code of 6 digits.

Don't

  • Do not use it for a password. Use an input of type password.
  • Do not use two components with one Id.

Keyboard

Key Action
Tab Moves focus to the input.
0 to 9 Types a digit into the active slot.
Backspace Removes the digit before the caret.
Arrow Left, Arrow Right Moves the caret.
Paste Fills the slots with the digits of the text.