Pin Input
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/pin-inputPlain-CSS theme — no build step
npx astrocraft-ui add forms/pin-input --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/pin-input --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/pin-input --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/pin-input --theme tailwind --bridge lumosLive demo
PinInput
Paste 123 456 into any box — spaces, dashes and newlines are stripped and the digits spread from where the paste landed. Backspace in an empty box clears the one before it. Only the first box carriesautocomplete="one-time-code", which is what makes a phone offer the SMS code exactly once.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `PinInput.astro` | `pin-input` `pin-input-value` | `data-mode`: `numeric` · `alphanumeric` `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | `data-value` | — |
| `PinInputSlot.astro` | `pin-input-slot` | `data-mode`: `numeric` · `alphanumeric` `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | — | — |
Source
What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/pin-input/PinInput.astro — headless primitive (see ../../README.md).
// The one-time-code field: N single-character boxes that behave like one input. Typing advances,
// Backspace on an empty box steps back and clears the one before it, ←/→ walk, and a pasted code is
// spread across the boxes from wherever it landed — including the code a phone autofills into the
// first box, which arrives as an ordinary paste of six characters.
//
// The value the form submits comes from a hidden input holding the joined code, so the boxes never
// have to be named `code-1`…`code-6` and reassembled on the server.
//
// All of this is behavior and none of it is looks: unstyled, it is N little text boxes that already
// work. The paste rule is in `pin-code.ts` and checked by `pin-code.test.ts`.
//
// ponytail: no per-box validation state — the field marks itself `data-state="error"` only if you
// pass it. Wire a failed verification by setting that attribute, the same way every other field
// here reports one.
import type { HTMLAttributes } from "astro/types";
import PinInputSlot from "./PinInputSlot.astro";
type Props = HTMLAttributes<"div"> & {
length?: number;
name?: string;
value?: string;
/** Which characters the boxes accept. Named `mode` to match PinInputSlot, where `type` is taken. */
mode?: "numeric" | "alphanumeric";
mask?: boolean;
/** Names the group, so the boxes are announced as one field. */
label?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const {
length = 6,
name,
value = "",
mode = "numeric",
mask = false,
label = "Verification code",
size = "md",
state = "default",
class: className,
...rest
} = Astro.props;
const characters = [...value];
---
<div
role="group"
aria-label={label}
data-slot="pin-input"
data-mode={mode}
data-value={value}
data-size={size}
data-state={state}
class={className}
{...rest}
>
{
Array.from({ length }, (_, i) => (
<PinInputSlot
index={i}
length={length}
mode={mode}
mask={mask}
size={size}
state={state}
value={characters[i] ?? ""}
/>
))
}
{name && <input type="hidden" name={name} value={value} data-slot="pin-input-value" />}
</div>
<script>
import { onReadyOnce } from "../../_once";
import { distribute } from "./pin-code";
function wire(root: HTMLElement) {
const boxes = [...root.querySelectorAll<HTMLInputElement>('[data-slot="pin-input-slot"]')];
if (boxes.length === 0) return;
const hidden = root.querySelector<HTMLInputElement>('[data-slot="pin-input-value"]');
const mode = root.dataset.mode === "alphanumeric" ? "alphanumeric" : "numeric";
const publish = () => {
const code = boxes.map((box) => box.value).join("");
root.dataset.value = code;
if (hidden) hidden.value = code;
root.dispatchEvent(new Event("change", { bubbles: true }));
};
/** Write `text` across the boxes from `start`, then park the caret after the last one filled. */
const fill = (text: string, start: number) => {
const chars = distribute(text, mode, boxes.length, start);
if (chars.length === 0) return;
chars.forEach((char, i) => (boxes[start + i].value = char));
boxes[Math.min(start + chars.length, boxes.length - 1)].focus();
publish();
};
boxes.forEach((box, i) => {
box.addEventListener("input", () => {
// More than one character means a paste or a platform autofill, not typing — the same
// distribution either way.
if (box.value.length > 1) {
const text = box.value;
box.value = "";
fill(text, i);
return;
}
if (box.value && !distribute(box.value, mode, 1).length) {
box.value = ""; // a character this field does not accept
return;
}
if (box.value) boxes[i + 1]?.focus();
publish();
});
box.addEventListener("keydown", (event) => {
if (event.key === "Backspace" && !box.value && i > 0) {
// Backspace in an empty box deletes the PREVIOUS character, which is what it looks like it
// should do: one press, one character gone, caret where you expect it.
event.preventDefault();
boxes[i - 1].value = "";
boxes[i - 1].focus();
publish();
return;
}
if (event.key === "ArrowLeft" && i > 0) {
event.preventDefault();
boxes[i - 1].focus();
}
if (event.key === "ArrowRight" && i < boxes.length - 1) {
event.preventDefault();
boxes[i + 1].focus();
}
});
box.addEventListener("paste", (event) => {
event.preventDefault();
fill(event.clipboardData?.getData("text") ?? "", i);
});
// Selecting the box's single character makes typing replace it rather than being ignored by
// `maxlength`, which is the difference between a field you can correct and one you cannot.
box.addEventListener("focus", () => box.select());
});
}
onReadyOnce('[data-slot="pin-input"]', wire);
</script>
---
// src/components/ui/forms/pin-input/PinInputSlot.astro — PinInput compound part (see ../../README.md).
// One box. PinInput renders `length` of these; this file exists so a consumer can lay out their own
// (a 3 + 3 split with a dash between, say) and still get the behavior, since PinInput's script wires
// whatever slots it finds inside it.
//
// Three attributes here are load-bearing and are what people leave off:
// • `autocomplete="one-time-code"` on the FIRST box only — that is what makes iOS and Android
// offer the SMS code. On every box, the browser offers it repeatedly and fills the wrong one.
// • `inputmode="numeric"` — the difference between a phone keyboard and a full one.
// • an `aria-label` per box, because "Digit 3 of 6" is the only way a screen reader user knows
// where they are; six inputs labelled "Code" are six identical announcements.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
/** 0-based position, used for the label and by the script to step between boxes. */
index: number;
length: number;
/** Which characters the box accepts. Named `mode` because `type` is the input's own attribute. */
mode?: "numeric" | "alphanumeric";
/** Hide the characters, for a PIN rather than a one-time code. */
mask?: boolean;
label?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const {
index,
length,
mode = "numeric",
mask = false,
label,
size = "md",
state = "default",
class: className,
...rest
} = Astro.props;
---
<input
type={mask ? "password" : "text"}
inputmode={mode === "numeric" ? "numeric" : "text"}
maxlength="1"
autocomplete={index === 0 ? "one-time-code" : "off"}
autocorrect="off"
spellcheck="false"
aria-label={label ?? `${mode === "numeric" ? "Digit" : "Character"} ${index + 1} of ${length}`}
data-slot="pin-input-slot"
data-index={index}
data-size={size}
data-state={state}
class={className}
{...rest}
/>
import PinInput from "./PinInput.astro";
import PinInputSlot from "./PinInputSlot.astro";
export { PinInput, PinInputSlot };
export default PinInput;
// src/components/ui/forms/pin-input/pin-code.ts — the paste rule behind PinInput, in a plain module so it
// is checkable without a DOM (see pin-code.test.ts).
//
// Pasting is the whole reason a one-time-code field is a component. Someone copies "123 456" out of
// a text message — with the space, sometimes with a dash, often with a trailing newline — and drops
// it on the third box. A naive implementation puts the entire string in that box, or writes "1" and
// throws the rest away. What has to happen is: keep the characters this field accepts, drop the
// rest, and spread them from where the paste landed.
/** Characters each `type` of pin accepts. Anything else in a pasted string is dropped. */
const ALLOWED: Readonly<Record<"numeric" | "alphanumeric", RegExp>> = {
numeric: /[0-9]/,
alphanumeric: /[0-9a-z]/i,
};
/**
* Spread `text` across the boxes of a pin field, starting at `from`.
*
* @param text - what was pasted, typed or autofilled
* @param type - which characters this field accepts
* @param length - how many boxes there are
* @param from - the box the paste landed on
* @returns one character per box to fill, in order, starting at `from` — never more than fit
* @example distribute("123 456", "numeric", 6, 0) // => ["1","2","3","4","5","6"]
* @example distribute("98", "numeric", 6, 4) // => ["9","8"]
*/
export function distribute(
text: string,
type: "numeric" | "alphanumeric",
length: number,
from = 0,
): string[] {
const allowed = ALLOWED[type] ?? ALLOWED.alphanumeric;
return [...text].filter((char) => allowed.test(char)).slice(0, Math.max(0, length - from));
}
What you get
The component source, copied into your project by npx astrocraft-ui add forms/pin-input — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.