Skip to content

OTP Input

A verification-code input that looks like separate boxes but is one real field. That keeps paste, SMS autofill, password managers and screen readers working as they do with any text field.

Enter the 6-digit code we sent to your phone.

Installation

Add OTP Input to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.

Terminal
npx ck-ui-cli@latest add otp-input
Adds
OTPInput.tsxField.tsxLabel.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@radix-ui/react-label@radix-ui/react-slotif your project does not have them yet

First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.

Manual installationInstall the packages and copy the files yourself.

Install the following dependencies

Run these commands in your terminal to install the required packages

Terminal
npm install @radix-ui/react-slot @radix-ui/react-label

Add the Field component

OTPInput imports ./Field, so add this file next to it

/components/ui/Field.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";

/*
 * Field wires a label, description and message to one form control:
 *   Field.Label        → <label for={controlId}>
 *   Field.Description  → referenced by aria-describedby
 *   Field.Message      → referenced by aria-describedby (error text when invalid)
 *   invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
 * Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
 * useFieldControlProps. It is plain React state: it works with native forms and form libraries.
 */

type FieldContextValue = {
    controlId: string;
    labelId: string;
    descriptionId: string;
    messageId: string;
    invalid: boolean;
    required: boolean;
    disabled: boolean;
    hasDescription: boolean;
    hasMessage: boolean;
    setHasDescription: (value: boolean) => void;
    setHasMessage: (value: boolean) => void;
};

const FieldContext = React.createContext<FieldContextValue | null>(null);

export function useField() {
    return React.useContext(FieldContext);
}

/**
 * Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
 * this lets each Field.Root reflect that state (label styling, `disabled` on the control).
 */
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);

export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Id for the control; generated when omitted. */
    id?: string;
    invalid?: boolean;
    required?: boolean;
    disabled?: boolean;
}

const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
    const fieldset = React.useContext(FieldsetContext);
    const disabled = disabledProp || Boolean(fieldset?.disabled);
    const generatedId = React.useId();
    const controlId = id ?? `field-${generatedId}`;
    const [hasDescription, setHasDescription] = React.useState(false);
    const [hasMessage, setHasMessage] = React.useState(false);

    const value = React.useMemo<FieldContextValue>(
        () => ({
            controlId,
            labelId: `${controlId}-label`,
            descriptionId: `${controlId}-description`,
            messageId: `${controlId}-message`,
            invalid,
            required,
            disabled,
            hasDescription,
            hasMessage,
            setHasDescription,
            setHasMessage,
        }),
        [controlId, invalid, required, disabled, hasDescription, hasMessage]
    );

    return (
        <FieldContext.Provider value={value}>
            <div
                ref={ref}
                className={cn("flex flex-col gap-1.5", className)}
                data-invalid={invalid || undefined}
                data-disabled={disabled || undefined}
                {...props}
            >
                {children}
            </div>
        </FieldContext.Provider>
    );
});
Root.displayName = "Field.Root";

type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;

const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
    const field = useField();

    return (
        <Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
            {children}
            {field?.required && <Label.Asterisk />}
        </Label.Root>
    );
});
FieldLabel.displayName = "Field.Label";

const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
    const field = useField();
    const setHasDescription = field?.setHasDescription;

    React.useEffect(() => {
        if (!setHasDescription) return;
        setHasDescription(true);
        return () => setHasDescription(false);
    }, [setHasDescription]);

    return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";

export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
    /** Only render while the field is invalid (default true). */
    showWhenValid?: boolean;
}

const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
    const field = useField();
    const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
    const setHasMessage = field?.setHasMessage;

    React.useEffect(() => {
        if (!setHasMessage || !visible) return;
        setHasMessage(true);
        return () => setHasMessage(false);
    }, [setHasMessage, visible]);

    if (!visible) return null;

    return (
        <p
            ref={ref}
            id={field?.messageId}
            aria-live="polite"
            className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
            {...props}
        >
            {children}
        </p>
    );
});
Message.displayName = "Field.Message";

type ControlAttributes = {
    id?: string;
    "aria-describedby"?: string;
    "aria-invalid"?: React.AriaAttributes["aria-invalid"];
    "aria-required"?: React.AriaAttributes["aria-required"];
    required?: boolean;
    disabled?: boolean;
};

