# Input OTP

> A polished OTP field for verification and two-factor auth flows.

A shadcn/ui block from the [@wensity](https://ui.wensity.com/components) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/wensity/input-otp).

- Page, with a live preview: https://shadcn.exchange/r/wensity/input-otp
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/wensity/input-otp-light-cd43cf44c67f67c2.png
- Registry item: https://raw.githubusercontent.com/wensity/registry/main/input-otp.json
- Categories: UI Primitives, wensity, free
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://raw.githubusercontent.com/wensity/registry/main/input-otp.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@base-ui/react`, `clsx`, `tailwind-merge`

## Notes from the registry

Free Wensity component. Installs to @components/wensity/input-otp.tsx. For Pro components and updates, use pnpm dlx wensity@latest add input-otp.

## Files

### `registry/wensity/lib/utils.ts`

```ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

### `registry/wensity/input-otp.tsx`

```tsx
"use client";

import * as React from "react";
import { OTPField } from "@base-ui/react/otp-field";
import { cn } from "@/lib/utils";

export type InputOtpMode = "numeric" | "alphanumeric";
export type InputOtpSize = "sm" | "md" | "lg";
export type InputOtpStatus = "default" | "error" | "success";

export interface InputOtpProps
  extends Omit<
    React.HTMLAttributes<HTMLDivElement>,
    "defaultValue" | "onChange"
  > {
  length?: number;
  mode?: InputOtpMode;
  masked?: boolean;
  /** Group sizes for separated layout. Must sum to `length`, e.g. `[3, 3]` → 123 • 456 */
  groups?: number[];
  /** Character or node between digit groups. Defaults to `•`. */
  separator?: React.ReactNode;
  /** When true, calls `onComplete` once all slots are filled after user input. */
  autoSubmit?: boolean;
  /** Fires when all slots are filled after user input. Requires `autoSubmit`. Never fires on mount with a prefilled value. */
  onComplete?: (value: string) => void;
  /** Fires when all slots are filled after user input, without submitting. Never fires on mount with a prefilled value. */
  onFilled?: (value: string) => void;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Controls success/error visuals and which message is shown. */
  status?: InputOtpStatus;
  error?: React.ReactNode;
  success?: React.ReactNode;
  label?: React.ReactNode;
  hint?: React.ReactNode;
  disabled?: boolean;
  /** Locks input without the muted disabled styling — useful for verified / post-submit display. */
  readOnly?: boolean;
  /** Shows a verifying state: locks input, sets `aria-busy`, and pulses slots. */
  loading?: boolean;
  /** Screen-reader + helper copy while `loading` is true. */
  loadingMessage?: React.ReactNode;
  inputSize?: InputOtpSize;
  fullWidth?: boolean;
  autoFocus?: boolean;
  name?: string;
  containerClassName?: string;
  slotClassName?: string;
  separatorClassName?: string;
}

const OTP_CHECK_PATH = "M3.25 8.1 6.35 11.2 12.75 4.6";
const OTP_ERROR_PATH_A = "M4.5 4.5 11.5 11.5";
const OTP_ERROR_PATH_B = "M11.5 4.5 4.5 11.5";
const INPUT_OTP_STYLE_ID = "wensity-input-otp-styles";

const INPUT_OTP_STYLES = `
[data-wensity-otp] {
  --wensity-otp-ease-morph: cubic-bezier(0.32, 0.72, 0, 1);
  --wensity-otp-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --wensity-otp-ease-draw: cubic-bezier(0.16, 1, 0.3, 1);
  --wensity-otp-success: var(--primitive-success);
  --wensity-otp-error: var(--primitive-destructive);
  --wensity-otp-tick-delay: 220ms;
  --wensity-otp-success-message-delay: 300ms;
}

[data-wensity-otp-slot] {
  transition:
    border-color 320ms var(--wensity-otp-ease-morph),
    background-color 320ms var(--wensity-otp-ease-morph),
    box-shadow 320ms var(--wensity-otp-ease-morph),
    color 240ms var(--wensity-otp-ease-out),
    transform 320ms var(--wensity-otp-ease-morph),
    filter 260ms var(--wensity-otp-ease-draw);
}

[data-wensity-otp-slot][data-filled]:not(:focus) {
  background: color-mix(in srgb, var(--foreground) 3%, var(--background));
}

[data-wensity-otp-slot]:focus {
  border-color: color-mix(in srgb, var(--foreground) 28%, transparent);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent),
    0 0 0 2px var(--primitive-ring);
}

[data-wensity-otp][data-disabled] [data-wensity-otp-slot] {
  cursor: not-allowed;
  opacity: 0.45;
}

[data-wensity-otp][data-readonly] [data-wensity-otp-slot] {
  cursor: default;
}

[data-wensity-otp][data-readonly] [data-wensity-otp-slot] {
  cursor: default;
}

[data-wensity-otp][data-status="error"] [data-wensity-otp-slot] {
  border-color: color-mix(in srgb, var(--wensity-otp-error) 50%, transparent);
}

[data-wensity-otp][data-status="error"] [data-wensity-otp-slot]:focus {
  border-color: color-mix(in srgb, var(--wensity-otp-error) 72%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--wensity-otp-error) 18%, transparent);
}

[data-wensity-otp][data-status="success"] [data-wensity-otp-slot] {
  border-color: color-mix(in srgb, var(--wensity-otp-success) 50%, transparent);
}

[data-wensity-otp][data-status="success"] [data-wensity-otp-slot]:focus {
  border-color: color-mix(in srgb, var(--wensity-otp-success) 72%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--wensity-otp-success) 18%, transparent);
}

[data-wensity-otp][data-status="success"] [data-wensity-otp-slot][data-filled] {
  background: color-mix(in srgb, var(--wensity-otp-success) 6%, var(--background));
}

@keyframes wensity-otp-shake {
  0%,
  100% {
    transform: translate3d(0, 0, 0);
  }
  18% {
    transform: translate3d(-4px, 0, 0);
  }
  36% {
    transform: translate3d(4px, 0, 0);
  }
  54% {
    transform: translate3d(-2px, 0, 0);
  }
  72% {
    transform: translate3d(2px, 0, 0);
  }
}

[data-wensity-otp][data-shake="true"] [data-wensity-otp-slot] {
  animation: wensity-otp-shake 280ms var(--wensity-otp-ease-out);
}

[data-wensity-otp-status] {
  position: relative;
  display: inline-flex;
  width: 1.25rem;
  min-width: 1.25rem;
  margin-left: 0.25rem;
  flex: 0 0 1.25rem;
  overflow: hidden;
  align-items: center;
  justify-content: center;
}

[data-slot="input-otp-check"],
[data-slot="input-otp-error-a"],
[data-slot="input-otp-error-b"] {
  fill: none;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  opacity: 0;
  transition:
    stroke-dashoffset 140ms var(--wensity-otp-ease-draw) 0ms,
    opacity 120ms var(--wensity-otp-ease-draw) 0ms;
}

[data-wensity-otp][data-status="success"] [data-slot="input-otp-check"] {
  stroke-dashoffset: 0;
  opacity: 1;
  transition:
    stroke-dashoffset 280ms var(--wensity-otp-ease-draw) var(--wensity-otp-tick-delay),
    opacity 200ms var(--wensity-otp-ease-draw) var(--wensity-otp-tick-delay);
}

[data-wensity-otp][data-status="error"] [data-slot="input-otp-error-a"] {
  stroke-dashoffset: 0;
  opacity: 1;
  transition:
    stroke-dashoffset 240ms var(--wensity-otp-ease-draw) 0ms,
    opacity 180ms var(--wensity-otp-ease-draw) 0ms;
}

[data-wensity-otp][data-status="error"] [data-slot="input-otp-error-b"] {
  stroke-dashoffset: 0;
  opacity: 1;
  transition:
    stroke-dashoffset 240ms var(--wensity-otp-ease-draw) 80ms,
    opacity 180ms var(--wensity-otp-ease-draw) 80ms;
}

@keyframes wensity-otp-message-enter {
  from {
    opacity: 0;
    transform: translate3d(0, 4px, 0);
  }
  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

[data-otp-message="success"] {
  animation: wensity-otp-message-enter 180ms var(--wensity-otp-ease-morph)
    var(--wensity-otp-success-message-delay) both;
}

[data-otp-message="error"],
[data-otp-message="invalid"] {
  animation: wensity-otp-message-enter 160ms var(--wensity-otp-ease-morph) 0ms both;
}

[data-otp-message="loading"] {
  animation: wensity-otp-message-enter 160ms var(--wensity-otp-ease-morph) 0ms both;
}

@keyframes wensity-otp-loading-pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.58;
  }
}

[data-wensity-otp][data-loading] [data-wensity-otp-slot] {
  animation: wensity-otp-loading-pulse 1200ms var(--wensity-otp-ease-morph) infinite;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  [data-wensity-otp-slot],
  [data-otp-message],
  [data-slot="input-otp-check"],
  [data-slot="input-otp-error-a"],
  [data-slot="input-otp-error-b"] {
    transition-duration: 0ms !important;
    transition-delay: 0ms !important;
  }

  [data-otp-message] {
    animation: none !important;
    opacity: 1;
    transform: none;
  }

  [data-wensity-otp][data-loading] [data-wensity-otp-slot],
  [data-wensity-otp][data-shake="true"] [data-wensity-otp-slot] {
    animation: none !important;
  }

  [data-slot="input-otp-check"],
  [data-slot="input-otp-error-a"],
  [data-slot="input-otp-error-b"] {
    stroke-dashoffset: 0;
  }

  [data-wensity-otp][data-status="success"] [data-slot="input-otp-check"],
  [data-wensity-otp][data-status="error"] [data-slot="input-otp-error-a"],
  [data-wensity-otp][data-status="error"] [data-slot="input-otp-error-b"] {
    opacity: 1;
  }
}
`;

function ensureInputOtpStyles() {
  if (typeof document === "undefined") return;
  if (document.getElementById(INPUT_OTP_STYLE_ID)) return;

  const style = document.createElement("style");
  style.id = INPUT_OTP_STYLE_ID;
  style.textContent = INPUT_OTP_STYLES;
  document.head.appendChild(style);
}

function OtpSuccessCheck() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 16 16"
      fill="none"
      className="absolute inset-0 m-auto size-4 text-[var(--primitive-success)]"
    >
      <path
        data-slot="input-otp-check"
        d={OTP_CHECK_PATH}
        pathLength={1}
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function OtpErrorX() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 16 16"
      fill="none"
      className="absolute inset-0 m-auto size-4 text-[var(--primitive-destructive)]"
    >
      <path
        data-slot="input-otp-error-a"
        d={OTP_ERROR_PATH_A}
        pathLength={1}
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
      />
      <path
        data-slot="input-otp-error-b"
        d={OTP_ERROR_PATH_B}
        pathLength={1}
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
      />
    </svg>
  );
}

