Bloom Logo

Input OTP

One-time password input component built on input-otp with instant auto-paste clipboards, flexible group separators (3 - 3, 2 - 2 - 2), character type filters, and custom slot sizes.

importimport { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "@/components/ui/inputOtp/inputOtp";
$npx @bloomui-react/cli add inputOtp
inputOtp.tsx
"use client";

import { Icon } from "@iconify/react";
import { cva, type VariantProps } from "class-variance-authority";
import {
  OTPInput,
  OTPInputContext,
  REGEXP_ONLY_CHARS,
  REGEXP_ONLY_DIGITS,
  REGEXP_ONLY_DIGITS_AND_CHARS,
} from "input-otp";
import * as React from "react";
import { cn } from "@/lib/utils";

export type OTPType = "numeric" | "alphabetic" | "alphanumeric";
export type OTPSize = "sm" | "md" | "lg";

export type InputOTPProps = React.ComponentPropsWithoutRef<typeof OTPInput> & {
  allowedType?: OTPType;
  showSeparator?: boolean;
  separatorIcon?: string;
  autoFocus?: boolean;
  label?: React.ReactNode;
  isRequired?: boolean;
};

const patternMap: Record<OTPType, string> = {
  numeric: REGEXP_ONLY_DIGITS,
  alphabetic: REGEXP_ONLY_CHARS,
  alphanumeric: REGEXP_ONLY_DIGITS_AND_CHARS,
};

const inputModeMap: Record<OTPType, "numeric" | "text"> = {
  numeric: "numeric",
  alphabetic: "text",
  alphanumeric: "text",
};

const InputOTP = React.forwardRef<
  React.ComponentRef<typeof OTPInput>,
  InputOTPProps
>(
  (
    {
      className,
      containerClassName,
      allowedType = "numeric",
      label,
      isRequired = false,
      ...props
    },
    ref,
  ) => {
    const inputOtpComponent = (
      <OTPInput
        ref={ref}
        pattern={patternMap[allowedType]}
        inputMode={inputModeMap[allowedType]}
        containerClassName={cn(
          "flex items-center gap-2 disabled:cursor-not-allowed",
          containerClassName,
        )}
        className={cn("disabled:cursor-not-allowed", className)}
        {...props}
      />
    );

    if (label) {
      return (
        <div className="flex flex-col gap-1.5 w-full">
          <label className="text-xs font-semibold text-zinc-900 dark:text-zinc-100 select-none">
            {label}
            {isRequired && <span className="text-rose-500 ml-0.5">*</span>}
          </label>
          {inputOtpComponent}
        </div>
      );
    }

    return inputOtpComponent;
  },
);
InputOTP.displayName = "InputOTP";

const InputOTPGroup = React.forwardRef<
  React.ElementRef<"div">,
  React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex items-center gap-2", className)}
    {...props}
  />
));
InputOTPGroup.displayName = "InputOTPGroup";

export interface InputOTPSlotProps
  extends React.ComponentPropsWithoutRef<"div">,
    VariantProps<typeof slotVariants> {
  index: number;
  size?: OTPSize;
  maskCode?: boolean;
}

const slotSizeStyles: Record<OTPSize, string> = {
  sm: "size-8 text-xs rounded-lg",
  md: "size-11 text-base rounded-xl",
  lg: "size-14 text-lg rounded-2xl",
};

