diff --git a/apps/docs/src/examples/pin-input.module.css b/apps/docs/src/examples/pin-input.module.css
new file mode 100644
index 00000000..952ff0b2
--- /dev/null
+++ b/apps/docs/src/examples/pin-input.module.css
@@ -0,0 +1,89 @@
+.pin-input {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.pin-input__label {
+ color: hsl(240 6% 10%);
+ font-size: 14px;
+ font-weight: 500;
+ user-select: none;
+}
+
+.pin-input__description {
+ color: hsl(240 5% 26%);
+ font-size: 12px;
+ user-select: none;
+}
+
+.pin-input__error-message {
+ color: hsl(0 72% 51%);
+ font-size: 12px;
+ user-select: none;
+}
+
+.pin-input__control {
+ display: flex;
+ gap: 8px;
+}
+
+.pin-input__input {
+ width: 40px;
+ height: 40px;
+ border-radius: 6px;
+ font-size: 16px;
+ outline: none;
+ background-color: white;
+ text-align: center;
+ border: 1px solid hsl(240 6% 90%);
+ color: hsl(240 4% 16%);
+ transition:
+ border-color 250ms,
+ color 250ms;
+}
+
+.pin-input__input:hover {
+ border-color: hsl(240 5% 65%);
+}
+
+.pin-input__input:focus {
+ outline: 2px solid hsl(200 98% 39%);
+ outline-offset: 2px;
+}
+
+.pin-input__input::placeholder {
+ color: hsl(240 4% 46%);
+}
+
+.pin-input__input[data-invalid] {
+ border-color: hsl(0 72% 51%);
+ color: hsl(0 72% 51%);
+}
+
+[data-kb-theme="dark"] .pin-input__label {
+ color: hsl(240 5% 84%);
+}
+
+[data-kb-theme="dark"] .pin-input__input {
+ background-color: hsl(240 4% 16%);
+ border: 1px solid hsl(240 5% 34%);
+ color: hsl(0 100% 100% / 0.9);
+}
+
+[data-kb-theme="dark"] .pin-input__input:hover {
+ border-color: hsl(240 4% 46%);
+}
+
+[data-kb-theme="dark"] .pin-input__input::placeholder {
+ color: hsl(0 100% 100% / 0.5);
+}
+
+[data-kb-theme="dark"] .pin-input__input[data-invalid] {
+ border-color: hsl(0 72% 51%);
+ color: hsl(0 72% 51%);
+}
+
+[data-kb-theme="dark"] .pin-input__description {
+ color: hsl(240 5% 65%);
+}
diff --git a/apps/docs/src/examples/pin-input.tsx b/apps/docs/src/examples/pin-input.tsx
new file mode 100644
index 00000000..b56d87ff
--- /dev/null
+++ b/apps/docs/src/examples/pin-input.tsx
@@ -0,0 +1,185 @@
+import { Index, createSignal } from "solid-js";
+import { PinInput } from "../../../../packages/core/src/pin-input";
+
+import style from "./pin-input.module.css";
+
+export function BasicExample() {
+ return (
+
+ PIN
+
+
+ {(_) => }
+
+
+
+ );
+}
+
+export function DefaultValueExample() {
+ return (
+
+
+
+ {(_) => }
+
+
+
+ );
+}
+
+export function ControlledExample() {
+ const [value, setValue] = createSignal([]);
+
+ return (
+ <>
+
+
+
+ {(_) => }
+
+
+
+
PIN code is: {value().join("")}
+ >
+ );
+}
+
+export function PlaceholderExample() {
+ return (
+
+
+
+ {(_) => }
+
+
+
+ );
+}
+
+export function BlurOnCompleteExample() {
+ return (
+
+
+
+ {(_) => }
+
+
+
+ );
+}
+
+export function OTPExample() {
+ return (
+
+
+
+ {(_) => }
+
+
+
+ );
+}
+
+export function MaskExample() {
+ return (
+
+
+
+ {(_) => }
+
+
+
+ );
+}
+
+export function TypeExample() {
+ return (
+
+
+
+ {(_) => }
+
+
+
+ );
+}
+
+export function DescriptionExample() {
+ return (
+
+ PIN
+
+
+ {(_) => }
+
+
+
+ Enter your 3 digit PIN code.
+
+
+ );
+}
+
+export function ErrorMessageExample() {
+ const [value, setValue] = createSignal([]);
+
+ return (
+ element === "9") ? "valid" : "invalid"
+ }
+ >
+ PIN
+
+
+ {(_) => }
+
+
+
+ Incorrect PIN. Please try again.
+
+
+ );
+}
+
+export function HTMLFormExample() {
+ let formRef: HTMLFormElement | undefined;
+
+ const onSubmit = (e: SubmitEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const formData = new FormData(formRef);
+
+ alert(JSON.stringify(Object.fromEntries(formData), null, 2));
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx
index cd2a5c19..9323c414 100644
--- a/apps/docs/src/routes/docs/core.tsx
+++ b/apps/docs/src/routes/docs/core.tsx
@@ -152,6 +152,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [
title: "Pagination",
href: "/docs/core/components/pagination",
},
+ {
+ title: "Pin Input",
+ href: "/docs/core/components/pin-input",
+ status: "new",
+ },
{
title: "Popover",
href: "/docs/core/components/popover",
diff --git a/apps/docs/src/routes/docs/core/components/pin-input.mdx b/apps/docs/src/routes/docs/core/components/pin-input.mdx
new file mode 100644
index 00000000..a3e3808f
--- /dev/null
+++ b/apps/docs/src/routes/docs/core/components/pin-input.mdx
@@ -0,0 +1,435 @@
+import { Preview, TabsSnippets, Kbd } from "../../../../components";
+import {
+ BasicExample,
+ DefaultValueExample,
+ ControlledExample,
+ PlaceholderExample,
+ BlurOnCompleteExample,
+ OTPExample,
+ MaskExample,
+ TypeExample,
+ DescriptionExample,
+ ErrorMessageExample,
+ HTMLFormExample,
+} from "../../../../examples/pin-input";
+
+# Pin Input
+
+The pin input is optimized for entering a sequence of digits or letters. The input fields allow one character at a time. When the digit or letter is entered, focus transfers to the next input in the sequence, until every input is filled.
+
+## Import
+
+```ts
+import { PinInput } from "@kobalte/core/pin-input";
+// or
+import { Root, Label, ... } from "@kobalte/core/pin-input";
+```
+
+## Features
+
+- Automatically focuses the next field on typing and focuses the previous field on deletion.
+- Supports numeric and alphanumeric values.
+- Support for masking value (for sensitive data).
+- Support for copy/paste to autofill all fields.
+- Supports fast paste SMS-code.
+
+## Anatomy
+
+The pin input consists of:
+
+- **PinInput**: The root container for the pin input.
+- **PinInput.Label**: The label that gives the user information on the pin input.
+- **PinInput.Control**: The container for the input fields.
+- **PinInput.Input**: The input fields of the pin input.
+- **PinInput.HiddenInput**: The native html input that is visually hidden in the pin input.
+- **PinInput.Description**: The description that gives the user more information on the pin input.
+- **PinInput.ErrorMessage**: The error message that gives the user information about how to fix a validation error on the pin input.
+
+```tsx
+
+
+
+
+
+
+
+
+
+```
+
+## Example
+
+
+
+
+
+
+
+ index.tsx
+ style.css
+
+ {/* */}
+
+ ```tsx
+ import { PinInput } from "@kobalte/core/pin-input";
+ import "./style.css";
+
+ function App() {
+ return (
+
+
+ PIN
+
+
+
+ {(_) => (
+
+ )}
+
+
+
+ );
+ }
+ ```
+
+
+
+```css
+.pin-input {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.pin-input\_\_label {
+color: hsl(240 6% 10%);
+font-size: 14px;
+font-weight: 500;
+user-select: none;
+}
+
+.pin-input\_\_control {
+display: flex;
+gap: 8px;
+}
+
+.pin-input\_\_input {
+width: 40px;
+height: 40px;
+border-radius: 6px;
+font-size: 16px;
+outline: none;
+background-color: white;
+text-align: center;
+border: 1px solid hsl(240 6% 90%);
+color: hsl(240 4% 16%);
+transition:
+border-color 250ms,
+color 250ms;
+}
+
+.pin-input\_\_input:hover {
+border-color: hsl(240 5% 65%);
+}
+
+.pin-input\_\_input:focus {
+outline: 2px solid hsl(200 98% 39%);
+outline-offset: 2px;
+}
+
+.pin-input\_\_input::placeholder {
+color: hsl(240 4% 46%);
+}
+
+````
+
+
+ {/* */}
+
+
+## Usage
+
+### Default value
+
+Set the initial value of the pin input using the `defaultValue` prop.
+
+
+
+
+
+```tsx {0}
+
+
+
+ {(_) => (
+
+ )}
+
+
+
+````
+
+### Controlled value
+
+The `value` prop can be used to make the pin input controlled. The `onChange` event is fired on input change.
+
+
+
+
+
+```tsx {3,7}
+import { createSignal } from "solid-js";
+
+function ControlledExample() {
+ const [value, setValue] = createSignal([]);
+
+ return (
+ <>
+
+
+ {_ => }
+
+
+ PIN code is: {value().join("")}
+ >
+ );
+}
+```
+
+### Placeholder
+
+Customize the default pin input placeholder for each input using the `placeholder` prop.
+
+
+
+
+
+```tsx{0}
+
+
+
+ {(_) => }
+
+
+
+```
+
+### Blur On Complete
+
+By default, the last input maintains focus when filled, and the `onComplete` callback is invoked. To blur the last input when the user completes the input, set the prop `blurOnComplete` to true.
+
+
+
+
+
+```tsx{0}
+
+
+
+ {(_) => }
+
+
+
+```
+
+### OTP
+
+Trigger smartphone OTP auto-suggestion by setting the `otp` prop to true.
+
+
+
+
+
+```tsx{0}
+
+
+
+ {(_) => }
+
+
+
+```
+
+### Mask
+
+When collecting private or sensitive information using the pin input, you might need to mask the value entered, similar to \\. Set the `mask` prop to true.
+
+
+
+
+
+```tsx{0}
+
+
+
+ {(_) => }
+
+
+
+```
+
+### Type
+
+By default, the pin input accepts only numeric values but you can choose between `numeric`, `alphanumeric` and `alphabetic` values. Use the `type` prop to change the input mode.
+
+
+
+
+
+```tsx{0}
+
+
+
+ {(_) => }
+
+
+
+```
+
+### Description
+
+The `PinInput.Description` component can be used to associate additional help text with the pin input.
+
+
+
+
+
+```tsx {7-9}
+
+ PIN
+
+ {_ => }
+
+ Enter your 3 digit PIN code.
+
+```
+
+### Error message
+
+The `PinInput.ErrorMessage` component can be used to help the user fix a validation error. It should be combined with the `validationState` prop to semantically mark the pin input as invalid for assistive technologies.
+
+By default, it will render only when the `validationState` prop is set to `invalid`, use the `forceMount` prop to always render the error message (ex: for usage with animation libraries).
+
+
+
+
+
+```tsx {9,19-21}
+import { createSignal } from "solid-js";
+
+function ErrorMessageExample() {
+ const [value, setValue] = createSignal([]);
+
+ return (
+ element === "9") ? "valid" : "invalid"}
+ >
+ PIN
+
+ {_ => }
+
+ Incorrect PIN. Please try again.
+
+ );
+}
+```
+
+### HTML forms
+
+The `name` prop can be used for integration with HTML forms.
+
+
+
+
+
+```tsx {7,13}
+function HTMLFormExample() {
+ const onSubmit = (e: SubmitEvent) => {
+ // handle form submission.
+ };
+
+ return (
+
+ );
+}
+```
+
+## API Reference
+
+### PinInput
+
+`PinInput` is equivalent to the `Root` import from `@kobalte/core/pin-input`.
+
+| Prop | Description |
+| :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------------- |
+| value | `string[]`
The value of the the pin input. |
+| defaultValue | `string[]`
The initial value of the pin input when it is first rendered. Use when you do not need to control the state of the pin input. |
+| onChange | `(value: string[]) => void`
Function called on input change. |
+| onComplete | `(value: string[]) => void`
Function called when all inputs have valid values. |
+| blurOnComplete | `boolean`
Whether to blur the input when the value is complete. |
+| mask | `boolean`
If `true`, the input's value will be masked just like `type=password`. |
+| otp | `boolean`
If `true`, the pin input component signals to its fields that they should use `autocomplete="one-time-code"`. |
+| pattern | `string`
The regular expression that the user-entered input value is checked against. |
+| placeholder | `string`
The placeholder text for the input. |
+| selectOnFocus | `boolean`
Whether to select input value when input is focused. |
+| type | `'numeric' \| 'alphabetic'` \| 'alphanumeric'`
The type of value the pin-input should allow. |
+| name | `string`
The name of the pin input. Submitted with its owning form as part of a name/value pair. |
+| validationState | `'valid' \| 'invalid'`
Whether the pin input should display its "valid" or "invalid" visual styling. |
+| required | `boolean`
Whether the pin input is required. |
+| disabled | `boolean`
Whether the pin input is disabled. |
+| readOnly | `boolean`
Whether the pin input is read only. |
+| translations | `PinInputIntlTranslations`
The localized strings of the component. |
+
+| Data attribute | Description |
+| :------------- | :----------------------------------------------------------------------- |
+| data-valid | Present when the pin input is valid according to the validation rules. |
+| data-invalid | Present when the pin input is invalid according to the validation rules. |
+| data-required | Present when the pin input is required. |
+| data-disabled | Present when the pin input is disabled. |
+| data-readonly | Present when the pin input is read only. |
+| data-complete | Present when the pin input value is complete. |
+
+`PinInput.Label`, `PinInput.Input`, `PinInput.Description` and `PinInput.ErrorMesssage` share the same data-attributes.
+
+### PinInput.ErrorMessage
+
+| Prop | Description |
+| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
+| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. |
+
+## Rendered elements
+
+| Component | Default rendered element |
+| :---------------------- | :----------------------- |
+| `PinInput` | `div` |
+| `PinInput.Label` | `span` |
+| `PinInput.Control` | `div` |
+| `PinInput.Input` | `input` |
+| `PinInput.HiddenInput` | `input` |
+| `PinInput.Description` | `div` |
+| `PinInput.ErrorMessage` | `div` |
+
+## Accessibility
+
+### Keyboard Interactions
+
+| Key | Description |
+| :--------------------- | :---------------------------------------------------------------------------- |
+| ArrowLeft | Moves focus to the previous input. |
+| ArrowRight | Moves focus to the next input. |
+| Backspace | Deletes the value in the current input and moves focus to the previous input. |
+| Delete | Deletes the value in the current input. |
+| Control + V | Pastes the value into the input fields. |
diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx
index f6a5ae0b..81a4e406 100644
--- a/packages/core/src/index.tsx
+++ b/packages/core/src/index.tsx
@@ -31,6 +31,7 @@ export * as Listbox from "./listbox";
export * as Menubar from "./menubar";
export * as NumberField from "./number-field";
export * as Pagination from "./pagination";
+export * as PinInput from "./pin-input";
export * as Popover from "./popover";
export * as Progress from "./progress";
export * as RadioGroup from "./radio-group";
diff --git a/packages/core/src/pin-input/index.tsx b/packages/core/src/pin-input/index.tsx
new file mode 100644
index 00000000..9d2ad55d
--- /dev/null
+++ b/packages/core/src/pin-input/index.tsx
@@ -0,0 +1,83 @@
+import {
+ FormControlDescription as Description,
+ FormControlErrorMessage as ErrorMessage,
+ type FormControlDescriptionCommonProps as PinInputDescriptionCommonProps,
+ type FormControlDescriptionOptions as PinInputDescriptionOptions,
+ type FormControlDescriptionProps as PinInputDescriptionProps,
+ type FormControlDescriptionRenderProps as PinInputDescriptionRenderProps,
+ type FormControlErrorMessageCommonProps as PinInputErrorMessageCommonProps,
+ type FormControlErrorMessageOptions as PinInputErrorMessageOptions,
+ type FormControlErrorMessageProps as PinInputErrorMessageProps,
+ type FormControlErrorMessageRenderProps as PinInputErrorMessageRenderProps,
+} from "../form-control";
+import {
+ PinInputControl as Control,
+ type PinInputControlCommonProps,
+ type PinInputControlOptions,
+ type PinInputControlProps,
+ type PinInputControlRenderProps,
+} from "./pin-input-control";
+import {
+ PinInputHiddenInput as HiddenInput,
+ type PinInputHiddenInputProps,
+} from "./pin-input-hidden-input";
+import {
+ PinInputInput as Input,
+ type PinInputInputCommonProps,
+ type PinInputInputOptions,
+ type PinInputInputProps,
+ type PinInputInputRenderProps,
+} from "./pin-input-input";
+import {
+ PinInputLabel as Label,
+ type PinInputLabelCommonProps,
+ type PinInputLabelOptions,
+ type PinInputLabelProps,
+ type PinInputLabelRenderProps,
+} from "./pin-input-label";
+import {
+ type PinInputRootCommonProps,
+ type PinInputRootOptions,
+ type PinInputRootProps,
+ type PinInputRootRenderProps,
+ PinInputRoot as Root,
+} from "./pin-input-root";
+
+export type {
+ PinInputControlCommonProps,
+ PinInputControlOptions,
+ PinInputControlProps,
+ PinInputControlRenderProps,
+ PinInputDescriptionOptions,
+ PinInputDescriptionCommonProps,
+ PinInputDescriptionRenderProps,
+ PinInputDescriptionProps,
+ PinInputErrorMessageOptions,
+ PinInputErrorMessageCommonProps,
+ PinInputErrorMessageRenderProps,
+ PinInputErrorMessageProps,
+ PinInputHiddenInputProps,
+ PinInputInputOptions,
+ PinInputInputCommonProps,
+ PinInputInputRenderProps,
+ PinInputInputProps,
+ PinInputLabelOptions,
+ PinInputLabelCommonProps,
+ PinInputLabelRenderProps,
+ PinInputLabelProps,
+ PinInputRootOptions,
+ PinInputRootCommonProps,
+ PinInputRootRenderProps,
+ PinInputRootProps,
+};
+
+export { Description, ErrorMessage, Control, HiddenInput, Input, Label, Root };
+
+export const PinInput = Object.assign(Root, {
+ Description,
+ ErrorMessage,
+ Control,
+ HiddenInput,
+ Input,
+ Label,
+});
diff --git a/packages/core/src/pin-input/pin-input-context.tsx b/packages/core/src/pin-input/pin-input-context.tsx
new file mode 100644
index 00000000..4c6e114c
--- /dev/null
+++ b/packages/core/src/pin-input/pin-input-context.tsx
@@ -0,0 +1,43 @@
+import {
+ type Accessor,
+ type Setter,
+ createContext,
+ useContext,
+} from "solid-js";
+import type { FormControlDataSet } from "../form-control";
+import type { CollectionItemWithRef } from "../primitives";
+import type { PinInputIntlTranslations } from "./pin-input.intl";
+
+export interface PinInputDataSet extends FormControlDataSet {
+ "data-complete": string | undefined;
+}
+
+export interface PinInputContextValue {
+ dataset: Accessor;
+ value: Accessor;
+ setValue: Setter;
+ mask: Accessor;
+ otp: Accessor;
+ pattern: Accessor;
+ placeholder: Accessor;
+ type: Accessor<"numeric" | "alphabetic" | "alphanumeric">;
+ focusedIndex: Accessor;
+ setFocusedIndex: Setter;
+ inputs: Accessor;
+ setInputs: Setter;
+ translations: Accessor;
+}
+
+export const PinInputContext = createContext();
+
+export function usePinInputContext() {
+ const context = useContext(PinInputContext);
+
+ if (context === undefined) {
+ throw new Error(
+ "[kobalte]: `usePinInputContext` must be used within a `PinInput` component",
+ );
+ }
+
+ return context;
+}
diff --git a/packages/core/src/pin-input/pin-input-control.tsx b/packages/core/src/pin-input/pin-input-control.tsx
new file mode 100644
index 00000000..cae35fa7
--- /dev/null
+++ b/packages/core/src/pin-input/pin-input-control.tsx
@@ -0,0 +1,47 @@
+import { mergeDefaultProps } from "@kobalte/utils";
+import type { ValidComponent } from "solid-js";
+import { useFormControlContext } from "../form-control";
+import {
+ type ElementOf,
+ Polymorphic,
+ type PolymorphicProps,
+} from "../polymorphic";
+
+export interface PinInputControlOptions {}
+
+export interface PinInputControlCommonProps<
+ T extends HTMLElement = HTMLElement,
+> {
+ id: string;
+}
+
+export interface PinInputControlRenderProps extends PinInputControlCommonProps {
+ role: "presentation";
+}
+
+export type PinInputControlProps<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = PinInputControlOptions & Partial>>;
+
+export function PinInputControl(
+ props: PolymorphicProps>,
+) {
+ const formControlContext = useFormControlContext();
+
+ const defaultId = `${formControlContext.generateId("control")}`;
+
+ const mergedProps = mergeDefaultProps(
+ {
+ id: defaultId,
+ },
+ props as PinInputControlProps,
+ );
+
+ return (
+
+ as="div"
+ role="presentation"
+ {...mergedProps}
+ />
+ );
+}
diff --git a/packages/core/src/pin-input/pin-input-hidden-input.tsx b/packages/core/src/pin-input/pin-input-hidden-input.tsx
new file mode 100644
index 00000000..c1642653
--- /dev/null
+++ b/packages/core/src/pin-input/pin-input-hidden-input.tsx
@@ -0,0 +1,28 @@
+import { visuallyHiddenStyles } from "@kobalte/utils";
+import type { ComponentProps } from "solid-js";
+
+import { useFormControlContext } from "../form-control";
+import { usePinInputContext } from "./pin-input-context";
+
+export interface PinInputHiddenInputProps extends ComponentProps<"input"> {}
+
+export function PinInputHiddenInput(props: PinInputHiddenInputProps) {
+ const formControlContext = useFormControlContext();
+ const context = usePinInputContext();
+
+ return (
+ // biome-ignore lint/a11y/noAriaHiddenOnFocusable: it is not focusable.
+
+ );
+}
diff --git a/packages/core/src/pin-input/pin-input-input.tsx b/packages/core/src/pin-input/pin-input-input.tsx
new file mode 100644
index 00000000..20c08ceb
--- /dev/null
+++ b/packages/core/src/pin-input/pin-input-input.tsx
@@ -0,0 +1,323 @@
+import {
+ EventKey,
+ callHandler,
+ mergeDefaultProps,
+ mergeRefs,
+} from "@kobalte/utils";
+import {
+ type JSX,
+ type ValidComponent,
+ batch,
+ createUniqueId,
+ splitProps,
+} from "solid-js";
+
+import {
+ FORM_CONTROL_FIELD_PROP_NAMES,
+ createFormControlField,
+ useFormControlContext,
+} from "../form-control";
+import {
+ type ElementOf,
+ Polymorphic,
+ type PolymorphicProps,
+} from "../polymorphic";
+import type { CollectionItemWithRef } from "../primitives";
+import { createDomCollectionItem } from "../primitives/create-dom-collection";
+import { type PinInputDataSet, usePinInputContext } from "./pin-input-context";
+
+export interface PinInputInputOptions {}
+
+export interface PinInputInputCommonProps<
+ T extends HTMLElement = HTMLInputElement,
+> {
+ id: string;
+ ref: T | ((el: T) => void);
+ onBeforeInput: JSX.EventHandlerUnion;
+ onInput: JSX.EventHandlerUnion;
+ onKeyDown: JSX.EventHandlerUnion;
+ onFocus: JSX.EventHandlerUnion;
+ onBlur: JSX.EventHandlerUnion;
+ "aria-label": string | undefined;
+ "aria-labelledby": string | undefined;
+ "aria-describedby": string | undefined;
+}
+
+export interface PinInputInputRenderProps
+ extends PinInputInputCommonProps,
+ PinInputDataSet {
+ type: string;
+ value: string;
+ inputMode: string;
+ placeholder: string;
+ autoCapitalize: "none";
+ autoComplete: string;
+ required: boolean | undefined;
+ disabled: boolean | undefined;
+ readonly: boolean | undefined;
+ "aria-invalid": boolean | undefined;
+ "aria-required": boolean | undefined;
+ "aria-disabled": boolean | undefined;
+ "aria-readonly": boolean | undefined;
+}
+
+export type PinInputInputProps<
+ T extends ValidComponent | HTMLElement = HTMLInputElement,
+> = PinInputInputOptions & Partial>>;
+
+export function PinInputInput(
+ props: PolymorphicProps>,
+) {
+ let ref: HTMLElement | undefined;
+
+ const context = usePinInputContext();
+ const formControlContext = useFormControlContext();
+
+ const mergedProps = mergeDefaultProps(
+ {
+ id: `${formControlContext.generateId("input")}-${createUniqueId()}`,
+ },
+ props as PinInputInputProps,
+ );
+
+ const [local, formControlFieldProps, others] = splitProps(
+ mergedProps,
+ ["ref", "onBeforeInput", "onInput", "onKeyDown", "onFocus", "onBlur"],
+ FORM_CONTROL_FIELD_PROP_NAMES,
+ );
+
+ const { fieldProps } = createFormControlField(formControlFieldProps);
+
+ createDomCollectionItem({
+ getItem: () => ({
+ ref: () => ref,
+ disabled: formControlContext.isDisabled()!,
+ key: fieldProps.id(),
+ textValue: "",
+ type: "item",
+ }),
+ });
+
+ const index = () =>
+ ref ? context.inputs().findIndex((v) => v.ref() === ref) : -1;
+
+ const inputType = () => (context.type() === "numeric" ? "tel" : "text");
+
+ const hasValue = () => context.value()[context.focusedIndex()] !== "";
+ const valueLength = () => context.value().length;
+ const filledValueLength = () =>
+ context.value().filter((v) => v?.trim() !== "").length;
+ const valueAsString = () => context.value().join("");
+ const focusedValue = () => context.value()[context.focusedIndex()] || "";
+ const isFinalValue = () =>
+ filledValueLength() + 1 === valueLength() &&
+ context.value().findIndex((v) => v.trim() === "") ===
+ context.focusedIndex();
+
+ const setFocusedValue = (value: string) => {
+ const nextValue = getNextValue(focusedValue(), value);
+ context.setValue((prev) => {
+ return [...replaceIndex(prev, context.focusedIndex(), nextValue)];
+ });
+ };
+ const setPrevFocusedIndex = () =>
+ context.setFocusedIndex(Math.max(context.focusedIndex() - 1, 0));
+ const setNextFocusedIndex = () =>
+ context.setFocusedIndex(
+ Math.min(context.focusedIndex() + 1, valueLength() - 1),
+ );
+ const clearFocusedValue = () =>
+ context.setValue((prev) => [
+ ...replaceIndex(prev, context.focusedIndex(), ""),
+ ]);
+
+ const ariaLabel = () =>
+ [
+ fieldProps.ariaLabel(),
+ context.translations().inputLabel(index(), valueLength()),
+ ]
+ .filter(Boolean)
+ .join(", ");
+
+ const isValidType = (value: string) => {
+ if (!context.type()) return true;
+ return !!REGEX[context.type()]?.test(value);
+ };
+
+ const isValidValue = (value: string) => {
+ if (!context.pattern()) return isValidType(value);
+ const regex = new RegExp(context.pattern()!, "g");
+ return regex.test(value);
+ };
+
+ const onBeforeInput: JSX.EventHandlerUnion = (
+ e,
+ ) => {
+ callHandler(e, local.onBeforeInput);
+ const target = e.currentTarget as HTMLInputElement;
+ try {
+ const value = getBeforeInputValue(e);
+ const isValid = isValidValue(value);
+ if (!isValid) {
+ e.preventDefault();
+ }
+ if (value.length > 2) {
+ target.setSelectionRange(0, 1, "forward");
+ }
+ } catch {}
+ };
+
+ const onInput: JSX.EventHandlerUnion = (e) => {
+ callHandler(e, local.onInput);
+ const target = e.currentTarget as HTMLInputElement;
+
+ if (e.inputType === "insertFromPaste" || target.value.length > 2) {
+ const startIndex = Math.min(context.focusedIndex(), filledValueLength());
+ const left =
+ startIndex > 0
+ ? valueAsString().substring(0, context.focusedIndex())
+ : "";
+ const right = target.value.substring(0, valueLength() - startIndex);
+ const value = left + right;
+ batch(() => {
+ context.setValue(value.split(""));
+ context.setFocusedIndex(
+ Math.min(filledValueLength(), valueLength() - 1),
+ );
+ });
+
+ target.value = target.value[0];
+ e.preventDefault();
+ return;
+ }
+
+ if (e.inputType === "deleteContentBackward") {
+ if (hasValue()) {
+ clearFocusedValue();
+ } else {
+ setPrevFocusedIndex();
+ clearFocusedValue();
+ }
+ return;
+ }
+ if (isFinalValue()) {
+ setFocusedValue(target.value);
+ } else {
+ batch(() => {
+ setFocusedValue(target.value);
+ setNextFocusedIndex();
+ });
+ }
+ target.value = target.value[0];
+ };
+
+ const onKeyDown: JSX.EventHandlerUnion = (e) => {
+ if (formControlContext.isDisabled() || formControlContext.isReadOnly())
+ return;
+ callHandler(e, local.onKeyDown);
+
+ switch (e.key) {
+ case EventKey.ArrowLeft:
+ e.preventDefault();
+ setPrevFocusedIndex();
+ break;
+ case EventKey.ArrowRight:
+ e.preventDefault();
+ setNextFocusedIndex();
+ break;
+ case "Delete":
+ e.preventDefault();
+ if (hasValue()) {
+ clearFocusedValue();
+ }
+ break;
+ case "Backspace":
+ e.preventDefault();
+ if (hasValue()) {
+ clearFocusedValue();
+ } else {
+ setPrevFocusedIndex();
+ clearFocusedValue();
+ }
+ break;
+ }
+ };
+
+ const onFocus: JSX.EventHandlerUnion = (e) => {
+ callHandler(e, local.onFocus);
+ context.setFocusedIndex(index());
+ };
+
+ const onBlur: JSX.EventHandlerUnion = (e) => {
+ callHandler(e, local.onBlur);
+ context.setFocusedIndex(-1);
+ };
+
+ return (
+
+ as="input"
+ id={fieldProps.id()}
+ ref={mergeRefs((el) => (ref = el), local.ref)}
+ type={context.mask() ? "password" : inputType()}
+ value={context.value()[index()] ?? ""}
+ inputMode={
+ context.otp() || context.type() === "numeric" ? "numeric" : "text"
+ }
+ placeholder={
+ context.focusedIndex() === index() ? "" : context.placeholder()
+ }
+ autoCapitalize="none"
+ autoComplete={context.otp() ? "one-time-code" : "off"}
+ required={formControlContext.isRequired()}
+ disabled={formControlContext.isDisabled()}
+ readonly={formControlContext.isReadOnly()}
+ aria-label={ariaLabel()}
+ aria-labelledby={fieldProps.ariaLabelledBy()}
+ aria-describedby={fieldProps.ariaDescribedBy()}
+ aria-invalid={
+ formControlContext.validationState() === "invalid" || undefined
+ }
+ aria-required={formControlContext.isRequired()}
+ aria-disabled={formControlContext.isDisabled()}
+ aria-readonly={formControlContext.isReadOnly()}
+ onBeforeInput={onBeforeInput}
+ onInput={onInput}
+ onKeyDown={onKeyDown}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ {...context.dataset()}
+ {...others}
+ />
+ );
+}
+
+const REGEX = {
+ numeric: /^[0-9]+$/,
+ alphabetic: /^[A-Za-z]+$/,
+ alphanumeric: /^[a-zA-Z0-9]+$/i,
+};
+
+function replaceIndex(array: T[], index: number, value: T) {
+ if (array[index] === value) {
+ return array;
+ }
+
+ return [...array.slice(0, index), value, ...array.slice(index + 1)];
+}
+
+function getBeforeInputValue(event: Pick) {
+ const { selectionStart, selectionEnd, value } =
+ event.currentTarget as HTMLInputElement;
+ return (
+ value.slice(0, selectionStart!) +
+ (event as any).data +
+ value.slice(selectionEnd!)
+ );
+}
+
+function getNextValue(current: string, next: string) {
+ let nextValue = next;
+ if (current[0] === next[0]) nextValue = next[1];
+ else if (current[0] === next[1]) nextValue = next[0];
+ return nextValue.split("")[nextValue.length - 1];
+}
diff --git a/packages/core/src/pin-input/pin-input-label.tsx b/packages/core/src/pin-input/pin-input-label.tsx
new file mode 100644
index 00000000..68cb7ca0
--- /dev/null
+++ b/packages/core/src/pin-input/pin-input-label.tsx
@@ -0,0 +1,27 @@
+import type { Component, ValidComponent } from "solid-js";
+
+import { FormControlLabel } from "../form-control";
+import type { ElementOf, PolymorphicProps } from "../polymorphic";
+
+export interface PinInputLabelOptions {}
+
+export interface PinInputLabelCommonProps<
+ T extends HTMLElement = HTMLElement,
+> {}
+
+export interface PinInputLabelRenderProps extends PinInputLabelCommonProps {}
+
+export type PinInputLabelProps<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = PinInputLabelOptions & Partial>>;
+
+export function PinInputLabel(
+ props: PolymorphicProps>,
+) {
+ return (
+ >
+ as="span"
+ {...(props as PinInputLabelProps)}
+ />
+ );
+}
diff --git a/packages/core/src/pin-input/pin-input-root.tsx b/packages/core/src/pin-input/pin-input-root.tsx
new file mode 100644
index 00000000..d15da37c
--- /dev/null
+++ b/packages/core/src/pin-input/pin-input-root.tsx
@@ -0,0 +1,290 @@
+import {
+ type ValidationState,
+ access,
+ mergeDefaultProps,
+ mergeRefs,
+} from "@kobalte/utils";
+import {
+ type Accessor,
+ type ValidComponent,
+ createEffect,
+ createSignal,
+ createUniqueId,
+ on,
+ splitProps,
+} from "solid-js";
+
+import {
+ FORM_CONTROL_PROP_NAMES,
+ FormControlContext,
+ type FormControlDataSet,
+ createFormControl,
+} from "../form-control";
+import {
+ type ElementOf,
+ Polymorphic,
+ type PolymorphicProps,
+} from "../polymorphic";
+import {
+ type CollectionItemWithRef,
+ createControllableArraySignal,
+ createFormResetListener,
+} from "../primitives";
+import { createDomCollection } from "../primitives/create-dom-collection";
+import {
+ PinInputContext,
+ type PinInputContextValue,
+ type PinInputDataSet,
+} from "./pin-input-context";
+import {
+ PIN_INPUT_INTL_TRANSLATIONS,
+ type PinInputIntlTranslations,
+} from "./pin-input.intl";
+
+export interface PinInputRootOptions {
+ /** The value of the the pin input. */
+ value?: string[];
+
+ /**
+ * The initial value of the pin input when it is first rendered.
+ * Use when you do not need to control the state of the pin input.
+ */
+ defaultValue?: string[];
+
+ /** Function called on input change. */
+ onChange?: (value: string[]) => void;
+
+ /** Function called when all inputs have valid values. */
+ onComplete?: (value: string[]) => void;
+
+ /** Whether to blur the input when the value is complete. */
+ blurOnComplete?: boolean;
+
+ /** If `true`, the input's value will be masked just like `type=password`. */
+ mask?: boolean;
+
+ /** If `true`, the pin input component signals to its fields that they should use `autocomplete="one-time-code"`. */
+ otp?: boolean;
+
+ /** The regular expression that the user-entered input value is checked against. */
+ pattern?: string;
+
+ /** The placeholder text for the input. */
+ placeholder?: string;
+
+ /** Whether to select input value when input is focused. */
+ selectOnFocus?: boolean;
+
+ /** The type of value the pin-input should allow. */
+ type?: "numeric" | "alphabetic" | "alphanumeric";
+
+ /**
+ * A unique identifier for the component.
+ * The id is used to generate id attributes for nested components.
+ * If no id prop is provided, a generated id will be used.
+ */
+ id?: string;
+
+ /**
+ * The name of the pin input.
+ * Submitted with its owning form as part of a name/value pair.
+ */
+ name?: string;
+
+ /** Whether the pin input should display its "valid" or "invalid" visual styling. */
+ validationState?: ValidationState;
+
+ /** Whether the pin input is required. */
+ required?: boolean;
+
+ /** Whether the pin input is disabled. */
+ disabled?: boolean;
+
+ /** Whether the pin input is read only. */
+ readOnly?: boolean;
+
+ /** The localized strings of the component. */
+ translations?: PinInputIntlTranslations;
+}
+
+export interface PinInputRootCommonProps {
+ id: string;
+ ref: T | ((el: T) => void);
+ "aria-labelledby": string | undefined;
+ "aria-describedby": string | undefined;
+ "aria-label"?: string;
+}
+
+export interface PinInputRootRenderProps
+ extends PinInputRootCommonProps,
+ FormControlDataSet {
+ role: "group";
+ "aria-invalid": boolean | undefined;
+ "aria-required": boolean | undefined;
+ "aria-disabled": boolean | undefined;
+ "aria-readonly": boolean | undefined;
+}
+
+export type PinInputRootProps<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = PinInputRootOptions & Partial>>;
+
+export function PinInputRoot(
+ props: PolymorphicProps>,
+) {
+ let ref: HTMLElement | undefined;
+
+ const defaultId = `pininput-${createUniqueId()}`;
+
+ const mergedProps = mergeDefaultProps(
+ {
+ id: defaultId,
+ otp: false,
+ placeholder: "○",
+ type: "numeric",
+ translations: PIN_INPUT_INTL_TRANSLATIONS,
+ },
+ props as PinInputRootProps,
+ );
+
+ const [local, formControlProps, others] = splitProps(
+ mergedProps,
+ [
+ "ref",
+ "value",
+ "defaultValue",
+ "onChange",
+ "onComplete",
+ "blurOnComplete",
+ "mask",
+ "otp",
+ "pattern",
+ "placeholder",
+ "selectOnFocus",
+ "type",
+ "aria-labelledby",
+ "aria-describedby",
+ "translations",
+ ],
+ FORM_CONTROL_PROP_NAMES,
+ );
+
+ const [inputs, setInputs] = createSignal([]);
+ const { DomCollectionProvider } = createDomCollection({
+ items: inputs,
+ onItemsChange: setInputs,
+ });
+
+ const [value, setValue] = createControllableArraySignal({
+ value: () => local.value,
+ defaultValue: () => local.defaultValue,
+ onChange: (value) => local.onChange?.(value),
+ });
+
+ const { formControlContext } = createFormControl(formControlProps);
+
+ createFormResetListener(
+ () => ref,
+ () => setValue(local.defaultValue ?? []),
+ );
+
+ const ariaLabelledBy = () => {
+ return formControlContext.getAriaLabelledBy(
+ access(formControlProps.id),
+ others["aria-label"],
+ local["aria-labelledby"],
+ );
+ };
+
+ const ariaDescribedBy = () => {
+ return formControlContext.getAriaDescribedBy(local["aria-describedby"]);
+ };
+
+ const [focusedIndex, setFocusedIndex] = createSignal(-1);
+ const isValueComplete = () =>
+ context.value().length === value().filter((v) => v?.trim() !== "").length;
+
+ createEffect(
+ on(
+ focusedIndex,
+ () => {
+ if (focusedIndex() === -1) return;
+ const inputEl = inputs()[focusedIndex()].ref() as HTMLInputElement;
+ inputEl.focus();
+ if (local.selectOnFocus) {
+ inputEl.select();
+ }
+ },
+ { defer: true },
+ ),
+ );
+
+ createEffect(
+ on(
+ isValueComplete,
+ () => {
+ if (!isValueComplete()) return;
+ local.onComplete?.(value());
+ if (local.blurOnComplete) {
+ (inputs()[focusedIndex()].ref() as HTMLInputElement).blur();
+ }
+ },
+ { defer: true },
+ ),
+ );
+
+ createEffect(() => {
+ if (value().length < inputs().length) {
+ const maxLength = Math.max(value().length, inputs().length);
+ setValue((prev) =>
+ (prev ?? []).concat(Array(maxLength - value().length).fill("")),
+ );
+ }
+ });
+
+ const dataset: Accessor = () => ({
+ ...formControlContext.dataset(),
+ "data-complete": isValueComplete() ? "" : undefined,
+ });
+
+ const context: PinInputContextValue = {
+ dataset,
+ value,
+ setValue,
+ mask: () => local.mask,
+ otp: () => local.otp,
+ placeholder: () => local.placeholder!,
+ pattern: () => local.pattern,
+ type: () => local.type!,
+ focusedIndex,
+ setFocusedIndex,
+ inputs,
+ setInputs,
+ translations: () => local.translations!,
+ };
+
+ return (
+
+
+
+
+ as="div"
+ ref={mergeRefs((el) => (ref = el), local.ref)}
+ role="group"
+ id={access(formControlProps.id)!}
+ aria-invalid={
+ formControlContext.validationState() === "invalid" || undefined
+ }
+ aria-required={formControlContext.isRequired() || undefined}
+ aria-disabled={formControlContext.isDisabled() || undefined}
+ aria-readonly={formControlContext.isReadOnly() || undefined}
+ aria-labelledby={ariaLabelledBy()}
+ aria-describedby={ariaDescribedBy()}
+ {...dataset()}
+ {...others}
+ />
+
+
+
+ );
+}
diff --git a/packages/core/src/pin-input/pin-input.intl.ts b/packages/core/src/pin-input/pin-input.intl.ts
new file mode 100644
index 00000000..c9a86b58
--- /dev/null
+++ b/packages/core/src/pin-input/pin-input.intl.ts
@@ -0,0 +1,6 @@
+export const PIN_INPUT_INTL_TRANSLATIONS = {
+ inputLabel: (index: number, length: number) =>
+ `pin code ${index + 1} of ${length}`,
+};
+
+export type PinInputIntlTranslations = typeof PIN_INPUT_INTL_TRANSLATIONS;