/**
 * Merges Field context into a control's props. Explicit props win; describedby ids are combined.
 */
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
    const field = useField();
    const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");

    const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
        .filter(Boolean)
        .join(" ");

    return {
        ...props,
        id: props.id ?? field?.controlId,
        "aria-describedby": describedBy || undefined,
        "aria-invalid": invalid || undefined,
        required: props.required ?? (field?.required || undefined),
        disabled: props.disabled ?? (field?.disabled || undefined),
    };
}

/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
    const controlProps = useFieldControlProps(props);
    return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";

export { Root, FieldLabel as Label, Description, Message, Control };

Add the Label component

OTPInput imports ./Label, so add this file next to it

/components/ui/Label.tsx
"use client";

import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";

export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
    /** Dims the label. The control itself must also be disabled. */
    disabled?: boolean;
}

const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
    return (
        <LabelPrimitive.Root
            ref={ref}
            data-disabled={disabled || undefined}
            className={cn(
                "group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
                "data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
                className
            )}
            {...props}
        />
    );
});
Root.displayName = "Label.Root";

// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
    <span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
        {children || "*"}
    </span>
));
Asterisk.displayName = "Label.Asterisk";

const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
    <span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";

export { Root, Asterisk, Sub };

Create or update your OTPInput component

Copy this code to create your OTPInput component

/components/ui/OTPInput.tsx
"use client";

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

/*
 * One-time code input. There is exactly ONE real <input> (autocomplete="one-time-code"), laid over a row of
 * visual slots that are hidden from assistive technology. Screen readers, SMS autofill, password managers,
 * paste and deletion all work with that single field; the slots only draw its value and caret.
 *   - Typing fills the next slot; on a filled slot the character is replaced.
 *   - Pasting a full code replaces the value; separators like spaces or dashes are dropped.
 *   - Backspace deletes, ArrowLeft/ArrowRight/Home/End move between slots.
 */

export type OTPInputMode = "numeric" | "alphanumeric";
export type OTPInputSize = "sm" | "md" | "lg";

export interface OTPInputProps
    extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "size" | "maxLength" | "type" | "pattern"> {
    /** Number of characters. @default 6 */
    length?: number;
    /** Allowed characters. @default "numeric" */
    mode?: OTPInputMode;
    value?: string;
    defaultValue?: string;
    onValueChange?: (value: string) => void;
    /** Called when the last slot is filled. */
    onComplete?: (value: string) => void;
    /** Draw a separator after every N slots (purely visual), e.g. 3 → "123 – 456". */
    groupSize?: number;
    size?: OTPInputSize;
    invalid?: boolean;
    /** Classes for the outer wrapper. */
    className?: string;
}

const slotSizes: Record<OTPInputSize, string> = {
    sm: "h-8 w-7 size-p-sm",
    md: "h-10 w-9 size-p-md",
    lg: "h-12 w-11 size-p-lg",
};

const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

export function sanitizeOTP(text: string, mode: OTPInputMode) {
    return mode === "numeric" ? text.replace(/\D/g, "") : text.replace(/[^A-Za-z0-9]/g, "");
}

