Input OTP
Accessible one-time password component with copy paste functionality.
npx shadcn@latest add @velaui/input-otppnpm dlx velaui add @velaui/input-otpyarn dlx shadcn@latest add @velaui/input-otpbunx --bun shadcn@latest add @velaui/input-otpveni add input-otpInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/input-otppnpm dlx velaui add @velaui/input-otpyarn dlx shadcn@latest add @velaui/input-otpbunx --bun shadcn@latest add @velaui/input-otpExamples
- Default
- Fill
- With separator
- Disabled
- Invalid
- Pattern
Usage
Props
InputOTP
| Prop | Type | Default |
|---|---|---|
containerClassName | string | — |
variant | InputOTPVariant | "outlined" |
Takes these and every prop of InputOTPProps.
InputOTPGroup
Takes every prop of React.ComponentProps<"div">.
InputOTPSlot
| Prop | Type | Default |
|---|---|---|
indexrequired | number | — |
variant | "fill" | "outlined" | — |
Takes these and every prop of InputOTPSlotProps.
InputOTPSeparator
Takes every prop of React.ComponentProps<"div">.
Dependencies
- npm
class-variance-authority@0.7.1input-otp@1.4.2- Installs with it
- 1 icon
Source
components/ui/input-otp.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { cva, type VariantProps } from "class-variance-authority";
import { Dash } from "@/components/icons/dash";
import { cn } from "@/lib/utils";
const inputOTPSlotVariants = cva(
// The box never changes size between states — every state difference is a
// colour, a ring or a border colour, so entering one never reflows the row.
"relative flex size-14 shrink-0 items-center justify-center rounded-md text-xl leading-[1.3] font-medium text-text-muted transition-all outline-none group-has-disabled/otp:text-text-disabled data-[active=true]:z-10 data-[active=true]:border-ring-neutral-outline data-[active=true]:ring-3 data-[active=true]:ring-ring-neutral-shadow aria-invalid:border-negative-middle data-[active=true]:aria-invalid:border-negative-middle data-[active=true]:aria-invalid:ring-negative-middle/25",
{
variants: {
variant: {
outlined:
"border border-border-default bg-bg-main hover:border-border-hover hover:bg-input-hover data-[active=true]:bg-input-default group-has-disabled/otp:border-border-disabled group-has-disabled/otp:bg-input-disabled",
// Fill draws no border at rest, but focus and invalid both draw a 1px
// one — reserve it as transparent so entering a state never reflows.
fill: "border border-transparent bg-input-default hover:bg-input-hover group-has-disabled/otp:border-transparent group-has-disabled/otp:bg-input-disabled",
},
},
defaultVariants: {
variant: "outlined",
},
},
);
type InputOTPVariant = NonNullable<
VariantProps<typeof inputOTPSlotVariants>["variant"]
>;
/** Set once on the root so every slot inherits it instead of each call site repeating it. */
const InputOTPVariantContext = React.createContext<InputOTPVariant>("outlined");
export type InputOTPProps = React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
variant?: InputOTPVariant;
};
function InputOTP({
className,
containerClassName,
variant = "outlined",
value,
defaultValue,
onChange,
...props
}: InputOTPProps) {
// input-otp seeds its state from `defaultValue` yet also spreads it onto its
// controlled <input>, which React rejects; hold the uncontrolled value here
// and always hand input-otp a controlled one.
const [uncontrolled, setUncontrolled] = React.useState(
defaultValue == null ? "" : String(defaultValue),
);
const controlled = value !== undefined;
return (
<InputOTPVariantContext value={variant}>
<OTPInput
data-slot="input-otp"
data-variant={variant}
containerClassName={cn(
"group/otp flex items-center",
containerClassName,
)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
value={controlled ? value : uncontrolled}
onChange={(next: string) => {
if (!controlled) {
setUncontrolled(next);
}
onChange?.(next);
}}
{...props}
/>
</InputOTPVariantContext>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
export type InputOTPSlotProps = React.ComponentProps<"div"> &
VariantProps<typeof inputOTPSlotVariants> & {
index: number;
};
function InputOTPSlot({
index,
className,
variant,
...props
}: InputOTPSlotProps) {
const inputOTPContext = React.useContext(OTPInputContext);
const inheritedVariant = React.useContext(InputOTPVariantContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
inputOTPSlotVariants({ variant: variant ?? inheritedVariant }),
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-5 w-px animate-caret-blink bg-text-muted duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-separator"
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
role="separator"
{...props}
>
<Dash />
</div>
);
}
export {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
inputOTPSlotVariants,
};