Svelte 5 component · fractalsvelte-ui · Forms
PinInput
A segmented one-time passcode and verification input supporting numeric or text entry. Features automatic next-box focus advance, backspace regression, clipboard paste distribution, and a bindable value.
Install
PinInput is a Svelte 5 pin input component in @fractaldesign/fractalsvelte-ui. It works in SvelteKit and in any Svelte 5 app.
pnpm add @fractaldesign/fractalsvelte-uiImport
import { PinInput } from '@fractaldesign/fractalsvelte-ui';Usage
<PinInput length={6} type={'numeric'} />Props
Also accepts every attribute of HTMLAttributes, spread onto
the root element.
| Name | Type | Default |
|---|---|---|
value bindable | string | '' |
length | number | 6 |
type | 'numeric' | 'text' | 'password' | 'numeric' |
mask | boolean | false |
label | string | — |
helperText | string | — |
error | boolean | string | — |
disabled | boolean | false |
size | Size 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' |
autofocus | boolean | false |
id | string | — |
class | string | '' |
Source
Show the PinInput.svelte source
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { Size } from '../../types';
interface Props extends HTMLAttributes<HTMLDivElement> {
value?: string;
length?: number;
type?: 'numeric' | 'text' | 'password';
mask?: boolean;
label?: string;
helperText?: string;
error?: boolean | string;
disabled?: boolean;
size?: Size;
autofocus?: boolean;
id?: string;
class?: string;
}
let {
value = $bindable(''),
length = 6,
type = 'numeric',
mask = false,
label,
helperText,
error,
disabled = false,
size = 'md',
autofocus = false,
id,
class: className = '',
...restProps
}: Props = $props();
let inputEls: HTMLInputElement[] = $state([]);
const autoId = 'f-pin-' + Math.random().toString(36).slice(2, 9);
const pinId = $derived(id || autoId);
const hasError = $derived(Boolean(error));
const errorMessage = $derived(typeof error === 'string' ? error : undefined);
// Digits array representing each cell
const digits = $derived(
Array.from({ length }, (_, i) => value[i] ?? '')
);
$effect(() => {
if (autofocus && inputEls[0]) {
inputEls[0].focus();
}
});
function updateDigits(newDigits: string[]) {
value = newDigits.join('').slice(0, length);
}
function handleInput(index: number, e: Event) {
const target = e.target as HTMLInputElement;
const char = target.value.slice(-1);
if (type === 'numeric' && char && !/^\d$/.test(char)) {
target.value = digits[index] || '';
return;
}
const newArr = [...digits];
newArr[index] = char;
updateDigits(newArr);
if (char && index < length - 1) {
inputEls[index + 1]?.focus();
inputEls[index + 1]?.select();
}
}
function handleKeyDown(index: number, e: KeyboardEvent) {
if (e.key === 'Backspace') {
if (!digits[index] && index > 0) {
e.preventDefault();
const newArr = [...digits];
newArr[index - 1] = '';
updateDigits(newArr);
inputEls[index - 1]?.focus();
} else {
const newArr = [...digits];
newArr[index] = '';
updateDigits(newArr);
}
} else if (e.key === 'ArrowLeft' && index > 0) {
e.preventDefault();
inputEls[index - 1]?.focus();
} else if (e.key === 'ArrowRight' && index < length - 1) {
e.preventDefault();
inputEls[index + 1]?.focus();
}
}
function handlePaste(e: ClipboardEvent) {
e.preventDefault();
const pasted = e.clipboardData?.getData('text') ?? '';
const filtered = type === 'numeric' ? pasted.replace(/\D/g, '') : pasted;
if (!filtered) return;
const chars = filtered.slice(0, length).split('');
const newArr = Array.from({ length }, (_, i) => chars[i] ?? '');
updateDigits(newArr);
const nextFocusIdx = Math.min(chars.length, length - 1);
inputEls[nextFocusIdx]?.focus();
}
</script>
<div
class="f-pin-container {className}"
data-size={size}
data-disabled={disabled || undefined}
data-invalid={hasError || undefined}
{...restProps}
>
{#if label}
<span class="f-pin-label">{label}</span>
{/if}
<div class="f-pin-boxes" onpaste={handlePaste}>
{#each Array.from({ length }) as _, index}
<input
bind:this={inputEls[index]}
type={mask || type === 'password' ? 'password' : 'text'}
inputmode={type === 'numeric' ? 'numeric' : 'text'}
pattern={type === 'numeric' ? '[0-9]*' : undefined}
maxlength="1"
value={digits[index]}
{disabled}
aria-label={`Digit ${index + 1} of ${length}`}
class="f-pin-box"
oninput={(e) => handleInput(index, e)}
onkeydown={(e) => handleKeyDown(index, e)}
onfocus={(e) => (e.target as HTMLInputElement).select()}
/>
{/each}
</div>
{#if errorMessage}
<p class="f-pin-error-msg" role="alert">{errorMessage}</p>
{:else if helperText}
<p class="f-pin-helper-msg">{helperText}</p>
{/if}
</div>
<style>
@layer fractalsvelte-ui.components {
.f-pin-container {
display: inline-flex;
flex-direction: column;
gap: var(--f-space-2xs);
font-family: inherit;
box-sizing: border-box;
}
.f-pin-label {
font-size: var(--f-text-sm);
font-weight: 500;
color: var(--f-text);
user-select: none;
}
.f-pin-boxes {
display: flex;
align-items: center;
gap: var(--f-space-xs);
}
.f-pin-box {
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
background: var(--f-input);
border: 1px solid var(--f-border);
border-radius: var(--f-radius-md);
color: var(--f-text);
font-family: var(--f-font-mono);
font-weight: 600;
outline: none;
box-sizing: border-box;
transition: border-color var(--f-duration-fast) var(--f-ease),
box-shadow var(--f-duration-fast) var(--f-ease);
}
.f-pin-box:hover:not(:disabled) {
border-color: var(--f-border-strong);
}
.f-pin-box:focus {
border-color: var(--f-primary);
box-shadow: 0 0 0 3px var(--f-ring);
}
.f-pin-box:disabled {
background: var(--f-sunken);
cursor: not-allowed;
opacity: 0.6;
}
/* Sizes */
.f-pin-container[data-size='sm'] .f-pin-box {
width: 32px;
height: 36px;
font-size: var(--f-text-sm);
border-radius: var(--f-radius-sm);
}
.f-pin-container[data-size='md'] .f-pin-box {
width: 42px;
height: 48px;
font-size: var(--f-text-lg);
border-radius: var(--f-radius-md);
}
.f-pin-container[data-size='lg'] .f-pin-box {
width: 52px;
height: 58px;
font-size: var(--f-text-xl);
border-radius: var(--f-radius-lg);
}
/* Invalid state */
.f-pin-container[data-invalid='true'] .f-pin-box {
border-color: var(--f-danger);
}
.f-pin-container[data-invalid='true'] .f-pin-box:focus {
box-shadow: 0 0 0 3px var(--f-danger-bg);
}
.f-pin-helper-msg {
margin: 0;
font-size: var(--f-text-xs);
color: var(--f-text-muted);
}
.f-pin-error-msg {
margin: 0;
font-size: var(--f-text-xs);
color: var(--f-danger-text);
font-weight: 500;
}
}
</style>Related components
Radio An individual selection control featuring a spring-animated inner indicator dot.RadioGroup A semantic radiogroup container that coordinates single-option selection among child radio items.RangeSlider A dual-thumb slider control designed for selecting lower and upper numeric boundaries.RotaryDial A precision hardware-grade rotational dial and knob control with 270-degree arc tracking, circular tick…SearchInput A specialized search field pre-configured with search iconography, quick clear action, and keyboard shortcut…Select A custom dropdown selection control featuring search filtering, clear actions, and complete keyboard…