const OTPInput = React.forwardRef<HTMLInputElement, OTPInputProps>(
    (
        {
            length = 6,
            mode = "numeric",
            value: valueProp,
            defaultValue = "",
            onValueChange,
            onComplete,
            groupSize,
            size = "md",
            invalid,
            disabled,
            className,
            id,
            "aria-describedby": ariaDescribedBy,
            onFocus,
            onBlur,
            onKeyDown,
            onPaste,
            ...props
        },
        forwardedRef
    ) => {
        const [uncontrolled, setUncontrolled] = React.useState(() => sanitizeOTP(defaultValue, mode).slice(0, length));
        const isControlled = valueProp !== undefined;
        const value = isControlled ? sanitizeOTP(valueProp, mode).slice(0, length) : uncontrolled;
        const [focused, setFocused] = React.useState(false);
        const [selection, setSelection] = React.useState<[number, number]>([value.length, value.length]);
        const inputRef = React.useRef<HTMLInputElement | null>(null);
        const slotsRef = React.useRef<HTMLDivElement | null>(null);
        const pendingSelection = React.useRef<[number, number] | null>(null);
        // Deferred caret placement after focus/click is cancelled as soon as the user types, pastes or presses a key.
        const placementToken = React.useRef(0);
        const placeLater = (index: () => number) => {
            const token = ++placementToken.current;
            requestAnimationFrame(() => {
                if (token === placementToken.current) select(index());
            });
        };
        const cancelPlacement = () => {
            placementToken.current += 1;
        };

        const controlProps = useFieldControlProps({ id, "aria-describedby": ariaDescribedBy, disabled, required: props.required }, { invalid });
        const isInvalid = Boolean(controlProps["aria-invalid"]);
        const isDisabled = Boolean(controlProps.disabled);

        const setRefs = (node: HTMLInputElement | null) => {
            inputRef.current = node;
            if (typeof forwardedRef === "function") forwardedRef(node);
            else if (forwardedRef) forwardedRef.current = node;
        };

        /** Caret at `index`; on a filled slot the character is selected so the next key replaces it. */
        const select = React.useCallback(
            (index: number, current = value) => {
                const position = Math.max(0, Math.min(index, current.length, length));
                const range: [number, number] = position < current.length ? [position, position + 1] : [position, position];
                const input = inputRef.current;
                if (input && document.activeElement === input) input.setSelectionRange(range[0], range[1]);
                setSelection(range);
            },
            [value, length]
        );

        // Apply a caret position after React has written a new value into the input.
        useIsomorphicLayoutEffect(() => {
            const pending = pendingSelection.current;
            const input = inputRef.current;
            if (!pending || !input) return;
            pendingSelection.current = null;
            if (document.activeElement === input) input.setSelectionRange(pending[0], pending[1]);
            setSelection(pending);
        });

        const update = (next: string, caret: number) => {
            if (!isControlled) setUncontrolled(next);
            if (next !== value) {
                onValueChange?.(next);
                if (next.length === length && value.length !== length) onComplete?.(next);
            }
            const position = Math.min(caret, length);
            pendingSelection.current = position < next.length ? [position, position + 1] : [position, position];
        };

        const syncSelection = () => {
            const input = inputRef.current;
            if (!input) return;
            setSelection([input.selectionStart ?? value.length, input.selectionEnd ?? value.length]);
        };

        const activeIndex = Math.min(selection[0], length - 1);
        const slotIndexAt = (clientX: number) => {
            const slots = Array.from(slotsRef.current?.querySelectorAll<HTMLElement>("[data-slot]") ?? []);
            const hit = slots.findIndex((slot) => {
                const rect = slot.getBoundingClientRect();
                return clientX >= rect.left && clientX <= rect.right;
            });
            return hit === -1 ? value.length : hit;
        };

        return (
            <div className={cn("relative inline-flex w-fit", isDisabled && "cursor-not-allowed opacity-50", className)} data-invalid={isInvalid || undefined}>
                <input
                    ref={setRefs}
                    type="text"
                    id={controlProps.id}
                    inputMode={mode === "numeric" ? "numeric" : "text"}
                    pattern={mode === "numeric" ? "[0-9]*" : "[A-Za-z0-9]*"}
                    autoComplete="one-time-code"
                    autoCapitalize={mode === "numeric" ? "off" : "characters"}
                    autoCorrect="off"
                    spellCheck={false}
                    maxLength={length}
                    value={value}
                    disabled={isDisabled}
                    required={controlProps.required}
                    aria-invalid={isInvalid || undefined}
                    aria-describedby={controlProps["aria-describedby"]}
                    onChange={(event) => {
                        cancelPlacement();
                        const input = event.target;
                        const next = sanitizeOTP(input.value, mode).slice(0, length);
                        // Caret after the change = allowed characters typed before it.
                        const caret = sanitizeOTP(input.value.slice(0, input.selectionStart ?? input.value.length), mode).length;
                        update(next, caret);
                    }}
                    onPaste={(event) => {
                        cancelPlacement();
                        onPaste?.(event);
                        if (event.defaultPrevented) return;
                        event.preventDefault();
                        const pasted = sanitizeOTP(event.clipboardData.getData("text"), mode);
                        if (!pasted) return;
                        const [start, end] = selection;
                        // A full code replaces everything; a partial one is inserted at the caret.
                        const next = pasted.length >= length ? pasted.slice(0, length) : (value.slice(0, start) + pasted + value.slice(end)).slice(0, length);
                        update(next, pasted.length >= length ? length : start + pasted.length);
                    }}
                    onKeyDown={(event) => {
                        cancelPlacement();
                        onKeyDown?.(event);
                        if (event.defaultPrevented) return;
                        const moves: Record<string, number> = {
                            ArrowLeft: activeIndex - 1,
                            ArrowRight: selection[0] < value.length ? selection[0] + 1 : selection[0],
                            Home: 0,
                            End: value.length,
                        };
                        if (event.key in moves) {
                            event.preventDefault();
                            select(moves[event.key]);
                        }
                    }}
                    onFocus={(event) => {
                        onFocus?.(event);
                        setFocused(true);
                        // Start on the first empty slot (or the last one when the code is complete).
                        placeLater(() => (value.length >= length ? length - 1 : value.length));
                    }}
                    onBlur={(event) => {
                        onBlur?.(event);
                        setFocused(false);
                    }}
                    onSelect={syncSelection}
                    onPointerUp={(event) => {
                        if (isDisabled) return;
                        const index = slotIndexAt(event.clientX);
                        placeLater(() => index);
                    }}
                    className={cn(
                        "absolute inset-0 z-10 h-full w-full cursor-text appearance-none border-0 bg-transparent p-0 text-base text-transparent caret-transparent",
                        "outline-none selection:bg-transparent focus:outline-none focus:ring-0 disabled:cursor-not-allowed"
                    )}
                    {...props}
                />
                <div ref={slotsRef} aria-hidden="true" className="flex items-center gap-2">
                    {Array.from({ length }, (_, index) => {
                        const char = value[index] ?? "";
                        const isActive = focused && index === activeIndex;
                        return (
                            <React.Fragment key={index}>
                                <div
                                    data-slot=""
                                    data-active={isActive || undefined}
                                    data-filled={char ? "" : undefined}
                                    className={cn(
                                        "relative flex items-center justify-center rounded-lg border font-medium tabular-nums text-foreground shadow-x-small transition-[border-color,box-shadow] duration-fast ease-standard",
                                        slotSizes[size],
                                        // The active slot gets the field focus treatment: a strong border (destructive stays) plus a soft halo.
                                        "outline outline-0 outline-offset-2 data-[active]:outline-2",
                                        isInvalid ? "border-destructive outline-destructive/25" : "border-input outline-accent data-[active]:border-foreground"
                                    )}
                                >
                                    {char}
                                    {isActive && !char && <span className="h-4 w-px animate-pulse bg-foreground motion-reduce:animate-none" />}
                                </div>
                                {groupSize && (index + 1) % groupSize === 0 && index < length - 1 && <span className="text-muted-foreground">–</span>}
                            </React.Fragment>
                        );
                    })}
                </div>
            </div>
        );
    }
);
OTPInput.displayName = "OTPInput.Root";

