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.
gx add input-otpThe 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.
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>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},
}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;// 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 aLabel. - Set
Lengthto the length of the code that you send. - Use
Groupfor 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. |