Input OTP
One-time-code field on bits-ui PinInput. The active cell lifts into the focus ring and a caret blinks inside the empty slot — paste fills every cell in one move.
Usage
value = —
input-otp.svelte
<script lang="ts">
import * as InputOTP from '$lib/components/ui/input-otp';
let code = $state('');
</script>
<InputOTP.Root maxlength={6} bind:value={code}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell, i (i)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell, i (i)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>States
Disabled
Invalid
input-otp.svelte
<script lang="ts">
import { PinInput as InputOTPPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value = $bindable(''),
...restProps
}: InputOTPPrimitive.RootProps = $props();
</script>
<InputOTPPrimitive.Root
bind:ref
bind:value
data-slot="input-otp"
spellcheck={false}
class={cn(
'flex items-center gap-2 disabled:cursor-not-allowed has-disabled:opacity-50',
className
)}
{...restProps}
/>
Pattern
Pass a pattern prop to restrict which characters are accepted.
The example below allows digits only.
input-otp-pattern.svelte
<script lang="ts">
import * as InputOTP from '$lib/components/ui/input-otp';
</script>
<!-- pattern="^[0-9]+$" restricts input to digits only -->
<InputOTP.Root maxlength={6} pattern="^[0-9]+$">
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell, i (i)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>Separator
Use InputOTP.Separator between groups for visual grouping
— defaults to a dash icon.
input-otp-separator.svelte
<script lang="ts">
import * as InputOTP from '$lib/components/ui/input-otp';
</script>
<InputOTP.Root maxlength={6}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell, i (i)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell, i (i)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>Controlled
Bind external $state to read or reset the value from outside
the component.
value = —
input-otp-controlled.svelte
<script lang="ts">
import * as InputOTP from '$lib/components/ui/input-otp';
let controlled = $state('');
</script>
<InputOTP.Root maxlength={4} bind:value={controlled}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell, i (i)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
<p class="font-mono text-xs text-muted-foreground">value = {controlled || '—'}</p>Form
Compose InputOTP inside a Field for label + validation message — the invalid state propagates into each Slot's ring colour.
input-otp-form.svelte
<script lang="ts">
import * as InputOTP from '$lib/components/ui/input-otp';
import * as Field from '$lib/components/ui/field';
import { Button } from '$lib/components/ui/button';
let formCode = $state('');
let formError = $state('');
function submitForm() {
if (formCode.length < 4) {
formError = 'Enter all 4 digits.';
} else {
formError = '';
}
}
</script>
<form onsubmit={(e) => { e.preventDefault(); submitForm(); }} class="flex flex-col gap-4">
<Field.Field data-invalid={formError ? true : undefined}>
<Field.Label for="otp-form">Verification code</Field.Label>
<InputOTP.Root
id="otp-form"
maxlength={4}
bind:value={formCode}
aria-invalid={!!formError || undefined}
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell, i (i)}
<InputOTP.Slot {cell} aria-invalid={!!formError || undefined} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
{#if formError}
<Field.Error>{formError}</Field.Error>
{/if}
</Field.Field>
<Button type="submit" size="sm" class="w-fit">Verify</Button>
</form>API reference
Inherits bits-ui PinInput.Root props via spread. Children are a snippet of cell descriptors — compose with Group, Slot, and Separator.
| Prop | Type | Default | Description |
|---|---|---|---|
string (bindable) | '' | Concatenated code across cells. Two-way bindable. | |
number | — | Total digits in the code. Split across groups as needed. | |
(value: string) => void | — | Fires when every cell is filled. | |
boolean | false | Disables every cell and drops opacity to 50%. | |
HTMLInputElement | null | null | Two-way-bindable hidden-input reference. | |
string | — | Merged onto the root via tailwind-merge. |