Skip to content
edmi-ui

Input OTP

One-time code entry with grouped slots. Paste fills all slots.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal window
npx shadcn@latest add @edmi-ui/input-otp
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/input-otp
Terminal window
yarn dlx shadcn@latest add @edmi-ui/input-otp
Terminal window
bunx shadcn@latest add @edmi-ui/input-otp

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/input-otp
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/input-otp
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/input-otp
Terminal window
bunx shadcn-vue@latest add @edmi-ui/input-otp

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/input-otp.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/input-otp.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/input-otp.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/input-otp.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
<InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<script setup lang="ts">
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
</script>
<template>
<InputOTP :maxlength="6" :pattern="REGEXP_ONLY_DIGITS">
<template #default="{ slots }">
<InputOTPGroup>
<InputOTPSlot v-for="(_, i) in slots.slice(0, 3)" :key="i" :index="i" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot v-for="(_, i) in slots.slice(3)" :key="i + 3" :index="i + 3" />
</InputOTPGroup>
</template>
</InputOTP>
</template>
<script lang="ts">
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "$lib/components/ui/input-otp";
</script>
<InputOTP maxlength={6} pattern={REGEXP_ONLY_DIGITS} bind:value>
{#snippet children({ cells })}
<InputOTPGroup>
{#each cells.slice(0, 3) as cell, i (i)}
<InputOTPSlot {cell} />
{/each}
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
{#each cells.slice(3, 6) as cell, i (i)}
<InputOTPSlot {cell} />
{/each}
</InputOTPGroup>
{/snippet}
</InputOTP>
registry/ui/input-otp.tsx
"use client";
import { cn } from "cn";
import { OTPInput, OTPInputContext } from "input-otp";
import * as React from "react";
import { MinusIcon } from "@phosphor-icons/react";
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
// ✦ depth (v4): fields sink (-1) in layered mode; focus swaps the edge for the ring
const fieldElevation = {
sunken:
"rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]",
flat: "",
raised:
"rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]",
floating:
"rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]",
};
const InputOTPElevationContext = React.createContext<Elevation | undefined>(
undefined,
);
function InputOTP({
className,
containerClassName,
elevation,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
/** ✦ depth for every slot: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
}) {
return (
<InputOTPElevationContext.Provider value={elevation}>
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
</InputOTPElevationContext.Provider>
);
}
// ✦ depth (v6): at sunken/raised/floating the group is one plate; slots turn transparent with 1px separators
const groupElevation = {
sunken: "rounded-lg border border-sk-bd bg-sk-bg shadow-sunken",
flat: "",
raised:
"rounded-lg border border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised",
floating:
"rounded-lg border border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating",
};
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
const group = React.useContext(InputOTPElevationContext);
const level = useElevation(group, "field");
return (
<div
data-slot="input-otp-group"
className={cn(
"flex items-center gap-1.5",
level !== "flat" && "inline-flex gap-0",
groupElevation[level],
className,
)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
elevation,
...props
}: React.ComponentProps<"div"> & {
index: number;
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
}) {
const group = React.useContext(InputOTPElevationContext);
const level = useElevation(elevation ?? group, "field");
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex size-10 items-center justify-center rounded-md border border-input bg-card font-mono text-sm text-foreground outline-none aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:shadow-ring data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:shadow-ring-error",
fieldElevation[level],
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-pulse bg-foreground duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
// biome-ignore lint/a11y/useSemanticElements: stock markup; div keeps layout predictable
<div
data-slot="input-otp-separator"
className="flex items-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4"
role="separator"
{...props}
>
<MinusIcon
/>
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };
registry/ui/input-otp/InputOTP.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { OTPInputEmits, OTPInputProps } from 'vue-input-otp'
import { reactiveOmit } from '@vueuse/core'
import { useForwardPropsEmits } from 'reka-ui'
import { provide } from 'vue'
import { OTPInput } from 'vue-input-otp'
import { cn } from '@/registry/edmi/lib/utils'
import type { Elevation } from '@/registry/edmi/ui/elevation'
const props = withDefaults(defineProps<OTPInputProps & {
class?: HTMLAttributes['class']
/** ✦ depth for every slot: sunken -1, flat 0, raised +1, floating +2 */
elevation?: Elevation
}>(), { elevation: undefined })
provide('inputOTPElevation', { get value() { return props.elevation } })
const emits = defineEmits<OTPInputEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'elevation')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<OTPInput
v-slot="slotProps"
v-bind="forwarded"
:container-class="cn('flex items-center gap-2 has-disabled:opacity-50', props.class)"
data-slot="input-otp"
:spellcheck="false"
class="disabled:cursor-not-allowed"
>
<slot v-bind="slotProps" />
</OTPInput>
</template>
registry/ui/input-otp/InputOTPGroup.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { inject } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
const group = inject<{ readonly value: Elevation | undefined } | undefined>('inputOTPElevation', undefined)
const level = useElevation(() => group?.value, 'field')
// ✦ depth (v6): at sunken/raised/floating the group is one plate; slots turn transparent with 1px separators
const groupElevation = {
sunken: 'rounded-lg border border-sk-bd bg-sk-bg shadow-sunken',
flat: '',
raised: 'rounded-lg border border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised',
floating: 'rounded-lg border border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating',
}
</script>
<template>
<div
data-slot="input-otp-group"
v-bind="forwarded"
:class="cn('flex items-center gap-1.5', level !== 'flat' && 'inline-flex gap-0', groupElevation[level], props.class)"
>
<slot />
</div>
</template>
registry/ui/input-otp/InputOTPSeparator.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { MinusIcon } from '@lucide/vue'
import { useForwardProps } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const forwarded = useForwardProps(props)
</script>
<template>
<div
data-slot="input-otp-separator"
role="separator"
v-bind="forwarded"
:class="cn('flex items-center text-muted-foreground [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot>
<MinusIcon />
</slot>
</div>
</template>
registry/ui/input-otp/InputOTPSlot.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { computed, inject } from 'vue'
import { useVueOTPContext } from 'vue-input-otp'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
const props = withDefaults(defineProps<{
index: number
class?: HTMLAttributes['class']
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2 */
elevation?: Elevation
}>(), { elevation: undefined })
const delegatedProps = reactiveOmit(props, 'class', 'elevation')
const forwarded = useForwardProps(delegatedProps)
const context = useVueOTPContext()
const group = inject<{ readonly value: Elevation | undefined } | undefined>('inputOTPElevation', undefined)
const level = useElevation(() => props.elevation ?? group?.value, 'field')
// ✦ depth (v4): slots sink (-1) in layered mode; the active slot swaps the edge for the ring
const fieldElevation = {
sunken: 'rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]',
flat: '',
raised: 'rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]',
floating: 'rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]',
}
const slot = computed(() => context?.value.slots[props.index])
</script>
<template>
<div
v-bind="forwarded"
data-slot="input-otp-slot"
:data-active="slot?.isActive"
:class="cn('relative flex size-10 items-center justify-center rounded-md border border-input bg-card font-mono text-sm text-foreground outline-none aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:shadow-ring data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:shadow-ring-error', fieldElevation[level], props.class)"
>
{{ slot?.char }}
<div v-if="slot?.hasFakeCaret" class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="h-4 w-px animate-pulse bg-foreground duration-1000" />
</div>
</div>
</template>
registry/ui/input-otp/index.ts
export { default as InputOTP } from "./InputOTP.vue";
export { default as InputOTPGroup } from "./InputOTPGroup.vue";
export { default as InputOTPSeparator } from "./InputOTPSeparator.vue";
export { default as InputOTPSlot } from "./InputOTPSlot.vue";
src/lib/registry/ui/input-otp/input-otp-group.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { getContext } from "svelte";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const group = getContext<(() => Elevation) | undefined>("inputOTPElevation");
const level = useElevation(() => group?.(), "field");
// ✦ depth (v6): at sunken/raised/floating the group is one plate; slots turn transparent with 1px separators
const groupElevation = {
sunken: "rounded-lg border border-sk-bd bg-sk-bg shadow-sunken",
flat: "",
raised: "rounded-lg border border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised",
floating: "rounded-lg border border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating",
};
</script>
<div
bind:this={ref}
data-slot="input-otp-group"
class={cn(
"flex items-center gap-1.5",
level.current !== "flat" && "inline-flex gap-0",
groupElevation[level.current],
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/input-otp/input-otp-separator.svelte
<script lang="ts">
import MinusIcon from "phosphor-svelte/lib/Minus";
import type { WithElementRef } from "$lib/utils.js";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
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("flex items-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<MinusIcon
/>
{/if}
</div>
src/lib/registry/ui/input-otp/input-otp-slot.svelte
<script lang="ts">
import { PinInput as InputOTPPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { getContext } from "svelte";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
let {
ref = $bindable(null),
cell,
class: className,
elevation,
...restProps
}: InputOTPPrimitive.CellProps & {
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
const group = getContext<(() => Elevation) | undefined>("inputOTPElevation");
const level = useElevation(() => elevation ?? group?.(), "field");
// ✦ depth (v4): slots sink (-1) in layered mode; the active slot swaps the edge for the ring
const fieldElevation = {
sunken: "rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]",
flat: "",
raised: "rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]",
floating: "rounded-none border-0 border-l border-border bg-transparent shadow-none first:border-l-0 data-[active=true]:rounded-md data-[active=true]:border-transparent data-[active=true]:shadow-[0_0_0_3px_var(--ring-soft),inset_0_0_0_1px_var(--ring)]",
};
</script>
<InputOTPPrimitive.Cell
{cell}
bind:ref
data-slot="input-otp-slot"
class={cn(
"relative flex size-10 items-center justify-center rounded-md border border-input bg-card font-mono text-sm text-foreground outline-none aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:shadow-ring data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:shadow-ring-error",
fieldElevation[level.current],
className
)}
{...restProps}
>
{cell.char}
{#if cell.hasFakeCaret}
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div class="h-4 w-px animate-pulse bg-foreground duration-1000"></div>
</div>
{/if}
</InputOTPPrimitive.Cell>
src/lib/registry/ui/input-otp/input-otp.svelte
<script lang="ts">
import { PinInput as InputOTPPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { type Elevation } from "$lib/registry/ui/elevation/index.js";
import { setContext } from "svelte";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
elevation = "auto",
...restProps
}: InputOTPPrimitive.RootProps & {
/** ✦ depth for every slot: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
setContext("inputOTPElevation", () => elevation);
</script>
<InputOTPPrimitive.Root
bind:ref
bind:value
data-slot="input-otp"
spellcheck={false}
class={cn(
"flex items-center gap-2 has-disabled:opacity-50",
"disabled:cursor-not-allowed",
className
)}
{...restProps}
/>
src/lib/registry/ui/input-otp/index.ts
import Root from "./input-otp.svelte";
import Group from "./input-otp-group.svelte";
import Separator from "./input-otp-separator.svelte";
import Slot from "./input-otp-slot.svelte";
export {
Group,
Group as InputOTPGroup,
Root,
Root as InputOTP,
Separator,
Separator as InputOTPSeparator,
Slot,
Slot as InputOTPSlot,
};

See the Elevation guide for the levels, roles and layered mode.

Set elevation on InputOTP for every slot, or on a single InputOTPSlot. sunken is the layered default; the active slot swaps its edge for the ring.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Prop Type Default
maxLength number required
pattern string none
InputOTPSlot index number required
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
  • Slots are separate boxes; the active slot gets the focus halo.