Inputs

Pin input

A row of single-character boxes that act as one field, for one-time codes, PINs and recovery codes.

Built on
<input inputmode>autocomplete="one-time-code"<input type="hidden">
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.4 kB
Dependencies
React

Sent to the phone ending 0412

Try its options

Loading its options.

Code
import { PinInput } from "@/components/equip/pin-input";

export default function Demo() {
  return <PinInput label="Verification code" defaultValue="382" groupAfter={3} hint="Sent to the phone ending 0412" />;
}
Your versionchange its options in Look, This part

Examples

Masked PIN

Masking only changes what is drawn; the digits are still in the page, so it is not a security boundary.

Code
import { PinInput } from "@/components/equip/pin-input";

export default function Example() {
  return <PinInput label="Unlock PIN" length={4} mask name="pin" defaultValue="70" />;
}

Error

That code has expired. Request a new one.

Code
import { PinInput } from "@/components/equip/pin-input";

export default function Example() {
  return (
    <PinInput
      label="Sign-in code"
      defaultValue="418203"
      groupAfter={3}
      error="That code has expired. Request a new one."
    />
  );
}

Recovery code

Paste the whole code into any box; spaces and dashes are dropped and onComplete fires once it is full.

Letters and numbers, from your saved recovery sheet.

Code
"use client";

import { useState } from "react";
import { PinInput } from "@/components/equip/pin-input";

export default function Example() {
  const [status, setStatus] = useState("Letters and numbers, from your saved recovery sheet.");
  return (
    <PinInput
      label="Recovery code"
      length={8}
      type="alphanumeric"
      groupAfter={4}
      hint={status}
      onValueChange={() => setStatus("Letters and numbers, from your saved recovery sheet.")}
      onComplete={(code) => setStatus(`Checking ${code.slice(0, 4)}-${code.slice(4)}`)}
    />
  );
}

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label for the whole code. Names the group.
lengthnumber6Number of boxes.
valuestringnoneControlled value.
defaultValuestring""Starting value when uncontrolled.
onValueChange(value: string) => voidnoneCalled on every change with the whole code.
onComplete(value: string) => voidnoneCalled once when the last box is filled.
type"numeric" | "alphanumeric""numeric"Accepted characters. Others are dropped from typing and paste. Also sets the mobile keyboard.
maskbooleanfalseDraws dots instead of the characters.
namestringnoneSubmits the code through a hidden input with this name.
hintReactNodenoneHelp text under the boxes, read as their description.
errorReactNodenoneError text. Marks every box invalid and turns its border red.
disabledbooleanfalseDisables every box and the hidden input.
groupAfternumbernoneAdds a visual gap after every this many boxes, e.g. 3 for 123 456.
...restOmit<ComponentProps<"div">, "defaultValue" | "onChange">noneEvery native div attribute, applied to the root group.

Anatomy

root.eq-pin-inputA div with role group, named by the label.
label.eq-pin-input-labelA label for the first box; clicking it starts the code.
boxes.eq-pin-input-boxes
box.eq-pin-input-boxOne real text input per character, named like Digit 2 of 6.
gap.eq-pin-input-gapThe short dash drawn between groups when groupAfter is set.
hint.eq-pin-input-hint
error.eq-pin-input-error

Keyboard

TabMoves into the code at the first empty box, and on out of the group.
0-9A-ZFills the box and moves to the next one.
BackspaceClears the box, or the one before it when the box is empty, and moves back.
DeleteClears the box and keeps focus there.
ArrowLeftArrowRightMoves between filled boxes and the first empty one.
HomeEndMoves to the first box, or the first empty one.
Cmd+VCtrl+VPastes from the focused box on, dropping spaces, dashes and other characters.

Notes

  • Boxes fill in order, so the value is always one unbroken string; clearing a middle box closes the gap.
  • The first box carries autocomplete="one-time-code", so codes from texts and email are offered on iOS, macOS and Android, and a whole code typed or autofilled into one box is spread across them.
  • Each box is a native input with its own name, such as Digit 2 of 6, and the hint and error are linked to every box.
  • Masking only changes how the characters are drawn; the value is still in the page, so it is not a security boundary.
  • Only boxes that were just typed into or pasted into land with the spring; nothing moves on first render.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/pin-input.tsx216 lines
"use client";

import { Fragment, useId, useRef, useState } from "react";
import type { ChangeEvent, ClipboardEvent, ComponentProps, FocusEvent, KeyboardEvent, ReactNode } from "react";
import "./pin-input.css";

/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