const slotVariants = cva(
  "relative flex items-center justify-center font-bold transition-all duration-200 select-none data-[active=true]:z-10 data-[active=true]:scale-105",
  {
    variants: {
      variant: {
        default:
          "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xs data-[active=true]:border-sky-500 data-[active=true]:ring-2 data-[active=true]:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
        bordered:
          "bg-transparent border-2 border-zinc-200 dark:border-zinc-800 data-[active=true]:border-sky-500 text-zinc-900 dark:text-zinc-100",
        flat: "bg-zinc-100 dark:bg-zinc-800/60 border-transparent hover:bg-zinc-200/70 dark:hover:bg-zinc-800 data-[active=true]:bg-white dark:data-[active=true]:bg-zinc-900 data-[active=true]:border-sky-500 border text-zinc-900 dark:text-zinc-100",
        underlined:
          "bg-transparent border-b-2 border-zinc-200 dark:border-zinc-800 rounded-none px-0 data-[active=true]:border-sky-500 text-zinc-900 dark:text-zinc-100",
        filled:
          "bg-zinc-100 dark:bg-zinc-800/80 border border-transparent data-[active=true]:border-sky-500 data-[active=true]:ring-2 data-[active=true]:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
        glassmorphism:
          "backdrop-blur-md bg-white/10 dark:bg-black/10 border border-white/20 dark:border-white/10 data-[active=true]:border-sky-500 shadow-lg text-zinc-900 dark:text-zinc-100",
        "gradient-border":
          "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 relative [background-clip:padding-box] border border-transparent before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:p-[1px] before:bg-gradient-to-r before:from-sky-500 before:via-indigo-500 before:to-pink-500 data-[active=true]:ring-2 data-[active=true]:ring-indigo-500/30",
        glow: "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xs data-[active=true]:border-sky-500 data-[active=true]:shadow-[0_0_12px_rgba(14,165,233,0.35)] text-zinc-900 dark:text-zinc-100",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

const InputOTPSlot = React.forwardRef<
  React.ElementRef<"div">,
  InputOTPSlotProps
>(
  (
    { index, size = "md", variant, maskCode = false, className, ...props },
    ref,
  ) => {
    const inputOTPContext = React.useContext(OTPInputContext);
    const slot = inputOTPContext.slots[index];
    const { char, hasFakeCaret, isActive } = slot || {};

    return (
      <div
        ref={ref}
        data-active={isActive}
        className={cn(
          slotVariants({ variant }),
          slotSizeStyles[size],
          className,
        )}
        {...props}
      >
        {char ? (
          maskCode ? (
            <span className="size-2.5 rounded-full bg-zinc-900 dark:bg-zinc-100 animate-in zoom-in-50" />
          ) : (
            char
          )
        ) : null}
        {hasFakeCaret && (
          <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
            <div className="h-4 w-0.5 animate-pulse bg-sky-500 duration-1000" />
          </div>
        )}
      </div>
    );
  },
);
InputOTPSlot.displayName = "InputOTPSlot";

export interface InputOTPSeparatorProps
  extends React.ComponentPropsWithoutRef<"div"> {
  icon?: string;
}

const InputOTPSeparator = React.forwardRef<
  React.ElementRef<"div">,
  InputOTPSeparatorProps
>(({ icon = "hugeicons:minus-01", className, ...props }, ref) => (
  <div
    ref={ref}
    role="presentation"
    className={cn(
      "px-1.5 text-zinc-400 dark:text-zinc-600 flex items-center justify-center",
      className,
    )}
    {...props}
  >
    <Icon icon={icon} className="size-4" />
  </div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";

export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };

Default

Standard 4-digit one-time password input.

Variants

Defines the visual appearance of the Input OTP slots using the variant prop.

variant: default | bordered
Default
Bordered
Flat
Filled
Glow
Glassmorphism
Gradient Border
Underlined

Group Separators

Split 6-digit OTP codes into two groups of 3 using <InputOTPSeparator />.

maxLength: anyindex: any

Copy a 6-digit code (e.g., 123456) to test instant auto-paste.

Custom Multi-Group Separators

Divide codes into any custom layout such as three groups of two digits with multiple separators.

maxLength: anyindex: any

Allowed Types

Restrict user entry to specific character sets using the allowedType prop: numeric, alphabetic, or alphanumeric.

allowedType: numeric | alphabetic | alphanumeric
1. Numbers Only (allowedType="numeric")
2. Letters Only (allowedType="alphabetic")
3. Alphanumeric Mixed (allowedType="alphanumeric")

Slot Sizes

Control slot square dimensions using the size prop: sm, md, or lg.

size: sm | md | lg
Small (sm):
Medium (md):
Large (lg):

Masked Password Dot Mode

Hide sensitive verification PIN codes with masked dot indicators using maskCode={true} on slots.

maskCode: boolean

Required State

Displays an asterisk next to the label indicating that entering the verification code is mandatory.

isRequired: boolean

Props — InputOTP

Supported properties for the InputOTP component.

PropTypeDefaultDescription
maskCode (on Slot)booleanfalseMasks digits into password dots for PIN security.
allowedType'numeric' | 'alphabetic' | 'alphanumeric''numeric'Restricts allowed input characters (numbers, letters, or mixed).
maxLengthnumber6Total number of OTP input digit slots.