const slotSizes: Record<InputOtpSize, string> = {
  sm: "size-9 text-sm rounded-[var(--primitive-radius-control-sm)]",
  md: "size-11 text-base rounded-[var(--primitive-radius-control)]",
  lg: "size-[3.25rem] text-lg rounded-[var(--primitive-radius-control)]",
};

const separatorSizes: Record<InputOtpSize, string> = {
  sm: "text-sm",
  md: "text-base",
  lg: "text-lg",
};

const INVALID_CHAR_FEEDBACK_MS = 1600;

function invalidCharMessage(mode: InputOtpMode): string {
  return mode === "numeric" ? "Digits only." : "Letters and numbers only.";
}

function resolveGroups(length: number, groups?: number[]): number[] {
  if (groups?.length) {
    const sum = groups.reduce((total, size) => total + size, 0);
    if (sum === length) return groups;
  }
  return [length];
}

function buildGroupRanges(groups: number[]): Array<[number, number]> {
  const ranges: Array<[number, number]> = [];
  let cursor = 0;
  for (const size of groups) {
    ranges.push([cursor, cursor + size]);
    cursor += size;
  }
  return ranges;
}

export const InputOtp = React.forwardRef<HTMLInputElement, InputOtpProps>(
  (
    {
      length = 6,
      mode = "numeric",
      masked = false,
      groups,
      separator = "•",
      autoSubmit = false,
      onComplete,
      onFilled,
      value,
      defaultValue = "",
      onValueChange,
      status: statusProp,
      error,
      success,
      label,
      hint,
      disabled = false,
      readOnly = false,
      loading = false,
      loadingMessage = "Verifying…",
      inputSize = "md",
      fullWidth = true,
      autoFocus = false,
      name,
      className,
      containerClassName,
      slotClassName,
      separatorClassName,
      id,
      "aria-describedby": ariaDescribedBy,
      "aria-invalid": ariaInvalid,
      ...props
    },
    ref,
  ) => {
    const reactId = React.useId();
    const rootId = id ?? `wensity-otp-${reactId}`;
    const labelId = label ? `${rootId}-label` : undefined;
    const hintId = hint ? `${rootId}-hint` : undefined;
    const errorId = error ? `${rootId}-error` : undefined;
    const successId = success ? `${rootId}-success` : undefined;
    const loadingId = loading ? `${rootId}-loading` : undefined;
    const invalidId = `${rootId}-invalid`;

    const [shake, setShake] = React.useState(false);
    const [invalidCharActive, setInvalidCharActive] = React.useState(false);
    const completedRef = React.useRef<string | null>(null);
    const hasInteractedRef = React.useRef(false);
    const invalidCharTimeoutRef = React.useRef<number | null>(null);
    const firstInputRef = React.useRef<HTMLInputElement | null>(null);

    const isFieldDisabled = disabled || loading;
    const resolvedStatus: InputOtpStatus = statusProp ?? "default";
    const displayStatus: InputOtpStatus = loading ? "default" : resolvedStatus;
    const showInvalidCharFeedback =
      invalidCharActive && displayStatus === "default" && !loading;
    const showHint =
      Boolean(hint) &&
      !loading &&
      !showInvalidCharFeedback &&
      displayStatus !== "success" &&
      displayStatus !== "error";

    React.useLayoutEffect(() => {
      ensureInputOtpStyles();
    }, []);

    React.useEffect(() => {
      return () => {
        if (invalidCharTimeoutRef.current !== null) {
          window.clearTimeout(invalidCharTimeoutRef.current);
        }
      };
    }, []);

    const pulseInvalidChar = React.useCallback(() => {
      setInvalidCharActive(true);
      if (invalidCharTimeoutRef.current !== null) {
        window.clearTimeout(invalidCharTimeoutRef.current);
      }
      invalidCharTimeoutRef.current = window.setTimeout(() => {
        setInvalidCharActive(false);
        invalidCharTimeoutRef.current = null;
      }, INVALID_CHAR_FEEDBACK_MS);
    }, []);

    // Start the shake the moment the status becomes "error", derived during
    // render, then clear it from an effect that owns the timer (and cancels it
    // if the component unmounts or re-errors first).
    const shakeKey = loading ? "loading" : resolvedStatus;
    const [prevShakeKey, setPrevShakeKey] = React.useState(shakeKey);
    if (shakeKey !== prevShakeKey) {
      setPrevShakeKey(shakeKey);
      if (!loading && resolvedStatus === "error") setShake(true);
    }

    React.useEffect(() => {
      if (!shake) return;
      const timer = window.setTimeout(() => setShake(false), 320);
      return () => window.clearTimeout(timer);
    }, [shake]);

    const describedBy =
      [
        ariaDescribedBy,
        loading
          ? loadingId
          : displayStatus === "error" && error
            ? errorId
            : displayStatus === "success" && success
              ? successId
              : showInvalidCharFeedback
                ? invalidId
                : showHint
                  ? hintId
                  : undefined,
      ]
        .filter(Boolean)
        .join(" ") || undefined;

    const groupSizes = React.useMemo(
      () => resolveGroups(length, groups),
      [groups, length],
    );
    const groupRanges = React.useMemo(
      () => buildGroupRanges(groupSizes),
      [groupSizes],
    );

    const validationType = mode === "numeric" ? "numeric" : "alphanumeric";
    const normalizeValue =
      mode === "alphanumeric"
        ? (next: string) => next.toUpperCase()
        : undefined;

    const assignFirstRef = React.useCallback(
      (node: HTMLInputElement | null) => {
        firstInputRef.current = node;
        if (typeof ref === "function") {
          ref(node);
        } else if (ref) {
          ref.current = node;
        }
      },
      [ref],
    );

    const handleValueChange = React.useCallback(
      (next: string) => {
        hasInteractedRef.current = true;
        if (next.length !== length) {
          completedRef.current = null;
        }
        onValueChange?.(next);
      },
      [length, onValueChange],
    );

    const handleValueComplete = React.useCallback(
      (next: string) => {
        if (!hasInteractedRef.current) return;
        if (completedRef.current === next) return;
        completedRef.current = next;
        onFilled?.(next);
        if (autoSubmit) {
          onComplete?.(next);
        }
      },
      [autoSubmit, onComplete, onFilled],
    );

    const handleValueInvalid = React.useCallback(() => {
      hasInteractedRef.current = true;
      pulseInvalidChar();
    }, [pulseInvalidChar]);

    const slotClassNameResolved = cn(
      "relative inline-flex items-center justify-center border border-[var(--border)] bg-[var(--background)] text-center font-medium tabular-nums text-[var(--foreground)] outline-none",
      "placeholder:text-[var(--primitive-text-placeholder)]",
      "disabled:cursor-not-allowed disabled:opacity-45",
      "focus-visible:outline-none",
      slotSizes[inputSize],
      slotClassName,
    );

    const slots = groupRanges.flatMap(([start, end], groupIndex) => {
      const groupSlots = Array.from({ length: end - start }, (_, offset) => {
        const index = start + offset;
        return (
          <OTPField.Input
            key={index}
            ref={index === 0 ? assignFirstRef : undefined}
            autoFocus={autoFocus && index === 0 && !readOnly}
            aria-label={
              index === 0 ? undefined : `Character ${index + 1} of ${length}`
            }
            render={(inputProps, state) => (
              <input
                {...inputProps}
                data-wensity-otp-slot=""
                data-filled={state.filled ? "" : undefined}
                className={cn(slotClassNameResolved, inputProps.className)}
              />
            )}
          />
        );
      });

      if (groupIndex === 0) return groupSlots;

      return [
        <OTPField.Separator
          key={`separator-${groupIndex}`}
          orientation="vertical"
          className={cn(
            "select-none px-1 font-medium text-[color-mix(in_srgb,var(--primitive-text-secondary)_70%,transparent)]",
            separatorSizes[inputSize],
            separatorClassName,
          )}
        >
          {separator}
        </OTPField.Separator>,
        ...groupSlots,
      ];
    });

    return (
      <div data-wensity-primitive=""
        className={cn(
          "flex flex-col gap-1.5",
          fullWidth ? "w-full" : "w-fit",
          containerClassName,
        )}
      >
        {label ? (
          <label
            id={labelId}
            htmlFor={rootId}
            className="text-[13px] font-medium leading-none tracking-[-0.01em] text-[var(--foreground)]"
          >
            {label}
          </label>
        ) : null}

        <OTPField.Root
          id={rootId}
          length={length}
          name={name}
          value={value}
          defaultValue={defaultValue}
          onValueChange={handleValueChange}
          onValueComplete={handleValueComplete}
          onValueInvalid={handleValueInvalid}
          autoSubmit={autoSubmit}
          mask={masked}
          validationType={validationType}
          normalizeValue={normalizeValue}
          disabled={isFieldDisabled}
          readOnly={readOnly}
          autoComplete="one-time-code"
          inputMode={mode === "numeric" ? "numeric" : "text"}
          aria-labelledby={label ? labelId : undefined}
          aria-describedby={describedBy}
          aria-busy={loading || undefined}
          aria-invalid={
            ariaInvalid ?? (displayStatus === "error" ? true : undefined)
          }
          data-wensity-otp=""
          data-status={displayStatus}
          data-shake={shake ? "true" : undefined}
          data-loading={loading ? "" : undefined}
          className={cn("relative", fullWidth ? "w-full" : "w-fit", className)}
          {...props}
        >
          <div data-wensity-otp-track="" className="flex items-center gap-2">
            {slots}
            <span
              data-wensity-otp-status=""
              aria-hidden={displayStatus === "default"}
              className="pointer-events-none relative size-5 shrink-0"
            >
              <OtpErrorX />
              <OtpSuccessCheck />
            </span>
          </div>
        </OTPField.Root>

        <div aria-live="polite" aria-atomic="true" className="relative min-h-5">
          <div className="grid [&>*]:col-start-1 [&>*]:row-start-1">
            {showHint ? (
              <p
                id={hintId}
                data-otp-message="hint"
                className="text-[12px] leading-[1.45] text-[var(--primitive-text-secondary)]"
              >
                {hint}
              </p>
            ) : null}
            {loading ? (
              <p
                id={loadingId}
                data-otp-message="loading"
                className="text-[12px] leading-[1.45] text-[var(--primitive-text-secondary)]"
              >
                {loadingMessage}
              </p>
            ) : displayStatus === "error" && error ? (
              <p
                id={errorId}
                data-otp-message="error"
                className="text-[12px] leading-[1.45] text-[var(--primitive-destructive)]"
              >
                {error}
              </p>
            ) : displayStatus === "success" && success ? (
              <p
                id={successId}
                data-otp-message="success"
                className="text-[12px] leading-[1.45] font-medium text-[var(--primitive-success)]"
              >
                {success}
              </p>
            ) : showInvalidCharFeedback ? (
              <p
                id={invalidId}
                data-otp-message="invalid"
                className="text-[12px] leading-[1.45] text-[var(--primitive-destructive)]"
              >
                {invalidCharMessage(mode)}
              </p>
            ) : null}
          </div>
        </div>
      </div>
    );
  },
);

InputOtp.displayName = "InputOtp";
```
