Skip to content
v2.5.0
English
Русский
Documentation

PinField

PinField is a compound component for fixed-length codes such as OTPs and PINs.
pin-field.tsx · 9 lines
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
Tested in
34 tests

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

  • PinFieldProps
  • PinFieldInputProps
  • PinFieldSlotProps
  • PinFieldSeparatorProps
  • PinFieldSize

Minimal Composition

minimal-composition.tsx · 13 lines
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 container
  • PinFieldInput: single hidden backing input
  • PinFieldSlot: visible per-character slot

Optional Parts

  • PinFieldSeparator: visual grouping separator between slots

Composition Order

  1. PinField
  2. PinFieldInput
  3. one or more PinFieldSlot
  4. 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.
  • PinFieldInput is 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.

standard-4-digit-pin.tsx · 20 lines
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.

alphanumeric-activation-code.tsx · 29 lines
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.

clear-the-code-after-a-failed-check.tsx · 59 lines
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.

6-digit-otp.tsx · 40 lines
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.

grouped-code-with-separator.tsx · 22 lines
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.

wrong-verification-code.tsx · 57 lines
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.

locked-code-entry.tsx · 31 lines
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.

grouped-8-character-code.tsx · 37 lines
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.

rtl-layout.tsx · 19 lines
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

  1. PinField
  2. PinFieldInput
  3. PinFieldSlot items in index order
  4. optional separators between slot groups

Valid Composition Patterns

grouped-code-with-separator.tsx · 22 lines
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.