deepmoon

OTP Input

Click anywhere in the boxes to focus, type digits, or paste a full code.

Current value: (empty). Not yet complete.

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/otp-input.json

Props

OtpInputProps
PropTypeRequired
lengthnumberNo
valuestringYes
onChange(value: string) => voidYes
onComplete(value: string) => voidNo
disabledbooleanNo
classNamestringNo
aria-labelstringNo

Code

otp-input.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export interface OtpInputProps {
  length?: number;
  value: string;
  onChange: (value: string) => void;
  onComplete?: (value: string) => void;
  disabled?: boolean;
  className?: string;
  "aria-label"?: string;
}

/**
 * One real, full-size <input> sits transparently over the visual boxes —
 * not N separate single-character inputs. That single-input model is what
 * makes paste-to-fill, native caret movement (Arrow keys), and screen
 * reader behavior all work for free, instead of needing bespoke paste
 * splitting and auto-advance-on-type logic across many inputs.
 */
export function OtpInput({
  length = 6,
  value,
  onChange,
  onComplete,
  disabled = false,
  className,
  "aria-label": ariaLabel = "One-time code",
}: OtpInputProps) {
  const [focused, setFocused] = React.useState(false);
  const activeIndex = Math.min(value.length, length - 1);

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const next = event.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, length);
    onChange(next);
    if (next.length === length) onComplete?.(next);
  };

  return (
    <div className={cn("relative inline-flex gap-2", className)}>
      <input
        type="text"
        inputMode="numeric"
        autoComplete="one-time-code"
        aria-label={ariaLabel}
        value={value}
        disabled={disabled}
        onChange={handleChange}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
        maxLength={length}
        className="absolute inset-0 z-10 h-full w-full cursor-text text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed"
      />
      {Array.from({ length }, (_, index) => (
        <div
          key={index}
          aria-hidden="true"
          className={cn(
            "flex size-10 items-center justify-center rounded-md border border-input bg-background text-base font-medium text-foreground",
            focused && index === activeIndex && "border-ring ring-2 ring-ring",
            disabled && "opacity-50",
          )}
        >
          {value[index] ??
            (focused && index === activeIndex ? (
              <span className="h-4 w-px bg-foreground motion-safe:animate-pulse" />
            ) : null)}
        </div>
      ))}
    </div>
  );
}