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>- Поддержка браузеров
- Широко доступно
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari на iOS 15.4
- Размер в бандле
- Протестирован в
- 34 теста · 6 нояб.
- История версий
- Добавлен в v1.0.0, последнее изменение в v3.0.0
- Oklab and OkLCh — приходит из дизайн-токенов
- clip-path
- aspect-ratio
- :where()
Кратко
PinField — составной компонент для кодов фиксированной длины, таких как одноразовые коды (OTP) и PIN. Визуально скрытый инпут хранит настоящее значение, а видимые слоты рендерят каждую позицию символа, поэтому сегментированный ввод кода — основная модель взаимодействия, а не стилевой эффект.
Импорт
import {
PinField,
PinFieldInput,
PinFieldSeparator,
PinFieldSlot,
type PinFieldInputProps,
type PinFieldProps,
type PinFieldSeparatorProps,
type PinFieldSize,
type PinFieldSlotProps,
} from '@vega-ui/react';Экспортируемые типы
PinFieldPropsPinFieldInputPropsPinFieldSlotPropsPinFieldSeparatorPropsPinFieldSize
Минимальная композиция
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>
);
}Обязательные части
PinField: корневой контейнер состоянияPinFieldInput: единственный скрытый инпут, хранящий значениеPinFieldSlot: видимый слот для одного символа
Необязательные части
PinFieldSeparator: визуальный разделитель групп слотов
Порядок композиции
PinFieldPinFieldInput- один или несколько
PinFieldSlot - необязательный
PinFieldSeparator
Варианты
- Размер:
sm,md,lg - Модель ввода: цифровая, буквенная или буквенно-цифровая маска
- Раскладка: сплошные слоты или группы слотов с разделителями
Пограничные случаи
- Поведение вставки и синхронизация каретки — основа удобства использования.
- Порядок слотов должен соответствовать
maxLength. - В нативном поведении формы и обработке фокуса участвует именно
PinFieldInput. - Правила кода на бэкенде, допустимые символы и клиентская маска должны быть согласованы, а не выводиться независимо друг от друга.
Частые ошибки
- Забытый
PinFieldInput. - Слотов меньше или больше, чем поддерживает задуманная длина кода.
- Маска, которая не соответствует формату кода на бэкенде.
Примеры
Базовый
Базовый: стандартный 4-значный PIN
Видимая Label ссылается на PinFieldInput — единственное скрытое поле, которое хранит код; слоты только показывают его символы. Подсказка связана через aria-describedby, а плейсхолдер заполняет пустые слоты.
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} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
</PinField>
<HelperText id={`${id}-hint`}>Avoid birthdays and repeated digits like 1111.</HelperText>
</div>
);
}Контролируемый/с состоянием
Контролируемый/с состоянием: код активации в верхнем регистре
Маска принимает буквы и цифры, а родитель хранит код заглавными, меняя его в onChangeValue. Подсказка считает, сколько символов осталось.
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]+$/}
maxLength={5}
value={code}
onChangeValue={(next) => setCode(next.toUpperCase())}
>
<PinFieldInput id={id} inputMode='text' autoCapitalize='characters' autoComplete='off' />
<PinFieldSlot index={0} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
<PinFieldSlot index={4} />
</PinField>
<Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
{code.length === 5 ? `Ready to activate ${code}.` : `${5 - code.length} characters left.`}
</Text>
</div>
);
}Контролируемый/с состоянием: очистка кода после неудачной проверки
Родитель проверяет код, как только введена четвёртая цифра. Неверный код очищается, чтобы можно было ввести его заново, счётчик попыток уменьшается, а после последней попытки поле отключается.
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) => {
if (next.length < 4) {
setCode(next);
return;
}
if (next === correctCode) {
setCode(next);
setUnlocked(true);
return;
}
setCode('');
setAttemptsLeft((current) => current - 1);
};
const failed = attemptsLeft < 3 && !unlocked;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
<Label htmlFor={id}>Enter your app passcode (try 2468)</Label>
<PinField
maxLength={4}
value={code}
onChangeValue={onChangeValue}
error={failed}
disabled={unlocked || attemptsLeft === 0}
>
<PinFieldInput
id={id}
autoComplete='off'
aria-invalid={failed}
aria-describedby={`${id}-status`}
/>
<PinFieldSlot index={0} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
</PinField>
<HelperText id={`${id}-status`} error={failed} role='status'>
{unlocked && 'Unlocked.'}
{!unlocked && attemptsLeft === 3 && 'Checked as soon as all four digits are in.'}
{!unlocked && attemptsLeft < 3 && attemptsLeft > 0 && `Wrong passcode. ${attemptsLeft} attempts left.`}
{!unlocked && attemptsLeft === 0 && 'Too many attempts. Sign in with your password instead.'}
</HelperText>
</div>
);
}Форма/интеграция
Форма/интеграция: 6-значный одноразовый код
PinFieldInput — настоящее поле ввода: name кладёт код в FormData, required и pattern дают нативную валидацию, autoComplete='one-time-code' позволяет браузеру подставить код из SMS, а кнопка сброса очищает его.
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')));
};
return (
<form
onSubmit={onSubmit}
onReset={() => setSubmitted('')}
style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}
>
<Label htmlFor={id}>Code from the SMS sent to +1 ••• ••• 0132</Label>
<PinField maxLength={6}>
<PinFieldInput id={id} name='otp' autoComplete='one-time-code' required pattern='\d{6}' />
<PinFieldSlot index={0} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
<PinFieldSlot index={4} />
<PinFieldSlot index={5} />
</PinField>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
<Button type='reset' variant='secondary' appearance='ghost'>Clear</Button>
<Button type='submit'>Verify</Button>
</div>
{submitted && (
<Text size={2} role='status' style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums', marginBlockStart: 'var(--spacing-4)' }}>
Submitted otp={submitted}
</Text>
)}
</form>
);
}Раскладка/оверлей
Раскладка/оверлей: сгруппированный код с разделителем
Шесть слотов разбиты по три, как код в приложении-аутентификаторе. PinFieldSeparator только визуальный: значение по-прежнему шесть цифр без дефиса.
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} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSeparator />
<PinFieldSlot index={3} />
<PinFieldSlot index={4} />
<PinFieldSlot index={5} />
</PinField>
</div>
);
}Ошибка
Ошибка: неверный код подтверждения
Форма проверяет код при отправке: короткий или неверный код ставит error на поле и связывает сообщение с полем ввода через aria-describedby. Любое изменение снимает сообщение.
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);
setError('');
};
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (code.length < 6) {
setError('Enter all 6 digits of the code.');
return;
}
if (code !== '135790') {
setError('This code is wrong or has expired. Check the latest email or request a new code.');
return;
}
setVerified(true);
};
return (
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
<Label htmlFor={id}>Code from the email (135790 passes)</Label>
<PinField maxLength={6} value={code} onChangeValue={onChangeValue} error={error !== ''}>
<PinFieldInput
id={id}
autoComplete='one-time-code'
aria-describedby={error === '' ? undefined : errorId}
/>
<PinFieldSlot index={0} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
<PinFieldSlot index={4} />
<PinFieldSlot index={5} />
</PinField>
{error !== '' && <HelperText id={errorId} error>{error}</HelperText>}
<Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Confirm email</Button>
{verified && (
<Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
Email confirmed.
</Text>
)}
</form>
);
}Отключённый
Отключённый: заблокированный ввод кода
После слишком многих попыток поле отключается на 30 секунд, а обратный отсчёт связан с полем ввода. disabled ставится на PinField, который передаёт его полю ввода и слотам.
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);
return () => clearTimeout(timer);
}, [locked, secondsLeft]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
<Label htmlFor={id}>Verification code</Label>
<PinField maxLength={4} disabled={locked}>
<PinFieldInput id={id} autoComplete='one-time-code' aria-describedby={`${id}-hint`} />
<PinFieldSlot index={0} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
</PinField>
<HelperText id={`${id}-hint`} style={{ fontVariantNumeric: 'tabular-nums' }}>
{locked ? `Too many attempts. Try again in ${secondsLeft} s.` : 'You can enter the code again.'}
</HelperText>
</div>
);
}Пограничный случай
Пограничный случай: сгруппированный 8-символьный код
Маска-массив задаёт шаблон для каждой позиции: четыре буквы, затем четыре цифры. Вставленный код заполняет все слоты сразу, а разделитель не попадает в значение.
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 }}>
<Label htmlFor={id}>Gift card code (4 letters, then 4 digits)</Label>
<PinField
size='sm'
maxLength={8}
mask={[letter, letter, letter, letter, digit, digit, digit, digit]}
value={code}
onChangeValue={(next) => setCode(next.toUpperCase())}
>
<PinFieldInput id={id} inputMode='text' autoCapitalize='characters' autoComplete='off' />
<PinFieldSlot index={0} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
<PinFieldSeparator />
<PinFieldSlot index={4} />
<PinFieldSlot index={5} />
<PinFieldSlot index={6} />
<PinFieldSlot index={7} />
</PinField>
<Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
{code.length === 8 ? `Redeem ${code.slice(0, 4)}-${code.slice(4)}` : 'Paste the whole code or type it.'}
</Text>
</div>
);
}Пограничный случай: код на странице справа налево
Коды и в арабском, и в иврите читаются слева направо, поэтому полю задан dir='ltr', а метка остаётся справа налево.
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} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSlot index={3} />
</PinField>
</div>
);
}Анатомия
Обзор
PinField координирует один визуально скрытый инпут с несколькими видимыми слотами. Корень отвечает за активный индекс, значение, состояние плейсхолдера и необязательное поведение «выделить всё».
Обязательные части
PinField
Обязательная. Владеет корневым состоянием, включая активный слот и текущее значение.
PinFieldInput
Обязательная. Хранит настоящее значение инпута, обрабатывает выделение и ввод с клавиатуры и сохраняет браузерную семантику.
PinFieldSlot
Обязательная. Рендерит одну видимую позицию символа для конкретного index.
Необязательные части
PinFieldSeparator
Необязательный визуальный разделитель между логическими группами слотов.
Порядок композиции
PinFieldPinFieldInput- элементы
PinFieldSlotв порядке индексов - необязательные разделители между группами слотов
Допустимые паттерны композиции
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} />
<PinFieldSlot index={1} />
<PinFieldSlot index={2} />
<PinFieldSeparator />
<PinFieldSlot index={3} />
<PinFieldSlot index={4} />
<PinFieldSlot index={5} />
</PinField>
</div>
);
}PinFieldInput идёт первым и хранит весь код, его имя и метку; слоты только рендерят по одному символу по index, а PinFieldSeparator между ними — лишь визуальная группировка.
Недопустимые паттерны композиции
Слоты без PinFieldInput
Интерфейс рендерится, но настоящей модели ввода нет.
Индексы слотов не по порядку
Каретка и соответствие значения слотам начинают сбивать с толку.
Разделители используются так, будто добавляют места для ввода
Разделители только визуальные и не обозначают дополнительных символов.