A one-time-code field with grouped slots, separator, caret, paste and autofill support, invalid state and reduced-motion-safe animation, on a single real input.
Enter the 6 digits we sent to j••••@acme.example. Try 123456.
"use client";
import * as React from "react";
import { ShieldCheck } from "lucide-react";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ballmac/input-otp";
export default function InputOtpDemo() {
const [value, setValue] = React.useState("");
const [status, setStatus] = React.useState<"idle" | "error" | "ok">("idle");
return (
<div className="grid w-full max-w-sm gap-4 rounded-xl border bg-card p-5 shadow-sm">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-lg bg-muted">
<ShieldCheck aria-hidden="true" className="size-5" />
</span>
<div>
<label htmlFor="otp-code" className="text-sm font-semibold">Verification code</label>
<p id="otp-hint" className="text-xs text-muted-foreground">Enter the 6 digits we sent to j••••@acme.example. Try 123456.</p>
</div>
</div>
<InputOTP
id="otp-code"
maxLength={6}
value={value}
aria-describedby="otp-hint otp-status"
aria-invalid={status === "error" || undefined}
onChange={(v) => {
setValue(v);
setStatus("idle");
}}
onComplete={(v) => setStatus(v === "123456" ? "ok" : "error")}
pattern="^[0-9]*$"
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<p id="otp-status" role="status" className={status === "error" ? "text-sm text-destructive" : status === "ok" ? "text-sm text-chart-2" : "sr-only"}>
{status === "error" ? "That code is not right. Try again." : status === "ok" ? "Verified. You are all set." : ""}
</p>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/input-otpInstall the dependencies.
$ pnpm add input-otp@^1 lucide-reactCopy the source into your project.
components/ballmac/input-otp.tsx// Ballmac UI: Input OTP. https://ui.ballmac.com/components/input-otp // Based on shadcn/ui Input OTP (MIT, Copyright (c) 2023 shadcn) on input-otp (MIT, Copyright (c) 2023 Guilherme Rodz), adding an invalid state, a reduced-motion-safe caret, and a filled-slot pop. "use client"; import * as React from "react"; import { OTPInput, OTPInputContext } from "input-otp"; import { Minus } from "lucide-react"; import { cn } from "@/lib/utils"; type InputOTPProps = React.ComponentProps<typeof OTPInput> & { /** Classes for the wrapper around the slots (use `className` for the hidden input). */ containerClassName?: string; }; /** * A one-time-code field. One real input sits under the slots, so paste, autofill (`autoComplete="one-time-code"`), * backspace and screen readers all behave like a normal text field. */ function InputOTP({ className, containerClassName, ...props }: InputOTPProps) { return ( <OTPInput data-slot="input-otp" autoComplete="one-time-code" inputMode="numeric" containerClassName={cn( "group/otp flex items-center gap-2 has-disabled:opacity-50", containerClassName, )} className={cn("disabled:cursor-not-allowed", className)} {...props} /> ); } type InputOTPGroupProps = React.ComponentProps<"div">; function InputOTPGroup({ className, ...props }: InputOTPGroupProps) { return ( <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} /> ); } type InputOTPSlotProps = React.ComponentProps<"div"> & { /** Position of this slot in the code, starting at 0. */ index: number; /** Mark the slot invalid (destructive border) without changing the field. */ invalid?: boolean; }; function InputOTPSlot({ index, className, invalid, ...props }: InputOTPSlotProps) { const context = React.useContext(OTPInputContext); const { char, hasFakeCaret, isActive } = context?.slots[index] ?? {}; return ( <div data-slot="input-otp-slot" data-active={isActive} data-filled={char ? "true" : undefined} data-invalid={invalid || undefined} className={cn( "relative flex h-11 w-10 items-center justify-center border-y border-e border-input bg-background text-base font-medium tabular-nums shadow-xs transition-[border-color,box-shadow] first:rounded-s-md first:border-s last:rounded-e-md dark:bg-input/30", "data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50", "data-[invalid=true]:border-destructive data-[invalid=true]:data-[active=true]:ring-destructive/30!", "group-has-[[aria-invalid=true]]/otp:border-destructive group-has-[[aria-invalid=true]]/otp:data-[active=true]:ring-destructive/30!", className, )} {...props} > <span key={char ?? "empty"} className={cn(char && "animate-in zoom-in-75 fade-in-0 duration-150 motion-reduce:animate-none")} > {char} </span> {hasFakeCaret && ( <span aria-hidden="true" className="pointer-events-none absolute inset-0 flex items-center justify-center"> <span className="h-5 w-px animate-pulse bg-foreground motion-reduce:animate-none" /> </span> )} </div> ); } type InputOTPSeparatorProps = React.ComponentProps<"div">; function InputOTPSeparator(props: InputOTPSeparatorProps) { return ( <div data-slot="input-otp-separator" role="separator" {...props}> <Minus aria-hidden="true" className="size-4 text-muted-foreground" /> </div> ); } export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator, type InputOTPProps, type InputOTPGroupProps, type InputOTPSlotProps, type InputOTPSeparatorProps, };Update the import paths to match your project setup.
Usage
import { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "@/components/ballmac/input-otp"The full example is in the Code tab above.
Examples
PIN, invalid, disabled
PIN
Invalid
Disabled
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ballmac/input-otp";
export default function InputOtpStates() {
return (
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-sm font-medium">PIN</p>
<InputOTP maxLength={4} aria-label="PIN" defaultValue="42" pattern="^[0-9]*$">
<InputOTPGroup>
{[0, 1, 2, 3].map((i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
</div>
<div className="grid gap-2">
<p className="text-sm font-medium">Invalid</p>
<InputOTP maxLength={4} aria-label="Invalid PIN" defaultValue="0000" aria-invalid="true">
<InputOTPGroup>
{[0, 1, 2, 3].map((i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
</div>
<div className="grid gap-2">
<p className="text-sm font-medium">Disabled</p>
<InputOTP maxLength={4} aria-label="Disabled PIN" disabled defaultValue="1234">
<InputOTPGroup>
{[0, 1, 2, 3].map((i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
</div>
</div>
);
}API reference
<InputOTP>
| Prop | Type | Default |
|---|---|---|
containerClassNameClasses for the wrapper around the slots (use `className` for the hidden input). | string | — |
<InputOTPSlot>
| Prop | Type | Default |
|---|---|---|
index*Position of this slot in the code, starting at 0. | number | — |
invalidMark the slot invalid (destructive border) without changing the field. | boolean | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Type / paste | Fills slots; autofill works with one-time-code |
| Backspace / arrows | Edit like a normal text field |
| Screen readers | One text input is announced, not six boxes |
Use with AI
<InputOTP maxLength={6}> with InputOTPGroup, InputOTPSlot index and InputOTPSeparator. onComplete fires when every slot is filled. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Input OTP (@ballmac/input-otp) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Two-step sign-in and email verification
- PIN entry
Not for
- Free-form text codes; use input
- Long passwords; use password-input
Registry JSON: https://ui.ballmac.com/r/input-otp.json
Credits
Based on shadcn/ui Input OTP, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- shadcn/utils
Pairs well with
Field
Form field layout with label, description and error that wire their ids to the control automatically, plus fieldset, legend, orientation and invalid/disabled state.
Label
An accessible form label built on Radix Label that dims itself when the paired peer control is disabled and never selects text on double-click.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.
Color Picker
A native color chooser paired with an editable hex field and a live swatch.