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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label for the whole code. Names the group. |
length | number | 6 | Number of boxes. |
value | string | none | Controlled value. |
defaultValue | string | "" | Starting value when uncontrolled. |
onValueChange | (value: string) => void | none | Called on every change with the whole code. |
onComplete | (value: string) => void | none | Called 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. |
mask | boolean | false | Draws dots instead of the characters. |
name | string | none | Submits the code through a hidden input with this name. |
hint | ReactNode | none | Help text under the boxes, read as their description. |
error | ReactNode | none | Error text. Marks every box invalid and turns its border red. |
disabled | boolean | false | Disables every box and the hidden input. |
groupAfter | number | none | Adds a visual gap after every this many boxes, e.g. 3 for 123 456. |
...rest | Omit<ComponentProps<"div">, "defaultValue" | "onChange"> | none | Every native div attribute, applied to the root group. |
Anatomy
| root | .eq-pin-input | A div with role group, named by the label. |
|---|---|---|
| label | .eq-pin-input-label | A label for the first box; clicking it starts the code. |
| boxes | .eq-pin-input-boxes | |
| box | .eq-pin-input-box | One real text input per character, named like Digit 2 of 6. |
| gap | .eq-pin-input-gap | The short dash drawn between groups when groupAfter is set. |
| hint | .eq-pin-input-hint | |
| error | .eq-pin-input-error |
Keyboard
| Tab | Moves into the code at the first empty box, and on out of the group. |
|---|---|
| 0-9A-Z | Fills the box and moves to the next one. |
| Backspace | Clears the box, or the one before it when the box is empty, and moves back. |
| Delete | Clears the box and keeps focus there. |
| ArrowLeftArrowRight | Moves between filled boxes and the first empty one. |
| HomeEnd | Moves to the first box, or the first empty one. |
| Cmd+VCtrl+V | Pastes 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);
}
}