Skip to content
EMBOSS
Docs menu

Input OTP

A one-time-code field rendered as a row of milled cells over a single real input — paste, autofill, and screen readers all behave normally.

@emboss/input-otp

Installation

pnpm dlx shadcn@latest add @emboss/input-otp

Usage

example.tsx
import { InputOtp } from "@/components/ui/input-otp";

export function Example() {
  return <InputOtp length={6} aria-label="Verification code" />;
}

API reference

InputOtp

Extends the native input element. Editing happens at the end of the value, like a terminal prompt.

PropTypeDefaultDescription
lengthnumber6Number of character cells.
value / defaultValue / onValueChangestring / string / (value) => voidControlled and uncontrolled usage.
Data attributeDescription
data-activePresent on the cell that will receive the next character while focused.

Source

View source — input-otp.tsx
input-otp.tsx
"use client";

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

export interface InputOtpProps extends Omit<
  React.ComponentProps<"input">,
  "value" | "defaultValue" | "onChange" | "size" | "maxLength"
> {
  /** Number of character cells. */
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
}

/**
 * A one-time-code field rendered as a row of milled cells. A single real
 * input sits invisibly over the row, so paste, autofill, and screen readers
 * all behave like a normal text field.
 *
 * @example
 * <InputOtp length={6} aria-label="Verification code" />
 */
function InputOtp({
  length = 6,
  value,
  defaultValue,
  onValueChange,
  disabled,
  className,
  onFocus,
  onBlur,
  ...props
}: InputOtpProps) {
  const [internal, setInternal] = React.useState(defaultValue ?? "");
  const [focused, setFocused] = React.useState(false);
  const controlled = value !== undefined;
  const current = controlled ? value : internal;
  const activeIndex = Math.min(current.length, length - 1);

  const handleChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
    const next = event.target.value.replace(/\s+/g, "").slice(0, length);
    if (!controlled) setInternal(next);
    onValueChange?.(next);
  };

  return (
    <div
      data-slot="input-otp"
      className={cn(
        "relative inline-flex gap-2",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
    >
      <input
        data-slot="input-otp-input"
        autoComplete="one-time-code"
        inputMode="numeric"
        {...props}
        value={current}
        maxLength={length}
        disabled={disabled}
        onChange={handleChange}
        onFocus={(event) => {
          setFocused(true);
          onFocus?.(event);
        }}
        onBlur={(event) => {
          setFocused(false);
          onBlur?.(event);
        }}
        className="absolute inset-0 z-10 cursor-text opacity-0"
      />
      {Array.from({ length }, (_, index) => {
        const active = focused && index === activeIndex;
        return (
          <div
            key={index}
            aria-hidden
            data-slot="input-otp-cell"
            data-active={active ? "" : undefined}
            className="flex h-10 w-8 items-center justify-center rounded-sm bg-well font-mono text-base text-ink shadow-deboss-2 transition-[background-color,box-shadow] duration-(--duration-press) ease-(--ease-press) data-active:bg-surface-1 data-active:shadow-deboss-1"
          >
            {current[index] ??
              (active ? (
                <span className="h-4 w-px animate-depth-pulse bg-ink" />
              ) : null)}
          </div>
        );
      })}
    </div>
  );
}

export { InputOtp };