Glrk UI

Input OTP

OTP input backed by Base UI OTPFieldPreview with grouped slots, separator, and numeric/alphanumeric validation modes.

Basic
6 slots — default numeric OTP

Value:

4 slots — PIN
separator — 3+3 split
separator={4} — 4+4 split on 8 slots
Variants
mask — password mode
validationType: alphanumeric
State
Disabled
OTPWrapper (field-wrapper)
With label
Error state

Installation

npx shadcn@latest add @glrk-ui/input-otp

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

ui/input-otp.tsx
'use client'

import * as React from 'react'
import { OTPFieldPreview as OTPFieldPrimitive } from '@base-ui/react/otp-field'
import { MinusIcon } from 'lucide-react'

import { cn } from '@/lib/utils'

function InputOTP({
  className,
  ...props
}: React.ComponentProps<typeof OTPFieldPrimitive.Root>) {
  return (
    <OTPFieldPrimitive.Root
      data-slot="input-otp"
      className={cn('flex items-center data-[disabled]:opacity-50', className)}
      {...props}
    />
  )
}

function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
  return (
    <div
      data-slot="input-otp-group"
      className={cn('flex items-center', className)}
      {...props}
    />
  )
}

function InputOTPSlot({
  className,
  ...props
}: React.ComponentProps<typeof OTPFieldPrimitive.Input>) {
  return (
    <OTPFieldPrimitive.Input
      data-slot="input-otp-slot"
      className={cn(
        'size-9 border-y border-r border-input bg-transparent text-center text-sm transition-all outline-none',
        'first:rounded-l-md first:border-l last:rounded-r-md',
        'focus:border-ring focus:ring-3 focus:ring-ring/50',
        'data-[invalid]:border-destructive data-[focused]:data-[invalid]:ring-destructive/20',
        'dark:bg-input/30 dark:data-[focused]:data-[invalid]:ring-destructive/40',
        'disabled:cursor-not-allowed',
        className,
      )}
      {...props}
    />
  )
}

function InputOTPSeparator({
  children,
  className,
  ...props
}: React.ComponentProps<typeof OTPFieldPrimitive.Separator>) {
  return (
    <OTPFieldPrimitive.Separator
      data-slot="input-otp-separator"
      className={cn('flex items-center [&_svg:not([class*="size-"])]:size-4', className)}
      {...props}
    >
      {children ?? <MinusIcon />}
    </OTPFieldPrimitive.Separator>
  )
}

type InputOTPWrapperProps = Omit<React.ComponentProps<typeof OTPFieldPrimitive.Root>, 'length'> & {
  length?: number
  separator?: boolean | number
  slotClassName?: string
}

function InputOTPWrapper({
  length = 6,
  separator = false,
  slotClassName,
  className,
  ...props
}: InputOTPWrapperProps) {
  const splitAt =
    separator === false ? null : separator === true ? Math.floor(length / 2) : separator

  if (splitAt === null) {
    return (
      <InputOTP length={length} className={className} {...props}>
        <InputOTPGroup>
          {Array.from({ length }, (_, i) => (
            <InputOTPSlot key={i} className={slotClassName} />
          ))}
        </InputOTPGroup>
      </InputOTP>
    )
  }

  return (
    <InputOTP length={length} className={className} {...props}>
      <InputOTPGroup>
        {Array.from({ length: splitAt }, (_, i) => (
          <InputOTPSlot key={i} className={slotClassName} />
        ))}
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        {Array.from({ length: length - splitAt }, (_, i) => (
          <InputOTPSlot key={i} className={slotClassName} />
        ))}
      </InputOTPGroup>
    </InputOTP>
  )
}

export {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  InputOTPSeparator,
  InputOTPWrapper,
  type InputOTPWrapperProps
}

Usage

Standalone (InputOTPWrapper)

import { InputOTPWrapper } from "@/components/ui/input-otp"

const [value, setValue] = useState('')

<InputOTPWrapper
  length={6}
  value={value}
  onValueChange={(v) => setValue(v)}
/>

With separator

// Split in half automatically (3+3 for length=6)
<InputOTPWrapper length={6} separator />

// Split after specific slot (4+4)
<InputOTPWrapper length={8} separator={4} />

Mask (password mode)

<InputOTPWrapper length={6} mask />

Alphanumeric validation

<InputOTPWrapper length={6} validationType="alphanumeric" />

With field label and error (OTPWrapper)

OTPWrapper wraps InputOTPWrapper inside a Field with label and error display. Import from field-wrapper:

import { OTPWrapper } from "@/components/ui/field-wrapper"

<OTPWrapper
  name="otp"
  label="Verification code"
  length={6}
  separator
  value={value}
  onValueChange={(v) => setValue(v)}
/>

With validation error

<OTPWrapper
  name="otp"
  label="Verification code"
  length={6}
  invalid
  error={{ message: "Invalid code. Please try again." }}
/>

React Hook Form

import { OTPWrapper } from "@/components/ui/field-wrapper-rhf"

<OTPWrapper name="otp" label="Verification code" control={form.control} length={6} />

TanStack Form

<form.AppField
  name="otp"
  validators={{
    onChange: ({ value }) =>
      value.length < 6 ? "Enter all 6 digits" : undefined,
  }}
>
  {field => <field.OTPField label="Verification code" length={6} separator />}
</form.AppField>

Using primitives

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  InputOTPSeparator,
} from "@/components/ui/input-otp"

<InputOTP length={6} value={value} onValueChange={setValue}>
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
</InputOTP>

Reference

InputOTPWrapper

Prop

Type