Pin Input
A shadcn-style pin input component built with Ark UI primitives.
import {
PinInputControl,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputDemo = () => (
<PinInputRoot
className="w-full max-w-md grid place-items-center"
defaultValue={["1", "2"]}
>
<PinInputControl>
<PinInputSlots variant="grouped" />
</PinInputControl>
</PinInputRoot>
);
export default PinInputDemo;
Installation
npx shadcn@latest add @ark-cn/pin-inputInstall the dependency required by this primitive:
npm install @ark-ui/reactCopy the component source into your app:
TSXcomponents/ui/pin-input.tsx
"use client";
import { ark } from "@ark-ui/react/factory";
import {
PinInput as PinInputPrimitive,
usePinInput,
usePinInputContext,
} from "@ark-ui/react/pin-input";
import { type ComponentProps, Fragment } from "react";
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@/components/ui/button-group";
import { cn } from "@/lib/utils";
export { usePinInput, usePinInputContext };
export const PinInputRoot = ({
className,
count = 6,
children,
...props
}: PinInputPrimitive.RootProps) => (
<PinInputPrimitive.Root
className={cn("group/pin-input flex w-full flex-col gap-1.5", className)}
count={count}
data-slot="pin-input"
{...props}
>
{children}
<PinInputPrimitive.HiddenInput />
</PinInputPrimitive.Root>
);
export type PinInputLabelComponentProps = PinInputPrimitive.LabelProps;
export const PinInputLabel = ({
className,
...props
}: PinInputLabelComponentProps) => (
<PinInputPrimitive.Label
className={cn(
"text-sm font-medium text-foreground leading-none select-none",
"group-data-disabled/pin-input:pointer-events-none group-data-disabled/pin-input:opacity-64",
"group-data-invalid/pin-input:text-destructive",
"group-data-readonly/pin-input:text-muted-foreground",
className,
)}
data-slot="pin-input-label"
{...props}
/>
);
export const PinInputControl = ({
...props
}: PinInputPrimitive.ControlProps) => <PinInputPrimitive.Control {...props} />;
/** Shared focus / invalid / disabled / read-only (Ark sets data-* and aria-invalid on each cell). */
const pinInputStatesClassName = cn(
"outline-none ring-ring/24 transition-[border-color,box-shadow,color,opacity]",
"focus-visible:z-1 focus-visible:border-ring focus-visible:ring-3",
"aria-invalid:border-destructive/36 aria-invalid:focus-visible:border-destructive aria-invalid:focus-visible:ring-3 aria-invalid:focus-visible:ring-destructive/20",
"data-invalid:border-destructive/36 data-invalid:focus-visible:border-destructive data-invalid:focus-visible:ring-3 data-invalid:focus-visible:ring-destructive/20",
"dark:aria-invalid:border-destructive/50 dark:aria-invalid:focus-visible:ring-destructive/40",
"dark:data-invalid:border-destructive/50 dark:data-invalid:focus-visible:ring-destructive/40",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-64 disabled:focus-visible:ring-0 disabled:focus-visible:ring-offset-0",
"data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-64 data-disabled:focus-visible:ring-0 data-disabled:focus-visible:ring-offset-0",
"read-only:cursor-default read-only:bg-muted/40 read-only:text-muted-foreground dark:read-only:bg-muted/25",
);
export const PinInput = ({
className,
...props
}: PinInputPrimitive.InputProps) => (
<PinInputPrimitive.Input
className={cn(pinInputStatesClassName, className)}
data-slot="pin-slot"
{...props}
/>
);
export type PinInputRootProviderComponentProps =
PinInputPrimitive.RootProviderProps;
export const PinInputRootProvider = (
props: PinInputRootProviderComponentProps,
) => (
<PinInputPrimitive.RootProvider
data-slot="pin-input-root-provider"
{...props}
>
{props.children}
<PinInputPrimitive.HiddenInput />
</PinInputPrimitive.RootProvider>
);
export type PinInputContextComponentProps = PinInputPrimitive.ContextProps;
export const PinInputContext = (props: PinInputContextComponentProps) => (
<PinInputPrimitive.Context {...props} />
);
export type PinInputSeparatorProps = ComponentProps<typeof ark.span>;
export const PinInputSeparator = ({
className,
children,
...props
}: PinInputSeparatorProps) => (
<ark.span
aria-hidden
className={cn(
"select-none px-0.5 text-center font-medium text-muted-foreground text-sm",
className,
)}
data-slot="pin-input-separator"
{...props}
>
{children ?? "-"}
</ark.span>
);
/**
* Pin cells on the native input (not `Input` + asChild): `Input`’s wrapper is
* `inline-flex w-full`, which inside `ButtonGroup` (`w-fit`) collapses intrinsic width.
*/
const pinCellClass = (variant: "grouped" | "separated") =>
cn(
"box-border h-8 w-8 min-h-8 min-w-8 max-h-8 max-w-8 shrink-0 grow-0 basis-8 px-0 text-center tabular-nums text-sm font-medium leading-8 text-foreground",
"placeholder:text-muted-foreground/72",
variant === "separated" &&
"rounded-lg border border-input bg-background shadow-xs/5 not-dark:bg-clip-padding dark:bg-input/32",
variant === "grouped" &&
"rounded-lg border border-input bg-background not-dark:bg-clip-padding dark:bg-input/32",
);
export const PinInputSlots = ({
variant = "separated",
separatorBetweenCount,
}: {
variant: "grouped" | "separated";
separatorBetweenCount?: number;
}) => {
const { count } = usePinInputContext();
const separatorCount = separatorBetweenCount
? Math.floor(count / separatorBetweenCount)
: count;
const cell = (index: number) => (
<PinInput className={pinCellClass(variant)} index={index} key={index} />
);
if (variant === "grouped") {
if (separatorBetweenCount && separatorBetweenCount > 0) {
return (
<PinInputControl className="flex w-max max-w-full flex-wrap items-center gap-2">
{Array.from({ length: separatorCount }, (_, i) => (
<Fragment key={`g-${i}`}>
{i > 0 ? <PinInputSeparator /> : null}
<ButtonGroup>
{Array.from({ length: separatorBetweenCount }, (_, j) => {
const idx = j + i * separatorBetweenCount;
return (
<Fragment key={idx}>
{j > 0 ? <ButtonGroupSeparator /> : null}
{cell(idx)}
</Fragment>
);
})}
</ButtonGroup>
</Fragment>
))}
</PinInputControl>
);
}
return (
<PinInputControl asChild>
<ButtonGroup>
{Array.from({ length: count }, (_, i) => (
<Fragment key={i}>
{i > 0 ? <ButtonGroupSeparator /> : null}
{cell(i)}
</Fragment>
))}
</ButtonGroup>
</PinInputControl>
);
}
return (
<PinInputControl className="flex w-max max-w-full flex-wrap items-center gap-2">
{separatorBetweenCount && separatorBetweenCount > 0
? Array.from({ length: separatorCount }, (_, i) => (
<Fragment key={`s-${i}`}>
{i > 0 ? <PinInputSeparator /> : null}
{Array.from({ length: separatorBetweenCount }, (_, j) =>
cell(j + i * separatorBetweenCount),
)}
</Fragment>
))
: Array.from({ length: separatorCount }, (_, i) => cell(i))}
</PinInputControl>
);
};
Update import aliases to match your project setup.
Usage
import * as PinInput from "@/components/ui/pin-input"Read exported parts in src/components/ui/pin-input.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Separated
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputSeparated = () => (
<PinInputRoot
aria-label="Verification code"
count={6}
className="w-full max-w-md grid justify-center"
>
<PinInputLabel>Separated (default)</PinInputLabel>
<PinInputSlots separatorBetweenCount={3} variant="separated" />
</PinInputRoot>
);
export default PinInputSeparated;
Grouped
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputGrouped = () => (
<PinInputRoot
aria-label="Verification code"
count={6}
className="w-full max-w-md grid justify-center"
>
<PinInputLabel>Grouped</PinInputLabel>
<PinInputSlots variant="grouped" />
</PinInputRoot>
);
export default PinInputGrouped;
Grouped 3-3
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputGroupedThreeThree = () => (
<PinInputRoot
aria-label="Verification code"
count={6}
className="w-full max-w-md grid justify-center"
>
<PinInputLabel>Grouped 3-3</PinInputLabel>
<PinInputSlots separatorBetweenCount={3} variant="grouped" />
</PinInputRoot>
);
export default PinInputGroupedThreeThree;
Grouped 1-4-1
import { Fragment } from "react";
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@/components/ui/button-group";
import {
PinInput,
PinInputControl,
PinInputLabel,
PinInputRoot,
PinInputSeparator,
} from "@/components/ui/pin-input";
const groups = [1, 4, 1];
const PinInputGroupedOneFourOne = () => (
<PinInputRoot
aria-label="Verification code"
count={6}
className="w-full max-w-md grid justify-center"
>
<PinInputLabel>Grouped 1-4-1</PinInputLabel>
<PinInputControl className="flex w-max max-w-full flex-wrap items-center gap-2">
{
groups.reduce<{ elements: React.ReactNode[]; offset: number }>(
(acc, size, groupIndex) => {
acc.elements.push(
<Fragment key={`g-${groupIndex}`}>
{groupIndex > 0 ? <PinInputSeparator /> : null}
<ButtonGroup>
{Array.from({ length: size }, (_, j) => {
const idx = acc.offset + j;
return (
<Fragment key={idx}>
{j > 0 ? <ButtonGroupSeparator /> : null}
<PinInput
className="box-border h-8 w-8 min-h-8 min-w-8 max-h-8 max-w-8 shrink-0 grow-0 basis-8 px-0 text-center tabular-nums text-sm font-medium leading-8 text-foreground placeholder:text-muted-foreground/72 rounded-lg border border-input bg-background not-dark:bg-clip-padding dark:bg-input/32 outline-none ring-ring/24 transition-[border-color,box-shadow,color,opacity] focus-visible:z-1 focus-visible:border-ring focus-visible:ring-3"
index={idx}
/>
</Fragment>
);
})}
</ButtonGroup>
</Fragment>,
);
acc.offset += size;
return acc;
},
{ elements: [], offset: 0 },
).elements
}
</PinInputControl>
</PinInputRoot>
);
export default PinInputGroupedOneFourOne;
Grouped pairs 2-2-2
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputGroupedPairs = () => (
<PinInputRoot
aria-label="Verification code"
count={6}
className="w-full max-w-md grid justify-center"
>
<PinInputLabel>Pairs 2-2-2</PinInputLabel>
<PinInputSlots separatorBetweenCount={2} variant="grouped" />
</PinInputRoot>
);
export default PinInputGroupedPairs;
Placeholder
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputPlaceholder = () => (
<PinInputRoot
count={4}
placeholder="-"
className="w-full max-w-md grid justify-center"
>
<PinInputLabel>Custom placeholder</PinInputLabel>
<PinInputSlots variant="separated" />
</PinInputRoot>
);
export default PinInputPlaceholder;
Blur on complete
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputBlurOnComplete = () => (
<PinInputRoot
blurOnComplete
count={4}
className="w-full max-w-xs grid justify-center"
>
<PinInputLabel>Blur when complete</PinInputLabel>
<PinInputSlots variant="separated" />
</PinInputRoot>
);
export default PinInputBlurOnComplete;
OTP autocomplete
Sets one-time-code autocomplete on fields.
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputOtp = () => (
<div className="w-full max-w-md grid justify-center">
<PinInputRoot count={6} otp className="w-full max-w-md grid justify-center">
<PinInputLabel>OTP (autocomplete)</PinInputLabel>
<PinInputSlots variant="grouped" />
</PinInputRoot>
<p className="mt-2 text-muted-foreground text-xs">
Sets one-time-code autocomplete on fields.
</p>
</div>
);
export default PinInputOtp;
Masked
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputMask = () => (
<PinInputRoot count={4} mask className="w-full max-w-xs grid justify-center">
<PinInputLabel>Masked</PinInputLabel>
<PinInputSlots variant="separated" />
</PinInputRoot>
);
export default PinInputMask;
Root provider
usePinInput + PinInputRootProvider - focus from outside.
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
PinInputLabel,
PinInputRootProvider,
PinInputSlots,
usePinInput,
} from "@/components/ui/pin-input";
const PinInputRootProviderDemo = () => {
const [message, setMessage] = useState<string | null>(null);
const store = usePinInput({
count: 6,
onValueComplete: (details) =>
setMessage(`Complete: ${details.valueAsString}`),
});
return (
<PinInputRootProvider
className="max-w-md grid place-items-center"
value={store}
>
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">
usePinInput + PinInputRootProvider - focus from outside.
</p>
{message ? (
<p className="font-medium text-foreground text-xs">{message}</p>
) : null}
<PinInputLabel>Programmatic control</PinInputLabel>
<div className="flex flex-wrap items-center gap-2">
<PinInputSlots variant="grouped" />
<Button
onClick={() => store.focus()}
size="sm"
type="button"
variant="outline"
>
Focus
</Button>
</div>
</div>
</PinInputRootProvider>
);
};
export default PinInputRootProviderDemo;
With field
Field wires disabled/invalid/required into Ark Field context for the pin root.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { PinInputRoot, PinInputSlots } from "@/components/ui/pin-input";
const PinInputWithField = () => (
<Field className="max-w-md grid justify-center">
<FieldLabel>Code</FieldLabel>
<PinInputRoot count={6} className="w-full max-w-md">
<PinInputSlots variant="separated" />
</PinInputRoot>
<FieldDescription>
Field wires disabled/invalid/required into Ark Field context for the pin
root.
</FieldDescription>
</Field>
);
export default PinInputWithField;
Field invalid
Code does not match the expected pattern.
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { PinInputRoot, PinInputSlots } from "@/components/ui/pin-input";
const PinInputFieldInvalid = () => (
<Field className="max-w-md grid justify-center" invalid>
<FieldLabel>Invalid</FieldLabel>
<PinInputRoot count={4} invalid>
<PinInputSlots variant="separated" />
</PinInputRoot>
<FieldError>Code does not match the expected pattern.</FieldError>
</Field>
);
export default PinInputFieldInvalid;
Controlled
Value: -
import { useState } from "react";
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputControlled = () => {
const [value, setValue] = useState<string[]>(["", "", "", "", "", ""]);
const [complete, setComplete] = useState<string | null>(null);
return (
<div className="w-full max-w-md grid place-content-center gap-2">
<PinInputRoot
count={6}
onValueChange={(details) => {
setValue(details.value);
setComplete(null);
}}
onValueComplete={(details) => setComplete(details.valueAsString)}
value={value}
className="w-full max-w-md grid justify-center"
>
<PinInputLabel>Controlled</PinInputLabel>
<PinInputSlots variant="separated" />
</PinInputRoot>
<p className="text-muted-foreground text-xs">
Value:{" "}
<span className="font-mono text-foreground">
{value.join("") || "-"}
</span>
{complete ? (
<>
{" "}
- complete:{" "}
<span className="font-medium text-foreground">{complete}</span>
</>
) : null}
</p>
</div>
);
};
export default PinInputControlled;
Invalid entry
Try a letter - onValueInvalid: -
import { useState } from "react";
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputInvalidEntry = () => {
const [last, setLast] = useState<string | null>(null);
return (
<div className="w-full max-w-xs grid justify-center">
<PinInputRoot
count={4}
onValueInvalid={(details) =>
setLast(`${details.value} @ ${details.index}`)
}
type="numeric"
className=""
>
<PinInputLabel>Digits only</PinInputLabel>
<PinInputSlots variant="separated" />
</PinInputRoot>
<p className="mt-2 text-muted-foreground text-xs">
Try a letter - onValueInvalid:{" "}
<span className="font-mono text-foreground">{last ?? "-"}</span>
</p>
</div>
);
};
export default PinInputInvalidEntry;
Alphanumeric
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputAlphanumeric = () => (
<div className="w-full max-w-md grid justify-center">
<PinInputRoot count={6} type="alphanumeric">
<PinInputLabel>Alphanumeric</PinInputLabel>
<PinInputSlots variant="grouped" />
</PinInputRoot>
</div>
);
export default PinInputAlphanumeric;
Disabled
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputDisabled = () => (
<div className="w-full max-w-xs grid justify-center">
<PinInputRoot count={4} disabled>
<PinInputLabel>Disabled</PinInputLabel>
<PinInputSlots variant="separated" />
</PinInputRoot>
</div>
);
export default PinInputDisabled;
Read only
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputReadOnly = () => (
<div className="w-full max-w-xs grid justify-center">
<PinInputRoot count={4} defaultValue={["1", "2", "3", "4"]} readOnly>
<PinInputLabel>Read only</PinInputLabel>
<PinInputSlots variant="separated" />
</PinInputRoot>
</div>
);
export default PinInputReadOnly;
Context
Context: in progress - -
import {
PinInputContext,
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputContextDemo = () => (
<PinInputRoot className="max-w-md grid justify-center" count={6}>
<PinInputContext>
{(context) => (
<p className="text-muted-foreground text-xs">
Context:{" "}
<span className="font-medium text-foreground">
{context.complete ? "complete" : "in progress"}
</span>
{" - "}
<span className="font-mono">{context.valueAsString || "-"}</span>
</p>
)}
</PinInputContext>
<PinInputLabel>Render prop</PinInputLabel>
<PinInputSlots variant="grouped" />
</PinInputRoot>
);
export default PinInputContextDemo;
Four digits
import {
PinInputLabel,
PinInputRoot,
PinInputSlots,
} from "@/components/ui/pin-input";
const PinInputFourDigits = () => (
<div className="w-full max-w-xs grid justify-center">
<PinInputRoot count={4}>
<PinInputLabel>Four digits</PinInputLabel>
<PinInputSlots variant="grouped" />
</PinInputRoot>
</div>
);
export default PinInputFourDigits;
API reference
This component mirrors the upstream Ark UI primitive.
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification.