Agents (llms.txt)
Octocat

OTP Input

A composable OTP field where each cell is a real input, managed by a shared root.

import { OTPInput } from '@/components/otp-input';

export default function OTPInputExample() {
  return (
    <OTPInput>
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
    </OTPInput>
  );
}

Dependencies

Source Code

import type { VariantProps } from 'cva';
import {
  Children,
  createContext,
  isValidElement,
  use,
  useCallback,
  useMemo,
  useRef,
  useState,
} from 'react';
import {
  InstanceCounterProvider,
  useInstanceCounter,
} from '@/components/instance-counter';
import { inputStyle } from '@/components/input';
import { composeRefs } from '@/lib/compose-refs';
import { cn, compose, cva } from '@/lib/utils/classnames';

const splitIntoSlots = (value: string, length: number): string[] =>
  Array.from({ length }, (_, i) => value[i] ?? '');

interface OTPInputContextValue {
  values: string[];
  size: NonNullable<VariantProps<typeof inputStyle>['size']>;
  inputMode: React.HTMLAttributes<HTMLInputElement>['inputMode'];
  placeholder?: string;
  masked?: boolean;
  disabled?: boolean;
  invalid?: boolean;
  rootId?: string;
  setCellRef: (index: number, el: HTMLInputElement | null) => void;
  focusCell: (index: number) => void;
  onCellChange: (index: number, value: string) => void;
  onCellKeyDown: (
    index: number,
    e: React.KeyboardEvent<HTMLInputElement>
  ) => void;
  onCellFocus: (index: number) => void;
  onCellPaste: (
    index: number,
    e: React.ClipboardEvent<HTMLInputElement>
  ) => void;
}

const OTPInputContext = createContext<OTPInputContextValue | null>(null);

const useOTPInputContext = () => {
  const context = use(OTPInputContext);
  if (!context)
    throw new Error(
      'OTPInputContext must be used within an OTPInput component'
    );
  return context;
};

interface OTPInputProps
  extends Omit<React.ComponentPropsWithRef<'div'>, 'onChange'> {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  onFill?: (value: string) => void;
  size?: VariantProps<typeof inputStyle>['size'];
  inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
  placeholder?: string;
  masked?: boolean;
  disabled?: boolean;
  invalid?: boolean;
}

const OTPInput = ({
  ref,
  id,
  value: valueProp,
  defaultValue = '',
  onChange,
  onFill,
  size = 'md',
  inputMode = 'numeric',
  placeholder,
  masked,
  disabled,
  invalid,
  children,
  className,
  ...props
}: OTPInputProps) => {
  const cellRefs = useRef<(HTMLInputElement | null)[]>([]);

  const cellCount = useMemo(
    () =>
      Children.toArray(children).filter(
        (child) => isValidElement(child) && child.type === OTPInputCell
      ).length,
    [children]
  );

  const [internalValues, setInternalValues] = useState<string[]>(() =>
    splitIntoSlots(defaultValue, cellCount)
  );

  const values =
    valueProp !== undefined
      ? splitIntoSlots(valueProp, cellCount)
      : internalValues;

  // Ref kept in sync with the latest committed values so that focus handlers
  // — which fire synchronously before React re-renders — always see fresh data.
  const valuesRef = useRef(values);
  valuesRef.current = values;

  const updateValues = useCallback(
    (nextValues: string[], bulk = false) => {
      valuesRef.current = nextValues;
      const joined = nextValues.join('');
      if (valueProp === undefined) setInternalValues(nextValues);
      onChange?.(joined);
      // Only fire onFill for bulk fills (paste / password manager),
      // not for character-by-character typing.
      if (bulk && nextValues.every((v) => v !== '')) onFill?.(joined);
    },
    [valueProp, onChange, onFill]
  );

  const setCellRef = useCallback(
    (index: number, el: HTMLInputElement | null) => {
      cellRefs.current[index] = el;
    },
    []
  );

  const focusCell = useCallback(
    (index: number) => {
      // Never skip past the first empty slot — fill cells in order.
      const firstEmpty = valuesRef.current.indexOf('');
      const lastAllowed = firstEmpty === -1 ? cellCount - 1 : firstEmpty;
      const target = Math.max(0, Math.min(index, lastAllowed));
      cellRefs.current[target]?.focus();
    },
    [cellCount]
  );

  const sanitize = useCallback(
    (s: string) => (inputMode === 'numeric' ? s.replace(/\D/g, '') : s),
    [inputMode]
  );

  const onCellChange = useCallback(
    (index: number, typed: string) => {
      // Password managers bypass maxLength and set the full OTP string on one cell.
      // Detect that and distribute characters across cells just like a paste.
      if (typed.length > 1) {
        const clean = sanitize(typed);
        const nextValues = [...valuesRef.current];
        let lastFilled = index;
        for (let i = 0; i < clean.length && index + i < cellCount; i++) {
          nextValues[index + i] = clean[i];
          lastFilled = index + i;
        }
        updateValues(nextValues, true);
        focusCell(lastFilled + 1);
        return;
      }
      // maxLength={2} lets the browser accept the new character alongside the old one.
      // We always want just the most recently typed character.
      const char = sanitize(typed).slice(-1);
      const nextValues = [...valuesRef.current];
      nextValues[index] = char;
      updateValues(nextValues);
      if (char) focusCell(index + 1);
    },
    [updateValues, focusCell, cellCount, sanitize]
  );

  const onCellKeyDown = useCallback(
    (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === 'ArrowLeft') {
        e.preventDefault();
        focusCell(index - 1);
      } else if (e.key === 'ArrowRight') {
        e.preventDefault();
        focusCell(index + 1);
      } else if (e.key === 'Backspace') {
        e.preventDefault();
        const clearIndex = valuesRef.current[index] ? index : index - 1;
        if (clearIndex >= 0) {
          // Remove the character and shift everything after it one step left,
          // keeping the array left-packed so there are never gaps between filled cells.
          const nextValues = [...valuesRef.current];
          nextValues.splice(clearIndex, 1);
          nextValues.push('');
          updateValues(nextValues);
        }
        focusCell(index - 1);
      } else if (e.key === 'Delete') {
        e.preventDefault();
        const nextValues = [...valuesRef.current];
        nextValues[index] = '';
        updateValues(nextValues);
      }
    },
    [updateValues, focusCell]
  );

  const onCellFocus = useCallback(
    (index: number) => {
      // Redirect clicks that land beyond the first empty slot.
      const firstEmpty = valuesRef.current.indexOf('');
      const lastAllowed = firstEmpty === -1 ? cellCount - 1 : firstEmpty;
      if (index > lastAllowed) {
        cellRefs.current[lastAllowed]?.focus();
        return;
      }
      cellRefs.current[index]?.select();
    },
    [cellCount]
  );

  const onCellPaste = useCallback(
    (index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
      e.preventDefault();
      const text = sanitize(e.clipboardData.getData('text').replace(/\s/g, ''));
      const nextValues = [...valuesRef.current];
      let lastFilled = index;
      for (let i = 0; i < text.length && index + i < cellCount; i++) {
        nextValues[index + i] = text[i];
        lastFilled = index + i;
      }
      updateValues(nextValues, true);
      focusCell(lastFilled + 1);
    },
    [updateValues, focusCell, cellCount, sanitize]
  );

  const ctx = useMemo(
    () => ({
      values,
      size: size ?? 'md',
      inputMode,
      placeholder,
      masked,
      disabled,
      invalid,
      rootId: id,
      setCellRef,
      focusCell,
      onCellChange,
      onCellKeyDown,
      onCellFocus,
      onCellPaste,
    }),
    [
      values,
      size,
      inputMode,
      placeholder,
      masked,
      disabled,
      invalid,
      id,
      setCellRef,
      focusCell,
      onCellChange,
      onCellKeyDown,
      onCellFocus,
      onCellPaste,
    ]
  );

  return (
    <OTPInputContext value={ctx}>
      <div
        ref={ref}
        data-invalid={invalid || undefined}
        data-disabled={disabled || undefined}
        className={cn('flex items-center gap-2', className)}
        {...props}
      >
        <InstanceCounterProvider>{children}</InstanceCounterProvider>
      </div>
    </OTPInputContext>
  );
};

const otpCellStyle = compose(
  inputStyle,
  cva({
    base: 'shrink-0 px-0 text-center',
    variants: {
      size: { xs: 'w-6', sm: 'w-8', md: 'w-10', lg: 'w-12' },
    },
  })
);

interface OTPInputCellProps
  extends Omit<
    React.ComponentPropsWithRef<'input'>,
    // All of these are managed by the root via context and cannot be overridden per-cell.
    | 'value'
    | 'type'
    | 'inputMode'
    | 'autoComplete'
    | 'placeholder'
    | 'disabled'
    | 'maxLength'
    | 'onChange'
    | 'onKeyDown'
    | 'onFocus'
    | 'onPaste'
  > {}

const OTPInputCell = ({ ref, className, ...props }: OTPInputCellProps) => {
  const index = useInstanceCounter();
  const {
    values,
    size,
    inputMode,
    placeholder,
    masked,
    disabled,
    invalid,
    rootId,
    setCellRef,
    onCellChange,
    onCellKeyDown,
    onCellFocus,
    onCellPaste,
  } = useOTPInputContext();

  const internalRef = useCallback(
    (el: HTMLInputElement | null) => setCellRef(index, el),
    [setCellRef, index]
  );

  return (
    <input
      ref={composeRefs(ref, internalRef)}
      id={index === 0 ? rootId : undefined}
      type={masked ? 'password' : 'text'}
      inputMode={inputMode}
      autoComplete="one-time-code"
      placeholder={placeholder}
      // maxLength={2} instead of 1: allows the browser to hold the previous
      // character alongside the newly typed one so onCellChange can read both
      // and extract just the latest via slice(-1).
      maxLength={2}
      value={values[index] ?? ''}
      disabled={disabled}
      data-invalid={invalid || undefined}
      aria-label={`Digit ${index + 1}`}
      {...props}
      className={cn(otpCellStyle({ size }), className)}
      onChange={(e) => onCellChange(index, e.target.value)}
      onKeyDown={(e) => onCellKeyDown(index, e)}
      onFocus={() => onCellFocus(index)}
      onPaste={(e) => onCellPaste(index, e)}
    />
  );
};

interface OTPInputHiddenProps
  extends Omit<React.ComponentPropsWithoutRef<'input'>, 'type' | 'value'> {}

const OTPInputHidden = ({ name, ...props }: OTPInputHiddenProps) => {
  const { values } = useOTPInputContext();
  return <input type="hidden" name={name} value={values.join('')} {...props} />;
};

const OTPInputSeparator = ({
  className,
  children,
  ...props
}: React.ComponentPropsWithoutRef<'div'>) => {
  return (
    <div
      aria-hidden="true"
      className={cn('text-foreground-secondary', className)}
      {...props}
    >
      {children ?? '—'}
    </div>
  );
};

const CompoundOTPInput = Object.assign(OTPInput, {
  Cell: OTPInputCell,
  Separator: OTPInputSeparator,
  Hidden: OTPInputHidden,
});

export type { OTPInputProps };
export { CompoundOTPInput as OTPInput };

Each cell is a real <input> element. The root manages all state, keyboard navigation, and focus logic — cells and separators are purely presentational, composed freely as children.

Anatomy


          <OTPInput>
  <OTPInput.Hidden name="otp" />
  <OTPInput.Cell />
  <OTPInput.Separator />
  <OTPInput.Cell />
</OTPInput>
        

API Reference

OTPInput

Extends the div element.

Prop Default Type Description
value - string The controlled value of the OTP input.
defaultValue - string The initial value when used as an uncontrolled component.
onChange - (value: string) => void Callback fired on every change.
onFill - (value: string) => void Callback fired when all cells are filled via paste or autofill. Does not fire on character-by-character typing.
size "md" "xs""sm""md""lg" The size applied to all cells.
inputMode "numeric" React.HTMLAttributes<HTMLInputElement>['inputMode'] The inputMode applied to all cells. Use "text" for alphanumeric codes.
placeholder - string Placeholder character shown in each empty cell.
masked - boolean Whether to mask the entered values like a password field.
disabled - boolean Whether all cells are disabled.
invalid - boolean Whether the input is in an invalid state.

OTPInput.Hidden

Extends the input element, omitting type and value which are managed internally. Only needed when the OTP field is inside a <form>.

OTPInput.Cell

Extends the input element.

OTPInput.Separator

Extends the div element. Renders an em dash by default, overridable via children.

Examples

Simple

import { OTPInput } from '@/components/otp-input';

export default function OTPInputExample() {
  return (
    <OTPInput>
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
    </OTPInput>
  );
}

With Separator

Use OTPInput.Separator to visually split cells into groups. It renders an em dash by default but accepts any children.

import { OTPInput } from '@/components/otp-input';

export default function OTPInputSeparatorExample() {
  return (
    <OTPInput>
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Separator />
      <OTPInput.Cell />
      <OTPInput.Cell />
    </OTPInput>
  );
}

With Label

Pass id to OTPInput and the same value as htmlFor on the Label. The id is automatically forwarded to the first cell, so clicking the label focuses it.