const allowed = { numeric: /^[0-9]$/, alphanumeric: /^[a-z0-9]$/i };

export type PinInputProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** Visible label for the whole code. */
  label: ReactNode;
  /** Number of boxes. */
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Called once when the last box is filled. */
  onComplete?: (value: string) => void;
  /** Which characters are accepted. Anything else is dropped from typing and paste. */
  type?: "numeric" | "alphanumeric";
  /** Shows dots instead of the characters. */
  mask?: boolean;
  /** Submits the code through a hidden input with this name. */
  name?: string;
  hint?: ReactNode;
  /** Error text. Marks every box invalid. */
  error?: ReactNode;
  disabled?: boolean;
  /** Puts a visual gap after every this many boxes, e.g. 3 for 123 456. */
  groupAfter?: number;
};

export function PinInput({
  label,
  length = 6,
  value,
  defaultValue = "",
  onValueChange,
  onComplete,
  type = "numeric",
  mask = false,
  name,
  hint,
  error,
  disabled = false,
  groupAfter,
  className,
  ...rest
}: PinInputProps) {
  const id = useId();
  const keep = (text: string) =>
    Array.from(text)
      .filter((c) => allowed[type].test(c))
      .join("");
  const [inner, setInner] = useState(() => keep(defaultValue).slice(0, length));
  const current = value === undefined ? inner : keep(value).slice(0, length);
  // Which boxes just received a character, so only they land. The flip restarts the animation.
  const [landed, setLanded] = useState<{ from: number; to: number; flip: boolean } | null>(null);
  const boxes = useRef<(HTMLInputElement | null)[]>([]);
  // Set while the part moves focus itself, before React has rendered the new value.
  const moving = useRef(false);

  const labelId = `${id}-label`;
  const hintId = present(hint) ? `${id}-hint` : undefined;
  const errorId = present(error) ? `${id}-error` : undefined;
  const described = [hintId, errorId].filter(Boolean).join(" ") || undefined;
  const noun = type === "numeric" ? "Digit" : "Character";

  const focus = (index: number) => {
    const box = boxes.current[Math.max(0, Math.min(index, length - 1))];
    moving.current = true;
    box?.focus();
    box?.select();
    moving.current = false;
  };

  const commit = (next: string) => {
    if (value === undefined) setInner(next);
    onValueChange?.(next);
    if (next.length === length && current.length < length) onComplete?.(next);
  };

  const fill = (index: number, text: string) => {
    const chars = keep(text);
    if (!chars) return;
    const at = Math.min(index, current.length);
    const added = chars.slice(0, length - at);
    commit((current.slice(0, at) + added + current.slice(at + added.length)).slice(0, length));
    setLanded((last) => ({ from: at, to: at + added.length, flip: !last?.flip }));
    focus(at + added.length);
  };

  const removeAt = (index: number) => {
    setLanded(null);
    commit(current.slice(0, index) + current.slice(index + 1));
  };

  const onChange = (index: number) => (event: ChangeEvent<HTMLInputElement>) => {
    const raw = event.target.value;
    const had = current[index];
    if (raw === "") {
      if (had) removeAt(index);
      return;
    }
    // The caret can sit beside the existing character; keep only what was just typed.
    const typed = had && Array.from(raw).length === 2 ? (raw.startsWith(had) ? raw.slice(had.length) : raw.slice(0, raw.length - had.length)) : raw;
    fill(index, typed);
  };

  const onKeyDown = (index: number) => (event: KeyboardEvent<HTMLInputElement>) => {
    switch (event.key) {
      case "Backspace":
        event.preventDefault();
        if (current[index]) removeAt(index);
        else if (index > 0) {
          removeAt(index - 1);
          focus(index - 1);
        }
        break;
      case "Delete":
        event.preventDefault();
        if (current[index]) removeAt(index);
        break;
      case "ArrowLeft":
        event.preventDefault();
        focus(index - 1);
        break;
      case "ArrowRight":
        event.preventDefault();
        focus(Math.min(index + 1, current.length));
        break;
      case "Home":
        event.preventDefault();
        focus(0);
        break;
      case "End":
        event.preventDefault();
        focus(current.length);
        break;
    }
  };

  // Boxes fill in order: focusing one past the first empty box moves to that empty box.
  const onFocus = (index: number) => (event: FocusEvent<HTMLInputElement>) => {
    if (!moving.current && index > current.length) focus(current.length);
    else event.currentTarget.select();
  };

  const onPaste = (index: number) => (event: ClipboardEvent<HTMLInputElement>) => {
    event.preventDefault();
    fill(index, event.clipboardData.getData("text"));
  };

  return (
    <div
      role="group"
      aria-labelledby={labelId}
      {...rest}
      className={["eq-pin-input", className].filter(Boolean).join(" ")}
    >
      <label className="eq-pin-input-label" id={labelId} htmlFor={`${id}-0`}>
        {label}
      </label>
      <div className="eq-pin-input-boxes">
        {Array.from({ length }, (_, index) => (
          <Fragment key={index}>
            {groupAfter != null && groupAfter > 0 && index > 0 && index % groupAfter === 0 && (
              <span className="eq-pin-input-gap" aria-hidden="true" />
            )}
            <input
              ref={(el) => {
                boxes.current[index] = el;
              }}
              id={`${id}-${index}`}
              className="eq-pin-input-box"
              type={mask ? "password" : "text"}
              inputMode={type === "numeric" ? "numeric" : "text"}
              autoComplete={index === 0 ? "one-time-code" : "off"}
              autoCapitalize="off"
              spellCheck={false}
              aria-label={`${noun} ${index + 1} of ${length}`}
              aria-describedby={described}
              aria-invalid={present(error) || undefined}
              disabled={disabled}
              // One tab stop: the first empty box, or the last box once the code is full.
              tabIndex={index === Math.min(current.length, length - 1) ? 0 : -1}
              value={current[index] ?? ""}
              data-filled={current[index] ? "" : undefined}
              data-landed={landed && index >= landed.from && index < landed.to ? (landed.flip ? "a" : "b") : undefined}
              onChange={onChange(index)}
              onKeyDown={onKeyDown(index)}
              onFocus={onFocus(index)}
              onPaste={onPaste(index)}
            />
          </Fragment>
        ))}
      </div>
      {name != null && <input type="hidden" name={name} value={current} disabled={disabled} />}
      {present(hint) && (
        <p className="eq-pin-input-hint" id={hintId}>
          {hint}
        </p>
      )}
      {present(error) && (
        <p className="eq-pin-input-error" id={errorId}>
          {error}
        </p>
      )}
    </div>
  );
}
components/equip/pin-input.css114 lines
@layer equip {
  .eq-pin-input {
    display: inline-flex;
    flex-direction: column;
    gap: var(--eq-space-1);
    min-width: 0;
    max-width: 100%;
    font-family: var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-pin-input-label {
    width: fit-content;
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-pin-input-boxes {
    display: flex;
    align-items: center;
    gap: var(--eq-space-1);
  }

  .eq-pin-input-box {
    box-sizing: border-box;
    /* A little narrower than tall, so six boxes and a gap fit a narrow column; in a column
       narrower still, the boxes give way down to one character and a little air. */
    flex: 0 1 auto;
    width: calc(var(--eq-h-m) - 4px);
    min-width: calc(1ch + 8px);
    height: var(--eq-h-m);
    margin: 0;
    padding: 0;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
    caret-color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-l) / 1 var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    text-align: center;
    appearance: none;
    transition:
      border-color var(--eq-dur) var(--eq-ease),
      background-color var(--eq-dur) var(--eq-ease);
  }

  .eq-pin-input-box[data-filled] {
    border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
  }

  /* Only boxes that were just typed or pasted into land; two names let a box land again. */
  .eq-pin-input-box[data-landed="a"] {
    animation: eq-pin-input-land-a var(--eq-dur-move) var(--eq-spring);
  }

  .eq-pin-input-box[data-landed="b"] {
    animation: eq-pin-input-land-b var(--eq-dur-move) var(--eq-spring);
  }

  @keyframes eq-pin-input-land-a {
    from {
      scale: 0.86;
    }
  }

  @keyframes eq-pin-input-land-b {
    from {
      scale: 0.86;
    }
  }

  @media (hover: hover) {
    .eq-pin-input-box:hover:where(:not(:disabled, [aria-invalid="true"])) {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 45%);
    }
  }

  .eq-pin-input-box:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-pin-input-box[aria-invalid="true"] {
    border-color: var(--eq-danger);
  }

  .eq-pin-input-box:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-pin-input-gap {
    flex: none;
    width: var(--eq-space-2);
    height: 2px;
    border-radius: var(--eq-radius-full);
    background: var(--eq-line-strong);
  }

  .eq-pin-input-hint,
  .eq-pin-input-error {
    margin: 0;
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-pin-input-hint {
    color: var(--eq-ink-2);
  }

  .eq-pin-input-error {
    color: var(--eq-danger);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index