PIN Input
Selection & Inputimport { PinInput } from "bits-ui" Six-digit OTP
Numeric input, sanitizing paste and an observable completion callback share one invisible native input
Paste “482-901” or type six digits.
0 of 6 digits entered
<script lang="ts">
import { PinInput, REGEXP_ONLY_DIGITS, type PinInputRootSnippetProps } from 'bits-ui';
import { Check } from '@lucide/svelte';
type CellProps = PinInputRootSnippetProps['cells'][number];
let value = $state('');
let completed = $state('');
function handleComplete(completedValue: string) {
completed = completedValue;
}
</script>
<div class="grid gap-4">
<div>
<label class="text-sm font-medium" for="verification-code">Verification code</label>
<p class="muted mt-1 text-xs">Paste “482-901” or type six digits.</p>
</div>
<PinInput.Root
bind:value
inputId="verification-code"
name="verificationCode"
maxlength={6}
pattern={REGEXP_ONLY_DIGITS}
pasteTransformer={(text) => text.replace(/\D/g, '').slice(0, 6)}
onComplete={handleComplete}
onValueChange={(nextValue) => {
if (nextValue.length < 6) completed = '';
}}
class="group/pin flex items-center"
>
{#snippet children({ cells })}
<div class="flex">
{#each cells.slice(0, 3) as cell, index (index)}
{@render Cell(cell)}
{/each}
</div>
<div class="flex w-8 items-center justify-center" aria-hidden="true">
<span class="h-px w-3 bg-ink-600"></span>
</div>
<div class="flex">
{#each cells.slice(3, 6) as cell, index (index)}
{@render Cell(cell)}
{/each}
</div>
{/snippet}
</PinInput.Root>
{#if completed}
<div class="flex items-center gap-2 text-sm text-brand-400" aria-live="polite">
<span class="grid size-5 place-items-center rounded-full bg-brand-500/15">
<Check class="size-3.5" />
</span>
Completion callback received all six digits.
</div>
{:else}
<p class="muted text-xs" aria-live="polite">{value.length} of 6 digits entered</p>
{/if}
</div>
{#snippet Cell(cell: CellProps)}
<PinInput.Cell
{cell}
class="relative grid size-11 place-items-center border-y border-r border-ink-700 bg-ink-900 text-lg font-semibold text-ink-50 outline-none first:rounded-l-lg first:border-l last:rounded-r-lg data-active:z-10 data-active:border-brand-500 data-active:ring-1 data-active:ring-brand-500 group-hover/pin:border-ink-600"
>
{#if cell.char !== null && cell.char !== undefined}
<span>{cell.char}</span>
{/if}
{#if cell.hasFakeCaret}
<span class="pointer-events-none absolute h-5 w-px animate-pulse bg-brand-400"></span>
{/if}
</PinInput.Cell>
{/snippet}
Form submission
The visual cells submit through the named native input rather than concatenating six separate fields
<script lang="ts">
import { PinInput, REGEXP_ONLY_DIGITS, type PinInputRootSnippetProps } from 'bits-ui';
import { Check } from '@lucide/svelte';
type CellProps = PinInputRootSnippetProps['cells'][number];
let value = $state('');
let submittedCode = $state('');
function submitCode(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
event.preventDefault();
const data = new FormData(event.currentTarget);
submittedCode = String(data.get('approvalCode') ?? '');
}
</script>
<form class="grid w-full max-w-sm gap-4" onsubmit={submitCode}>
<div>
<label class="text-sm font-medium" for="approval-code">Approve sensitive change</label>
<p class="muted mt-1 text-xs">Enter the four-digit code from your authenticator.</p>
</div>
<PinInput.Root
bind:value
inputId="approval-code"
name="approvalCode"
maxlength={4}
pattern={REGEXP_ONLY_DIGITS}
class="group/pin flex items-center"
>
{#snippet children({ cells })}
{#each cells as cell, index (index)}
{@render Cell(cell)}
{/each}
{/snippet}
</PinInput.Root>
<div class="flex items-center gap-3">
<button class="btn btn-primary btn-sm" type="submit" disabled={value.length !== 4}>
Approve change
</button>
{#if submittedCode}
<span class="flex items-center gap-1.5 text-xs text-brand-400" aria-live="polite">
<Check class="size-3.5" /> Submitted as approvalCode
</span>
{/if}
</div>
</form>
{#snippet Cell(cell: CellProps)}
<PinInput.Cell
{cell}
class="relative grid size-12 place-items-center border-y border-r border-ink-700 bg-ink-900 text-xl font-semibold text-ink-50 outline-none first:rounded-l-lg first:border-l last:rounded-r-lg data-active:z-10 data-active:border-brand-500 data-active:ring-1 data-active:ring-brand-500 group-hover/pin:border-ink-600"
>
{#if cell.char !== null && cell.char !== undefined}
<span>{cell.char}</span>
{/if}
{#if cell.hasFakeCaret}
<span class="pointer-events-none absolute h-5 w-px animate-pulse bg-brand-400"></span>
{/if}
</PinInput.Cell>
{/snippet}
The root owns one string value and its children snippet supplies a cells array. Each cell reports char, isActive and hasFakeCaret; pass that object unchanged to PinInput.Cell. A pattern rejects invalid input, while pasteTransformer must return the sanitized text Bits UI should actually store.
The cells are presentation around an invisible native input. Set name to include its string value in form data, inputId to connect a label to that real input, and bind inputRef when imperative focus or selection access is needed. onComplete fires when maxlength cells are filled.