<script lang="ts">
import * as InputOTP from "$lib/components/ui/input-otp/index.js";
</script>
<InputOTP.Root maxlength={6}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root> About
Input OTP is built on top of Bits UI's PinInput which is inspired by @guilherme_rodz's Input OTP component.
Installation
pnpm dlx shadcn-svelte@latest add input-otp Install bits-ui:
pnpm add bits-ui -D Copy and paste the following code into your project.
import Root from './input-otp.svelte';
import Group from './input-otp-group.svelte';
import Slot from './input-otp-slot.svelte';
import Separator from './input-otp-separator.svelte';
export {
Root,
Group,
Slot,
Separator,
Root as InputOTP,
Group as InputOTPGroup,
Slot as InputOTPSlot,
Separator as InputOTPSeparator
};
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$UTILS$.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="input-otp-group"
class={cn(
'has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 gap-1 rounded-none flex items-center',
className
)}
{...restProps}
>
{@render children?.()}
</div>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from '$UTILS$.js';
import { cn } from '$UTILS$.js';
import MinusIcon from 'phosphor-svelte/lib/Minus';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="input-otp-separator"
role="separator"
class={cn("text-zinc-600 [&_svg:not([class*='size-'])]:size-3.5 flex items-center", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<MinusIcon />
{/if}
</div>
<script lang="ts">
import { PinInput as InputOTPPrimitive } from 'bits-ui';
import { cn } from '$UTILS$.js';
let {
ref = $bindable(null),
cell,
class: className,
...restProps
}: InputOTPPrimitive.CellProps = $props();
</script>
<InputOTPPrimitive.Cell
{cell}
bind:ref
data-slot="input-otp-slot"
class={cn(
'border-zinc-800 bg-zinc-900 text-zinc-50 data-[active=true]:border-zinc-300 data-[active=true]:ring-2 data-[active=true]:ring-zinc-300/25 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 size-10 rounded-none border font-mono text-sm transition-[color,border-color,box-shadow] outline-none first:rounded-none last:rounded-none relative flex items-center justify-center data-[active=true]:z-10',
className
)}
{...restProps}
>
{cell.char}
{#if cell.hasFakeCaret}
<div
class="cn-input-otp-caret pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div class="animate-caret-blink bg-zinc-50 h-4 w-px duration-1000"></div>
</div>
{/if}
</InputOTPPrimitive.Cell>
<script lang="ts">
import { PinInput as InputOTPPrimitive } from 'bits-ui';
import { cn } from '$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(
'cn-input-otp-input gap-2 font-mono text-zinc-50 flex items-center disabled:cursor-not-allowed has-disabled:opacity-50',
className
)}
{...restProps}
/>
Usage
<script lang="ts">
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
</script> <InputOTP.Root maxlength={6}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root> Examples
Pattern
Use the pattern prop to define a custom pattern for the OTP input.
<script lang="ts">
import * as InputOTP from "$lib/components/ui/input-otp/index.js";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "bits-ui";
</script>
<InputOTP.Root maxlength={6} pattern={REGEXP_ONLY_DIGITS_AND_CHARS}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root> <script lang="ts">
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
import { REGEXP_ONLY_DIGITS_AND_CHARS } from 'bits-ui';
</script>
<InputOTP.Root maxlength={6} pattern={REGEXP_ONLY_DIGITS_AND_CHARS}>
<!-- ... -->
</InputOTP.Root> Separator
You can use the InputOTP.Separator component to add a separator between the groups of cells.
<script lang="ts">
import * as InputOTP from "$lib/components/ui/input-otp/index.js";
</script>
<InputOTP.Root maxlength={6}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 2) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(2, 4) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(4, 6) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root> <script lang="ts">
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
</script>
<InputOTP.Root maxlength={4}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 2) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(2, 4) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root> Controlled
Enter your one-time password.
<script lang="ts">
import * as InputOTP from "$lib/components/ui/input-otp/index.js";
let value = $state("");
</script>
<div class="space-y-2">
<InputOTP.Root maxlength={6} bind:value>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 6) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
<div class="text-center text-sm">
{value === "" ? "Enter your one-time password." : `You entered: ${value}`}
</div>
</div> Form
<script lang="ts" module>
import { z } from "zod";
const formSchema = z.object({
pin: z.string().min(6, {
message: "Your one-time password must be at least 6 characters."
})
});
</script>
<script lang="ts">
import { defaults, superForm } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { toast } from "svelte-sonner";
import * as InputOTP from "$lib/components/ui/input-otp/index.js";
import * as Form from "$lib/components/ui/form/index.js";
const form = superForm(defaults(zod4(formSchema)), {
validators: zod4(formSchema),
SPA: true,
onUpdate: ({ form: f }) => {
if (f.valid) {
toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`);
} else {
toast.error("Please fix the errors in the form.");
}
}
});
const { form: formData, enhance } = form;
</script>
<form method="POST" class="w-2/3 space-y-6" use:enhance>
<Form.Field {form} name="pin">
<Form.Control>
{#snippet children({ props })}
<Form.Label>One-Time Password</Form.Label>
<InputOTP.Root maxlength={6} {...props} bind:value={$formData.pin}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
{/snippet}
</Form.Control>
<Form.Description
>Please enter the one-time password sent to your phone.</Form.Description
>
<Form.FieldErrors />
</Form.Field>
<Form.Button>Submit</Form.Button>
</form>