export { OTPInput as Root };

Test Your Component

  • 1. Add the component to a test page
  • 2. Check all props and variants work as expected
  • 3. Verify responsive behavior
  • 4. Test with different content/data
  • 5. Ensure accessibility features work correctly

Grouped

groupSize draws a separator between groups; it is visual only.

Paste “123 456” or “123-456”: separators are ignored.

Alphanumeric

mode="alphanumeric" accepts letters and digits and uses the full keyboard; length sets the number of slots.

Letters and numbers, as printed on your recovery sheet.

Verification

onComplete fires when the last slot is filled. Show errors with Field.Root invalid and Field.Message. The demo code is 424242.

Accessibility

  • One labelled <input> with autocomplete="one-time-code", maxlength and a numeric inputmode (mobile number pad). The boxes are drawn from its value and hidden from assistive technology.
  • Screen readers hear one field with the code typed so far, not a set of unrelated one-character inputs.
  • Paste: a full code (separators are dropped) replaces the value; a partial code is inserted at the caret. Backspace deletes; ArrowLeft/ArrowRight/Home/End move between boxes, and a filled box is selected so the next key replaces it.
  • Field wiring: label, description, message, aria-invalid, required and disabled.

API Reference

Import with import * as OTPInput from "@/components/ui/OTPInput". Other input attributes (name, form…) go to the input; ref points at it.

OTPInput.Root

PropTypeDefaultDescription
length"number""6"

Number of characters.

mode
"numeric""alphanumeric"
"numeric"

Allowed characters and keyboard.

value / defaultValue"string"""""

The code.

onValueChange"(value: string) => void""undefined"

Called on every change.

onComplete"(value: string) => void""undefined"

Called when the last slot is filled.

groupSize"number""undefined"

Visual separator after every N slots.

size
"sm""md""lg"
"md"

Slot size.

invalid / disabled / required"boolean""from Field"

State; also read from Field.Root.

className"string""undefined"

Classes for the wrapper.

Used in these blocks

Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.