import { Label } from '@/components/label';
import { OTPInput } from '@/components/otp-input';

export default function OTPInputLabelExample() {
  return (
    <div className="flex flex-col gap-2">
      <Label htmlFor="otp">Verification code</Label>
      <OTPInput id="otp">
        <OTPInput.Cell />
        <OTPInput.Cell />
        <OTPInput.Separator />
        <OTPInput.Cell />
        <OTPInput.Cell />
      </OTPInput>
    </div>
  );
}

Placeholder

A single character shown in empty cells via the native placeholder attribute.

import { OTPInput } from '@/components/otp-input';

export default function OTPInputPlaceholderExample() {
  return (
    <OTPInput placeholder="○">
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Separator />
      <OTPInput.Cell />
      <OTPInput.Cell />
    </OTPInput>
  );
}

Masked

Switches each cell to type="password" so entered characters are hidden.

import { OTPInput } from '@/components/otp-input';

export default function OTPInputMaskedExample() {
  return (
    <OTPInput masked>
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
    </OTPInput>
  );
}

Alphanumeric

Set inputMode="text" for codes that include letters. This switches the mobile keyboard to the full keyboard and lifts the digit-only restriction.

import { OTPInput } from '@/components/otp-input';

export default function OTPInputAlphanumericExample() {
  return (
    <OTPInput inputMode="text">
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Separator />
      <OTPInput.Cell />
      <OTPInput.Cell />
    </OTPInput>
  );
}

Disabled

import { OTPInput } from '@/components/otp-input';

export default function OTPInputDisabledExample() {
  return (
    <OTPInput disabled>
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
    </OTPInput>
  );
}

Invalid

import { OTPInput } from '@/components/otp-input';

export default function OTPInputInvalidExample() {
  return (
    <OTPInput defaultValue="123456" invalid>
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
      <OTPInput.Cell />
    </OTPInput>
  );
}

Form

Use OTPInput.Hidden inside a <form> to expose the value as a named field for native form submission.

import { useState } from 'react';

import { Button } from '@/components/button';
import { OTPInput } from '@/components/otp-input';

const LENGTH = 6;

export default function OTPInputFormExample() {
  const [value, setValue] = useState('');
  const [submitted, setSubmitted] = useState<string | null>(null);
  const [invalid, setInvalid] = useState(false);

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (value.length < LENGTH) {
      setInvalid(true);
      return;
    }
    const data = new FormData(e.currentTarget);
    setSubmitted(data.get('otp') as string);
  }

  function handleChange(v: string) {
    setValue(v);
    if (invalid) setInvalid(false);
  }

  return (
    <form onSubmit={handleSubmit} className="flex flex-col items-start gap-4">
      <OTPInput onChange={handleChange} invalid={invalid}>
        <OTPInput.Hidden name="otp" />
        <OTPInput.Cell />
        <OTPInput.Cell />
        <OTPInput.Cell />
      </OTPInput>
      <Button type="submit">Verify</Button>
      {submitted !== null && (
        <p className="text-foreground-secondary text-sm">
          Submitted: <span className="text-foreground">{submitted}</span>
        </p>
      )}
    </form>
  );
}

Password Manager

Enter verification code

Open 1Password and autofill the 6-digit code.

import { useState } from 'react';
import { OTPInput } from '@/components/otp-input';

export default function OTPInputPasswordManagerExample() {
  const [value, setValue] = useState('');
  const [submitted, setSubmitted] = useState(false);

  const handleComplete = (v: string) => {
    setSubmitted(true);
    setValue(v);
  };

  return (
    <form
      className="flex flex-col items-center gap-6"
      onSubmit={(e) => e.preventDefault()}
    >
      <div className="flex flex-col items-center gap-1 text-center">
        <p className="font-medium text-foreground text-sm">
          Enter verification code
        </p>
        <p className="text-foreground-secondary text-xs">
          Open 1Password and autofill the 6-digit code.
        </p>
      </div>

      <OTPInput value={value} onChange={setValue} onFill={handleComplete}>
        <OTPInput.Cell />
        <OTPInput.Cell />
        <OTPInput.Separator />
        <OTPInput.Cell />
        <OTPInput.Cell />
      </OTPInput>

      {submitted && (
        <p className="text-foreground-secondary text-xs">
          Filled: <span className="font-mono text-foreground">{value}</span>
        </p>
      )}
    </form>
  );
}

Previous

Modal

Next

Popover