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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/pin-input/PinInput.astro
---
import type { HTMLAttributes } from "astro/types";

import PinInputSlot from "./PinInputSlot.astro";

type Props = HTMLAttributes<"div"> & {
  length?: number;
  name?: string;
  value?: string;
  mode?: "numeric" | "alphanumeric";
  mask?: boolean;
  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 }));
    };

    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", () => {
        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 = "";
          return;
        }
        if (box.value) boxes[i + 1]?.focus();
        publish();
      });

      box.addEventListener("keydown", (event) => {
        if (event.key === "Backspace" && !box.value && i > 0) {
          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);
      });

      box.addEventListener("focus", () => box.select());
    });
  }

  onReadyOnce('[data-slot="pin-input"]', wire);
</script>
src/components/ui/forms/pin-input/PinInputSlot.astro
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & {
  index: number;
  length: number;
  mode?: "numeric" | "alphanumeric";
  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
const ALLOWED: Readonly<Record<"numeric" | "alphanumeric", RegExp>> = {
  numeric: /[0-9]/,
  alphanumeric: /[0-9a-z]/i,
};

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.