Skip to main content
astrocraft-ui/ components · 101

Pin Input

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add forms/pin-input

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/pin-input --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/pin-input --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/pin-input --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/pin-input --theme tailwind --bridge lumos

Live 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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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}
/>
src/components/ui/forms/pin-input/index.ts
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
// 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.