Input OTP

Accessible one-time password component with copy paste functionality.

Form
Verify your login
Enter the verification code we sent to your email address: m@example.com.
Simple
Digits Only
With Separator
Alphanumeric

Accepts both letters and numbers.

Disabled
4 Digits

Common pattern for PIN codes.

Invalid State

Example showing the invalid error state.

Installation

Install the dependencies

pnpm add cn github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/input-otp.

src/lib/components/ui/input-otp/InputOTP.svelte
<script lang="ts">
  import { OTPField as Primitive, type OTPFieldRootProps } from '@sveltery/base/otp-field';
  import { cn } from 'cn';
  let { class: classProp, value = $bindable(), ...props }: OTPFieldRootProps = $props();
</script>
<Primitive.Root data-slot="input-otp" spellcheck={false} {...props} class={cn('gap-2 flex items-center has-disabled:opacity-50', classProp)} bind:value />
src/lib/components/ui/input-otp/InputOTPGroup.svelte
<script lang="ts">
  import type { HTMLAttributes } from 'svelte/elements';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="input-otp-group" {...props} class={cn('has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive rounded-lg has-aria-invalid:ring-3 flex items-center', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/input-otp/InputOTPSeparator.svelte
<script lang="ts">
  import type { HTMLAttributes } from 'svelte/elements';
  import { cn } from 'cn';
  import MinusIcon from '@lucide/svelte/icons/minus';
  let { class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="input-otp-separator" role="separator" {...props} class={cn("[&_svg:not([class*='size-'])]:size-4 flex items-center", classProp)}>
  <MinusIcon />
</div>
src/lib/components/ui/input-otp/InputOTPSlot.svelte
<script lang="ts">
  import { OTPField as Primitive, type OTPFieldInputProps } from '@sveltery/base/otp-field';
  import { cn } from 'cn';
  let { class: classProp, ...props }: OTPFieldInputProps = $props();
</script>
<Primitive.Input data-slot="input-otp-slot" {...props} class={cn('dark:bg-input/30 border-input focus:border-ring focus:ring-ring/50 focus:aria-invalid:ring-destructive/20 dark:focus:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus:aria-invalid:border-destructive size-8 border-y border-r text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg focus:ring-3 relative flex items-center justify-center text-center focus:z-10 disabled:cursor-not-allowed', classProp)} />
src/lib/components/ui/input-otp/index.ts
export { default as InputOTP } from './InputOTP.svelte';
export { default as InputOTPGroup } from './InputOTPGroup.svelte';
export { default as InputOTPSlot } from './InputOTPSlot.svelte';
export { default as InputOTPSeparator } from './InputOTPSeparator.svelte';
export type { OTPFieldRootProps as InputOTPProps, OTPFieldInputProps as InputOTPSlotProps } from '@sveltery/base/otp-field';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '$lib/components/ui/input-otp';
</script>
<InputOTP length={6}>
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
</InputOTP>

Each InputOTPSlot is a real input, so there is no input-otp dependency. Slots take their position from their order, so they have no index prop. length sets the number of slots and must match the slots you render.

Typing moves to the next slot, Backspace moves back, and pasting a code fills every slot from the first. A phone that offers the code from a text message fills it too, through autocomplete="one-time-code".

Characters

validationType sets which characters a slot accepts: numeric (the default), alpha, alphanumeric or none. Characters that don't fit are dropped as the user types or pastes. onValueInvalid reports them.

In a form

Give InputOTP a name and it submits its value with its form. A native reset restores defaultValue. onValueComplete runs when every slot is filled, and autoSubmit submits the form at that point. To label it, put it in a Field and pair the FieldLabel with the InputOTP id.

<Field>
  <FieldLabel for="code">Verification code</FieldLabel>
  <InputOTP id="code" name="code" length={6} required>
    …
  </InputOTP>
</Field>

API reference

InputOTP is styled on top of the Sveltery Base OTPField primitive, which owns its behavior, focus handling and accessibility. See the Base UI OTP Field documentation for the behavior it ports.