Elements

OtpInput

A one-time-code input takes a fixed number of digits (a verification code from a text message, an authenticator’s code) and shows them in cells. It owns the behavior: one native text field, transparent and over the cells, takes the keyboard, so typing, input methods, paste, the number pad and the system’s code autofill (iOS’s “From Messages”, Android’s SMS suggestions) are the platform’s own, and screen readers find one text field. Digits only go in, up to length; Backspace removes the last one, and onComplete tells when the code is full. The look is the app’s: an OtpInput has no colors, sizes, borders or label of its own. Its parts are the cells, the groups they are split into and a caret, and the app styles each.

el-otp-input.tsx
el-otp-input.tsxLive
import { Clipboard, cleanup, mount, signal, Text, View } from "nsh";
import { Button, OtpInput, Switch } from "nsh/base";
import { button, buttonText, desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";

// The parts: geometry and motion in classes, colors from the palette. The
// cell that takes the next digit gets the keyboard's focus ring (`focus:`,
// the palette's `info` ring) while the code has the focus, and the caret
// blinks in it (steady with reduced motion: the input's own). Cells are
// 44 points on a touch screen, 36 with a pointer; a pressed or hovered
// button and the switches' rows take the palette's states.
const CODE = "246810";
const cell = desktop ? "w-9 h-11 text-lg" : "w-11 h-13 text-xl";
const cellClass = `${cell} items-center justify-center rounded-lg font-semibold text-foreground transition-colors motion-reduce:transition-none focus:outline-2 focus:outline-info focus:-outline-offset-2 disabled:opacity-40`;
const row = `-mx-2 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`;
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`;
const thumb = desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5";

function Option(props: { label: string; checked: boolean; onChange: (next: boolean) => void }) {
  return (
    <Switch
      checked={props.checked}
      onChange={props.onChange}
      class={row}
      hoverStyle={hoverFill()}
      focusStyle={focusRing()}
      trackClass={track}
      trackStyle={props.checked ? ui().accentFill : ui().inset}
      thumbClass={thumb}
      thumbStyle={props.checked ? { backgroundColor: ui().onAccent } : ui().raised}
    >
      <Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>{props.label}</Text>
    </Switch>
  );
}

function Action(props: { label: string; disabled?: boolean; onPress: () => void }) {
  return (
    <Button onPress={props.onPress} disabled={props.disabled} {...button("secondary", { style: desktop ? {} : { flex: 1 } })}>
      <Text style={buttonText()}>{props.label}</Text>
    </Button>
  );
}

function App() {
  const code = signal("");
  const masked = signal(false);
  const disabled = signal(false);
  const status = signal<"typing" | "checking" | "right" | "wrong">("typing");
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 6));
  // Checking the code takes a moment, as a server's answer does.
  let check: ReturnType<typeof setTimeout> | undefined;
  cleanup(() => clearTimeout(check));
  const verify = (value: string) => {
    status.set("checking");
    clearTimeout(check);
    check = setTimeout(() => status.set(value === CODE ? "right" : "wrong"), 600);
  };
  const message = () =>
    ({
      typing: { text: `${code().length} of 6 digits`, color: ui().muted },
      checking: { text: "Checking…", color: ui().muted },
      right: { text: "Verified", color: ui().up },
      wrong: { text: "That code is not right", color: ui().down },
    })[status()];

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>otp input</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click the code and type it, or paste it; Tab reaches it too" : "Tap the code to type it, or paste it"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value · masked · disabled">
              <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
                {code() ? `value “${masked() ? "•".repeat(code().length) : code()}”` : "value “” (empty)"}
              </Text>
              <Option
                label="Masked"
                checked={masked()}
                onChange={(next) => {
                  masked.set(next);
                  log(next ? "masked: bullets, a secure field" : "masked off");
                }}
              />
              <Option
                label="Disabled"
                checked={disabled()}
                onChange={(next) => {
                  disabled.set(next);
                  log(next ? "disabled: no focus" : "enabled");
                }}
              />
            </Panel>
            <Log title="onChange · onComplete · onFocus · onBlur" lines={events()} minHeight={120} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 20 : 16, gap: 16, alignItems: "center" }}>
          <View style={{ gap: 4, alignItems: "center" }}>
            <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Enter the code</Text>
            <Text style={{ fontSize: size.detail, color: ui().muted }}>{`We sent it to •••• 4821 (it is ${CODE})`}</Text>
          </View>
          <OtpInput
            length={6}
            groups={2}
            value={code()}
            masked={masked()}
            disabled={disabled()}
            onChange={(next) => {
              code.set(next);
              status.set("typing");
              log(`onChange “${next}”`);
            }}
            onComplete={({ value }) => {
              log(`onComplete “${value}”`);
              verify(value);
            }}
            onFocus={() => log("onFocus")}
            onBlur={() => log("onBlur")}
            accessibilityLabel="Verification code, 6 digits"
            class="flex-row items-center gap-3"
            groupClass="flex-row gap-1.5"
            cellClass={cellClass}
            cellStyle={ui().inset}
            caretClass="w-0.5 h-6 rounded-full"
            caretStyle={{ backgroundColor: ui().text }}
          />
          <Text style={{ fontSize: size.detail, color: message().color }}>{message().text}</Text>
          <View class={desktop ? "flex-row gap-2" : "flex-row gap-2 self-stretch"}>
            <Action
              label="Copy the code"
              onPress={() => {
                Clipboard.writeText(CODE);
                log("copied: paste it into the code");
              }}
            />
            <Action
              label="Clear"
              disabled={code() === ""}
              onPress={() => {
                code.set("");
                status.set("typing");
                log("value set to “”");
              }}
            />
          </View>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{OtpInputCompleteEvent, otp_input, theme};
use nsh_core::prelude::*;

fn verification(verify: impl Fn(&str) + 'static) -> impl IntoElement {
    let code = signal(SharedString::default());
    otp_input()
        .length(6)
        .groups(2)
        .value(move || Some(code.get()))
        .on_change(move |next: &SharedString| code.set(next.clone()))
        .on_complete(move |e: &OtpInputCompleteEvent| verify(&e.value))
        .accessibility_label("Verification code, 6 digits")
        .flex_row()
        .gap_3()
        .group_style(|s| s.flex_row().gap_2())
        .cell_style(|s| {
            s.w(px(40.0))
                .h(px(48.0))
                .items_center()
                .justify_center()
                .rounded(px(8.0))
                .border_1()
                .border_color(move || theme().input)
                .text_xl()
        })
        // The cell that takes the next digit, while the code has the focus.
        .cell_class("focus:border-ring")
        .caret_style(|s| s.w(px(2.0)).h(px(24.0)).bg(move || theme().foreground))
}

OtpInput comes from nsh/base, the nsh-base crate in Rust. In Rust, otp_input() makes one with a setter per prop (.length(..), .cell_style(..)), a listener method per event (.on_complete(..)) and the typed style methods for the input itself.

#The code

  • Controlled: pass value and set it in onChange. The cells show the value the app passes, as it is; a value the app sets (clearing the code after a wrong one) is never reported.
  • Uncontrolled: pass defaultValue (or nothing), and read the code from onChange and onComplete.

What the user does is read from the field, with the caret always at its end:

The userThe code
Types a digitIt is added at the end while the code is shorter than length; once full, nothing changes.
Types a full-width digit (0–9, an East Asian input method’s)The ASCII digit is added.
Types anything elseNothing changes; the field drops it.
Presses BackspaceThe last digit is removed.
Pastes, or the system autofills a codeIts digits are added the same way; text with length digits or more (123-456, a message’s code) replaces the code with its first length digits.

onChange reports each change with the new code, and onComplete follows it when the change filled the last cell, so the app verifies the code there.

#Props

PropRustTypeDefaultDescription
length.length(..)numberrequiredHow many digits the code has: one cell each.
value.value(..)stringnoneThe code, controlled by the app (shown as it is). Without it the input keeps its own.
defaultValue.default_value(..)stringnoneThe code at first, while value is not set.
masked.masked(..)booleanfalseFilled cells show a bullet instead of their digit, and the field is a secure one: screen readers do not read the code, and the system keeps it out of its suggestions.
disabled.disabled(..)booleanfalseNo focus and no editing; disabled: styles apply to the input and its parts, and screen readers say it is dimmed.
groups.groups(..)number1How many groups the cells are split into, as evenly as they go (3 + 3 for six digits in two); at least one, at most one per cell.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say for the field: what the code is for and how many digits it has.
cellClass, cellStyle.cell_class(..), .cell_style(|s| ..)string, StyleProp<ViewStyle>noneEach cell: its size, border and text.
groupClass, groupStyle.group_class(..), .group_style(|s| ..)string, StyleProp<ViewStyle>noneEach group of cells (flex-row gap-2).
caretClass, caretStyle.caret_class(..), .caret_style(|s| ..)string, StyleProp<ViewStyle>noneThe caret: its width, height and color.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe input itself: the row (or column) around its groups.

#Events

EventRustPayloadWhen
onChange.on_change(|value: &SharedString| ..)(value: string)The user changed the code: typed, deleted, pasted or autofilled.
onComplete.on_complete(|e: &OtpInputCompleteEvent| ..){ value }The user’s change filled the last cell, after its onChange.
onFocus / onBlur.on_focus / .on_blurnoneThe input took / lost the keyboard focus.

#Ref methods

MethodRustDescription
focus()focus() (OtpInputApi)Takes the keyboard focus and shows the software keyboard, as a press on the input does.

#Parts

PartInsideStyled with
The inputclass, style
A groupthe input, one per groupgroupClass, groupStyle; disabled: follows the input
A cellits group, one per digitcellClass, cellStyle; focus: on the cell that takes the next digit while the input has the focus, disabled: follows the input
The digita filled cellthe cell’s text styles (text-xl, font-semibold); • while masked
The caretthe empty cell that takes the next digit, while the input has the focuscaretClass, caretStyle

The cell that takes the next digit is the one after the last digit, or the last cell once the code is full. The caret blinks as a text field’s does (half a second on, half off, and steady for a moment after each edit); with reduced motion it stays still. A press anywhere on the input focuses it: the field lies over the cells.

#Accessibility

  • Screen readers find one text field, named by accessibilityLabel (say what the code is for and its length: “Verification code, 6 digits”) with the code as its value; the cells are hidden from them. While masked the field is a secure one, so the code is not read aloud.
  • The keyboard is the platform’s: the number pad on phones, any keyboard on desktops (digits only go in), and Backspace. Tab reaches the input as any text field.
  • While disabled it takes no focus, and screen readers say it is dimmed.

See Accessibility for labels, roles and announcements in general.

#Platform notes

  • iOS: the field is a code field (textContentType oneTimeCode), so the QuickType bar offers a code from Messages or Mail; the number pad comes up. A masked field starts over when typed in again after it lost the focus, as iOS’s secure fields do.
  • Android: autofillHints smsOTPCode lets the keyboard and the autofill service offer the code of a text message; the number keyboard comes up.
  • macOS, Windows, Linux and the web: typing and paste go to the field (in the browser, with inputmode="numeric" for a phone’s number keyboard), and the I-beam pointer shows over the cells.