Primitives
Input OTP
One-time-code field with grouped, focus-aware slots.
Composition
Custom digit groups
Pass length and separator to generate the slots automatically, or compose indexed slots as children.
- <InputOTP>
- first digit group
- <InputOTPSlot />
- <InputOTPSeparator />
- remaining digits
- <InputOTPSlot />
- first digit group
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
The Control UI source installs this primitive from src/registry/sources/control-ui/ui/input-otp.tsx.
npx shadcn@latest add https://control-ui.dev/r/input-otp.jsonRaw code
Primary installed source
"use client";import { OTPField as OTPFieldPrimitive } from "@base-ui/react/otp-field";import type { ComponentProps, CSSProperties, ReactNode } from "react";import { createContext, Fragment, useContext, useId } from "react";import type { FieldKnobStyle } from "@/components/control-ui/knob-contracts/field-knobs";import { cn } from "@/components/control-ui/lib/cn";export type InputOTPProps = { length?: number; separator?: boolean; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; disabled?: boolean; readOnly?: boolean; required?: boolean; name?: string; mask?: boolean; id?: string; className?: string; children?: ReactNode; "aria-label"?: string; "aria-describedby"?: string;} & { style?: CSSProperties & FieldKnobStyle };export type InputOTPSlotProps = { index: number; length?: number; disabled?: boolean; placeholder?: string; className?: string; style?: CSSProperties & FieldKnobStyle;};export type InputOTPSeparatorProps = ComponentProps<"div"> & { style?: CSSProperties & FieldKnobStyle };const InputOTPLabelContext = createContext<string | undefined>(undefined);function slotAriaLabel(index: number, length: number | undefined): string | undefined { if (index === 0) return undefined; return length ? `Character ${index + 1} of ${length}` : `Character ${index + 1}`;}export function InputOTPSlot({ index, length, className, ...props }: InputOTPSlotProps) { const labelId = useContext(InputOTPLabelContext); return ( <OTPFieldPrimitive.Input data-control-ui="input-otp" data-field-kind="input-otp" data-slot="slot" data-control-family="field" data-control="true" aria-label={slotAriaLabel(index, length)} aria-labelledby={index === 0 ? labelId : undefined} className={cn("relative flex size-control-md items-center justify-center", className)} {...props} /> );}export function InputOTPSeparator({ className, ...props }: InputOTPSeparatorProps) { return ( <OTPFieldPrimitive.Separator data-control-ui="input-otp" data-control-family="field" data-field-kind="input-otp" data-slot="separator" className={cn("shrink-0", className)} {...props} /> );}export function InputOTP({ length = 6, separator = false, className, children, "aria-label": ariaLabel, ...props }: InputOTPProps) { const labelId = useId(); const labelledBy = ariaLabel ? labelId : undefined; return ( <InputOTPLabelContext.Provider value={labelledBy}> {ariaLabel ? ( <span id={labelId} hidden> {ariaLabel} </span> ) : null} <OTPFieldPrimitive.Root data-control-ui="input-otp" data-control-family="field" data-field-kind="input-otp" data-slot="root" length={length} aria-labelledby={labelledBy} className={cn("flex items-center", className)} {...props} > {children ?? Array.from({ length }, (_, index) => `input-otp-slot-${index}`).map((slotKey, index) => ( <Fragment key={slotKey}> {separator && index === Math.ceil(length / 2) ? <InputOTPSeparator /> : null} <InputOTPSlot index={index} length={length} /> </Fragment> ))} </OTPFieldPrimitive.Root> </InputOTPLabelContext.Provider> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-field-* · 15 knobsHow the cascade resolves--cui-field-radius<length-percentage>var(--nest-radius, var(--radius-control))--cui-field-background<color>var(--control-fill)--cui-field-hover-background<color>var(--hover-fill)--cui-field-affordance-background<color>transparent--cui-field-affordance-shadow*0 0 transparent--cui-field-affordance-foreground<color>var(--muted-foreground)--cui-field-affordance-hover-background<color>var(--hover-fill)--cui-field-foreground<color>var(--foreground)--cui-field-border-color<color>var(--control-rim)--cui-field-border-width<length>var(--control-rim-width)--cui-field-shadow*var(--shadow-inset)--cui-field-backdrop-filter*blur(0px)--cui-field-height<length-percentage>var(--control-h-md)--cui-field-padding-inline<length-percentage>var(--padding-x)--cui-field-font-size<length-percentage>var(--text-body)