Input OTP
A one-time-passcode input on the input-otp primitive - individual slots, a blinking caret, an active-slot ring and aria-invalid danger styling.
"use client"
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp"
export default function InputOTPDemo() {
return (
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/input-otpThe input-otp dependency, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install input-otp@^1.4.2 lucide-react@^1.23.0 tw-animate-css@^1.4.0The component depends on input-otp for the hidden input and slot state and lucide-react for the separator icon.
Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/input-otp.tsx"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
aria-label="One-time password"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-field-border text-sm shadow-xs outline-none transition-all duration-(--motion-fast) first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-danger data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-danger data-[active=true]:aria-invalid:ring-danger/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-danger/40",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000 motion-reduce:animate-none" />
</div>
)}
</div>
)
}
function InputOTPSeparator(props: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<Minus className="size-4" />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the active-slot ring and danger colors will be missing.
Usage
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp"
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>Set maxLength on InputOTP and render one InputOTPSlot per character, each with a matching index.
Examples
With separator
Two groups of three with an InputOTPSeparator between them is the familiar six-digit layout.
Digits only
Pass pattern={REGEXP_ONLY_DIGITS} from input-otp so only numbers can be typed.
Disabled
The disabled prop blocks typing and dims the container to 50 percent.
Invalid
Mark each InputOTPSlot with aria-invalid to switch the borders and rings to the ai2 danger color when a code is wrong.
Props
InputOTP forwards every input-otp prop; index is set on each InputOTPSlot.
| Prop | Type | Default | Description |
|---|---|---|---|
maxLength | number | - | Total number of characters the passcode holds. It must equal the number of InputOTPSlot elements you render across all groups. |
value | string | undefined | Controlled value on InputOTP. Pair with onChange to drive the input from React state; omit both for uncontrolled behavior. |
onChange | (value: string) => void | undefined | Called on InputOTP with the full string whenever the value changes, so you can validate or auto-submit once it reaches maxLength. |
pattern | string | undefined | Regex source string that restricts what can be typed. Import REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS or REGEXP_ONLY_DIGITS_AND_CHARS from input-otp, or pass your own. |
containerClassName | string | undefined | Classes for the wrapper around the slots and hidden input. The default lays out the groups in a row with gap-2 and dims to 50 percent when disabled. |
disabled | boolean | false | Disables the hidden input, blocks typing and dims the whole container to 50 percent opacity. |
index | number | - | Set on each InputOTPSlot to bind it to a position in the value. Slots read char, caret and active state from the input context by this index. |
ai2 Input OTP: a one-time-passcode input for React
The ai2 Input OTP is a shadcn-compatible one-time-passcode input for React, built on the input-otp primitive and styled with Tailwind CSS v4. It splits a verification code into individual character slots with a blinking caret and an active-slot ring, ideal for two-factor authentication, email confirmation and phone verification flows.
It ships through the shadcn registry format, so one CLI command, an MCP agent, or a copy-paste puts the source in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Input OTP?
It is a four-part composition: InputOTP, InputOTPGroup, InputOTPSlot and InputOTPSeparator. The anatomy matches shadcn/ui exactly, so existing snippets and AI agents keep working without changes.
Under the hood it is input-otp, which renders one hidden input and mirrors each character into a slot. You set maxLength on the root and render that many InputOTPSlot elements, each with an index. The active slot gets a ring and a blinking caret, and any slot marked aria-invalid switches to the ai2 danger border and ring.
Why use it
- One hidden input, real slots: input-otp keeps a single hidden input for the browser and screen readers while mirroring each character into a styled slot, so paste, autofill and mobile SMS autofill all work.
- Active-slot ring and caret: The slot the cursor is on lifts with the ring color and shows a blinking fake caret, so the focus position is always visible even though the real input is hidden.
- Grouping and separators: Wrap slots in InputOTPGroup to round the outer corners of each block, and drop an InputOTPSeparator between groups for the familiar 3 plus 3 code layout.
- Pattern restriction: Pass a pattern from input-otp (REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS, REGEXP_ONLY_DIGITS_AND_CHARS) or your own regex source to block invalid characters as the user types.
- Invalid styling from tokens: Mark a slot aria-invalid and it switches to the ai2 danger border and ring, the same invalid look every other ai2 form control uses.
Features
- shadcn registry install: One command adds the component, the input-otp dependency and the @ai2/tokens theme to your project.
- Blinking caret: The active slot renders a one-pixel caret with the animate-caret-blink keyframe, so the cursor reads like a native text field.
- Controlled or uncontrolled: Pass value and onChange to drive the code from React state and auto-submit at maxLength, or leave them off for uncontrolled use.
- Disabled state: The disabled prop blocks input and dims the container to 50 percent via has-disabled on the wrapper.
- Data attributes for styling: Every part exposes data-slot and each slot exposes data-active, so you can restyle the focused slot from CSS without forking the component.
- TypeScript source: The file you install is typed end to end and forwards every input-otp prop, so autocomplete covers the full API.
Production tips
- Match maxLength to the slot count: The number you pass to maxLength must equal the total number of InputOTPSlot elements across every group, or the extra slots stay permanently empty.
- Restrict digits for numeric codes: Most verification codes are numeric. Pass pattern={REGEXP_ONLY_DIGITS} so letters are rejected as the user types instead of failing validation later.
- Auto-submit on completion: In the onChange handler, submit when the value length reaches maxLength so the user does not have to press a separate button after the last digit.
- Group for readability: Split a six-digit code into two InputOTPGroup blocks of three with an InputOTPSeparator between them; it reads faster than one long run of slots.
- Set aria-invalid on the slots: When the code is wrong, mark each InputOTPSlot aria-invalid to turn the borders and rings the ai2 danger color, matching the rest of the form.
Works with the rest of ai2
The input works well inside the rest of the registry. Wrap it in an ai2 Field with a label and helper text, place it in a Card for a verification step, and confirm with an ai2 Button.
For a single free-form value use the Input instead; the OTP input is specifically for fixed-length codes split across slots. Everything reads one token source, so the invalid and focus states match in both light and dark mode.