Componentform1.16 KB gzipZero dependencies

Input OTP

Six-box one-time code with paste split and autocomplete.

Preview

Live Preview
Gate code
Value:

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add input-otp

Or install the full npm package:

npm install bigbullui
import { InputOtp } from "@/components/ui/input-otp";

<InputOtp length={6} onComplete={(pin) => console.log(pin)} />

Usage guidance

Use Input OTP when a ticket stub surface needs Six-box one-time code with paste split and autocomplete. Keep props minimal and prefer composition with sibling stubs.

Avoid Input OTP for long form flows or dense data grids where a dedicated layout block fits better.

Source code

input-otp.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface InputOtpProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> {
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  onComplete?: (value: string) => void;
  disabled?: boolean;
  label?: string;
}

export function InputOtp({
  length = 6,
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  onComplete,
  disabled = false,
  label,
  className,
  ...props
}: InputOtpProps) {
  const [inner, setInner] = React.useState(defaultValue);
  const value = controlledValue ?? inner;
  const refs = React.useRef<(HTMLInputElement | null)[]>([]);

  const commit = (next: string) => {
    const clean = next.replace(/\D/g, "").slice(0, length);
    setInner(clean);
    onValueChange?.(clean);
    if (clean.length === length) onComplete?.(clean);
  };

  const onKeyDown = (index: number) => (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Backspace" && !value[index] && index > 0) {
      refs.current[index - 1]?.focus();
    }
  };

  const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
    e.preventDefault();
    commit(e.clipboardData.getData("text"));
    refs.current[Math.min(value.length, length - 1)]?.focus();
  };

  return (
    <div className={cn("w-full", className)} {...props}>
      {label ? (
        <span className="mb-2 block font-mono text-[11px] uppercase tracking-widest text-muted-foreground">
          {label}
        </span>
      ) : null}
      <div className="flex items-center gap-2" role="group" aria-label={label ?? "One-time code"}>
        {Array.from({ length }, (_, i) => (
          <input
            key={i}
            ref={(el) => {
              refs.current[i] = el;
            }}
            value={value[i] ?? ""}
            disabled={disabled}
            inputMode="numeric"
            autoComplete={i === 0 ? "one-time-code" : "off"}
            aria-label={`Digit ${i + 1}`}
            maxLength={1}
            onChange={(e) => {
              const digit = e.target.value.replace(/\D/g, "").slice(-1);
              if (!digit) {
                commit(value.slice(0, i) + value.slice(i + 1));
                return;
              }
              commit(value.slice(0, i) + digit + value.slice(i + 1));
              if (i < length - 1) refs.current[i + 1]?.focus();
            }}
            onKeyDown={onKeyDown(i)}
            onPaste={onPaste}
            onFocus={(e) => e.target.select()}
            className="h-12 w-10 rounded-md border border-border bg-card text-center font-mono text-lg font-bold transition-colors focus:border-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
          />
        ))}
      </div>
    </div>
  );
}

Props

PropTypeDescription
lengthnumberBox count (default 6).
valuestringControlled code string.
onValueChange(value: string) => voidChange callback.
onComplete(value: string) => voidFires when all boxes filled.