import { PinField, PinFieldInput, PinFieldSlot } from '@vega-ui/react';
<PinField maxLength={4} defaultValue='42'>
<PinFieldInput aria-label='Verification code' />
<PinFieldSlot index={0} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
</PinField>- Browser support
- Widely available
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari on iOS 15.4
- Bundle size
- Oklab and OkLCh — comes from the design tokens
- clip-path
- aspect-ratio
- :where()
Summary
PinField is a compound component for fixed-length codes such as OTPs and PINs. A visually hidden input stores the real value while visible slots render each character position, making segmented code entry the primary interaction model rather than a styling effect.
Imports
import {
PinField,
PinFieldInput,
PinFieldSeparator,
PinFieldSlot,
type PinFieldInputProps,
type PinFieldProps,
type PinFieldSeparatorProps,
type PinFieldSize,
type PinFieldSlotProps,
} from '@vega-ui/react';Exported Types
PinFieldPropsPinFieldInputPropsPinFieldSlotPropsPinFieldSeparatorPropsPinFieldSize
Minimal Composition
import { PinField, PinFieldInput, PinFieldSlot } from '@vega-ui/react';
export default function MinimalComposition() {
return (
<PinField maxLength={4}>
<PinFieldInput aria-label='Card PIN' autoComplete='off' />
<PinFieldSlot index={0} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
</PinField>
);Required Parts
PinField: root state containerPinFieldInput: single hidden backing inputPinFieldSlot: visible per-character slot
Optional Parts
PinFieldSeparator: visual grouping separator between slots
Composition Order
PinFieldPinFieldInput- one or more
PinFieldSlot - optional
PinFieldSeparator
Variants
- Size:
sm,md,lg - Input model: numeric, alphabetic, or alphanumeric mask
- Layout: continuous slots or grouped slots with separators
Edge Cases
- Paste behavior and caret synchronization are core to usability.
- Slot order must stay aligned with
maxLength. PinFieldInputis what participates in native form behavior and focus handling.- The backend code policy, allowed characters, and client-side mask should stay aligned instead of being inferred independently.
Common Mistakes
- Forgetting
PinFieldInput. - Rendering fewer or more slots than the intended code length supports.
- Using a mask that does not match the backend code format.
Examples
Basic
Basic: standard 4-digit PIN
The visible Label points at PinFieldInput, the single hidden input that holds the code; the slots only show its characters. The hint is linked with aria-describedby, and the placeholder fills empty slots.
import { HelperText, Label, PinField, PinFieldInput, PinFieldSlot } from '@vega-ui/react';
import { useId } from 'react';
export default function Standard4DigitPin() {
const id = useId();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
<Label htmlFor={id}>New card PIN</Label>
<PinField placeholder='••••' maxLength={4}>
<PinFieldInput id={id} name='pin' autoComplete='off' aria-describedby={`${id}-hint`} />
<PinFieldSlot index={0} />Controlled/Stateful
Controlled/Stateful: uppercase activation code
The mask accepts letters and digits, and the parent stores the code in capitals by changing it in onChangeValue. The readout counts the characters that are left.
import { Label, PinField, PinFieldInput, PinFieldSlot, Text } from '@vega-ui/react';
import { useId, useState } from 'react';
export default function AlphanumericActivationCode() {
const id = useId();
const [code, setCode] = useState('');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
<Label htmlFor={id}>Activation code from the box</Label>
<PinField
mask={/^[A-Za-z0-9]+$/}Controlled/Stateful: clear the code after a failed check
The parent checks the code as soon as the fourth digit arrives. A wrong code is cleared so the user can type again, the attempts counter goes down, and after the last attempt the field is disabled.
import { HelperText, Label, PinField, PinFieldInput, PinFieldSlot } from '@vega-ui/react';
import { useId, useState } from 'react';
const correctCode = '2468';
export default function ClearTheCodeAfterAFailedCheck() {
const id = useId();
const [code, setCode] = useState('');
const [attemptsLeft, setAttemptsLeft] = useState(3);
const [unlocked, setUnlocked] = useState(false);
const onChangeValue = (next: string) => {Form/Integration
Form/Integration: 6-digit OTP
PinFieldInput is a real input: name puts the code into FormData, required and pattern give native validation, autoComplete='one-time-code' lets the browser offer the code from an SMS, and a reset button clears it.
import { Button, Label, PinField, PinFieldInput, PinFieldSlot, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';
export default function Example6DigitOtp() {
const id = useId();
const [submitted, setSubmitted] = useState('');
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(String(new FormData(event.currentTarget).get('otp')));
};
Layout/Overlay
Layout/Overlay: grouped code with separator
Six slots split three and three, like an authenticator app shows the code. PinFieldSeparator is only visual: the value is still six digits without a dash.
import { Label, PinField, PinFieldInput, PinFieldSeparator, PinFieldSlot } from '@vega-ui/react';
import { useId } from 'react';
export default function GroupedCodeWithSeparator() {
const id = useId();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
<Label htmlFor={id}>Code from your authenticator app</Label>
<PinField maxLength={6}>
<PinFieldInput id={id} name='totp' autoComplete='one-time-code' />
<PinFieldSlot index={0} />Error
Error: wrong verification code
The form checks the code on submit: a short or wrong code sets error on the field and links the message to the input with aria-describedby. Any edit clears the message.
import { Button, HelperText, Label, PinField, PinFieldInput, PinFieldSlot, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';
export default function WrongVerificationCode() {
const id = useId();
const errorId = `${id}-error`;
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [verified, setVerified] = useState(false);
const onChangeValue = (next: string) => {
setCode(next);Disabled
Disabled: locked code entry
After too many attempts the field is disabled for 30 seconds and the countdown is linked to the input. disabled goes on PinField, which passes it to the input and the slots.
import { HelperText, Label, PinField, PinFieldInput, PinFieldSlot } from '@vega-ui/react';
import { useEffect, useId, useState } from 'react';
export default function LockedCodeEntry() {
const id = useId();
const [secondsLeft, setSecondsLeft] = useState(30);
const locked = secondsLeft > 0;
useEffect(() => {
if (!locked) return undefined;
const timer = setTimeout(() => setSecondsLeft((current) => current - 1), 1000);Edge
Edge: grouped 8-character code
An array mask sets a pattern per position: four letters, then four digits. A pasted code fills all slots at once, and the separator does not become part of the value.
import { Label, PinField, PinFieldInput, PinFieldSeparator, PinFieldSlot, Text } from '@vega-ui/react';
import { useId, useState } from 'react';
const letter = /[A-Za-z]/;
const digit = /\d/;
export default function Grouped8CharacterCode() {
const id = useId();
const [code, setCode] = useState('');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 340 }}>Edge: code inside a right-to-left page
Codes are read left to right in Arabic and Hebrew too, so the field gets dir='ltr' while the label stays right-to-left.
import { Label, PinField, PinFieldInput, PinFieldSlot } from '@vega-ui/react';
import { useId } from 'react';
export default function RtlLayout() {
const id = useId();
return (
<div dir='rtl' lang='ar' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', alignItems: 'flex-start', width: '100%', maxWidth: 320 }}>
<Label htmlFor={id}>رمز التحقق</Label>
<PinField dir='ltr' maxLength={4}>
<PinFieldInput id={id} autoComplete='one-time-code' />
<PinFieldSlot index={0} />Anatomy
Overview
PinField coordinates one visually hidden input with several visible slots. The root owns active index, value, placeholder state, and optional selection-all behavior.
Required Parts
PinField
Required. Owns root state including active slot and current value.
PinFieldInput
Required. Stores the real input value, handles selection and keyboard input, and keeps browser semantics intact.
PinFieldSlot
Required. Renders one visible character position for a specific index.
Optional Parts
PinFieldSeparator
Optional visual divider between logical groups of slots.
Composition Order
PinFieldPinFieldInputPinFieldSlotitems in index order- optional separators between slot groups
Valid Composition Patterns
import { Label, PinField, PinFieldInput, PinFieldSeparator, PinFieldSlot } from '@vega-ui/react';
import { useId } from 'react';
export default function GroupedCodeWithSeparator() {
const id = useId();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
<Label htmlFor={id}>Code from your authenticator app</Label>
<PinField maxLength={6}>
<PinFieldInput id={id} name='totp' autoComplete='one-time-code' />
<PinFieldSlot index={0} />PinFieldInput comes first and holds the whole code, its name and its label; slots only render one character each by index, and PinFieldSeparator between them is visual grouping only.
Invalid Composition Patterns
Slots without PinFieldInput
The UI renders, but the actual input model is missing.
Slot indexes out of order
Caret and value mapping become confusing.
Separators used as if they add input capacity
Separators are visual only and do not represent extra characters.