Input OTP
A one-time code built from SegmentedInput.
Preview
Loading...
import { useState } from "react";
import { InputOTP } from "@/components/ui/InputOTP";
export function InputOTPDemo() {
const [value, setValue] = useState("");
return (
<div className="grid gap-1">
<InputOTP
value={value}
onValueChange={setValue}
separatorAfter={3}
name="code"
/>
<p className="text-sm text-gray-11">Code: {value || "empty"}</p>
</div>
);
}Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/input-otpManual
Install the following dependencies:
pnpm install @radix-ui/react-iconsCopy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the segmented input component into your components/ui/SegmentedInput.tsx file.
"use client";
/**
* @description A group of connected input fields that appear as a single segmented visual unit.
*/
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
Direction as DirectionPrimitive,
Slot as SlotPrimitive,
} from "radix-ui";
import { cn } from "@/utils/class-names";
import { Input } from "@/components/ui/Input";
const ROOT_NAME = "SegmentedInput";
const ITEM_NAME = "SegmentedInputItem";
type Direction = "ltr" | "rtl";
type Orientation = "horizontal" | "vertical";
type Size = "default" | "sm" | "lg";
type Position = "isolated" | "first" | "middle" | "last";
interface SegmentedInputContextValue {
dir?: Direction;
orientation?: Orientation;
size?: Size;
disabled?: boolean;
invalid?: boolean;
required?: boolean;
}
const SegmentedInputContext =
React.createContext<SegmentedInputContextValue | null>(null);
function useSegmentedInputContext(consumerName: string) {
const context = React.useContext(SegmentedInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface SegmentedInputProps extends React.ComponentProps<"div"> {
dir?: Direction;
orientation?: Orientation;
size?: Size;
asChild?: boolean;
disabled?: boolean;
invalid?: boolean;
required?: boolean;
}
function SegmentedInput(props: SegmentedInputProps) {
const {
size = "default",
dir: dirProp,
orientation = "horizontal",
children,
className,
asChild,
disabled,
invalid,
required,
...rootProps
} = props;
const dir = DirectionPrimitive.useDirection(dirProp);
const contextValue = React.useMemo<SegmentedInputContextValue>(
() => ({
dir,
orientation,
size,
disabled,
invalid,
required,
}),
[dir, orientation, size, disabled, invalid, required],
);
const childrenArray = React.Children.toArray(children);
const childrenCount = childrenArray.length;
const segmentedInputItems = React.Children.map(children, (child, index) => {
if (React.isValidElement<SegmentedInputItemProps>(child)) {
if (!child.props.position) {
let position: Position;
if (childrenCount === 1) {
position = "isolated";
} else if (index === 0) {
position = "first";
} else if (index === childrenCount - 1) {
position = "last";
} else {
position = "middle";
}
return React.cloneElement(child, { position });
}
}
return child;
});
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<SegmentedInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
aria-orientation={orientation}
data-slot="segmented-input"
data-orientation={orientation}
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-required={required ? "" : undefined}
dir={dir}
{...rootProps}
className={cn(
"flex",
orientation === "horizontal" ? "flex-row" : "flex-col",
className,
)}
>
{segmentedInputItems}
</RootPrimitive>
</SegmentedInputContext.Provider>
);
}
const segmentedInputItemVariants = cva(
[
"relative min-w-0 flex-1 rounded-md border border-gray-7 bg-gray-1 text-gray-12 transition-colors outline-none",
"selection:bg-accent-4 selection:text-gray-12 placeholder:text-gray-11",
"focus-visible:z-10 focus-visible:border-gray-8 focus-visible:ring-2 focus-visible:ring-gray-8",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-danger aria-invalid:ring-2 aria-invalid:ring-danger/20",
].join(" "),
{
variants: {
position: {
isolated: "",
first: "rounded-e-none",
middle: "-ms-px rounded-none border-l-0",
last: "-ms-px rounded-s-none border-l-0",
},
orientation: {
horizontal: "",
vertical: "",
},
size: {
// Aura --spacing 13px: avoid text-xs/text-sm on editable inputs (iOS zoom)
sm: "h-2.5 px-1",
default: "h-3 px-1",
lg: "h-4 px-1.5",
},
},
compoundVariants: [
{
position: "first",
orientation: "vertical",
class: "ms-0 rounded-e-md rounded-b-none border-l",
},
{
position: "middle",
orientation: "vertical",
class: "ms-0 -mt-px rounded-none border-t-0 border-l",
},
{
position: "last",
orientation: "vertical",
class: "ms-0 -mt-px rounded-s-md rounded-t-none border-t-0 border-l",
},
],
defaultVariants: {
position: "isolated",
orientation: "horizontal",
size: "default",
},
},
);
interface SegmentedInputItemProps
extends
React.ComponentProps<"input">,
Omit<VariantProps<typeof segmentedInputItemVariants>, "size"> {
asChild?: boolean;
}
function SegmentedInputItem(props: SegmentedInputItemProps) {
const { asChild, className, position, disabled, required, ...inputProps } =
props;
const context = useSegmentedInputContext(ITEM_NAME);
const isDisabled = disabled ?? context.disabled;
const isRequired = required ?? context.required;
const ItemPrimitive = asChild ? SlotPrimitive.Slot : Input;
return (
<ItemPrimitive
aria-invalid={context.invalid}
aria-required={isRequired}
data-disabled={isDisabled ? "" : undefined}
data-invalid={context.invalid ? "" : undefined}
data-orientation={context.orientation}
data-position={position}
data-required={isRequired ? "" : undefined}
data-slot="segmented-input-item"
disabled={isDisabled}
required={isRequired}
{...inputProps}
className={cn(
segmentedInputItemVariants({
position,
orientation: context.orientation,
size: context.size,
className,
}),
)}
/>
);
}
export {
SegmentedInput,
SegmentedInputItem,
SegmentedInput as Root,
SegmentedInputItem as Item,
segmentedInputItemVariants,
type SegmentedInputProps,
type SegmentedInputItemProps,
};
Copy and paste the InputOTP component into your components/ui/InputOTP.tsx file.
"use client";
/**
* @description One-time code entry built from SegmentedInput.
*/
import * as React from "react";
import { MinusIcon } from "@radix-ui/react-icons";
import {
SegmentedInput,
SegmentedInputItem,
} from "@/components/ui/SegmentedInput";
import { cn } from "@/utils/class-names";
function InputOTP({
length = 6,
value: valueProp,
defaultValue = "",
onValueChange,
disabled,
invalid,
id,
name,
separatorAfter,
className,
}: {
length?: number;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
invalid?: boolean;
id?: string;
name?: string;
separatorAfter?: number;
className?: string;
}) {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const value = (valueProp ?? uncontrolled).slice(0, length);
const refs = React.useRef<Array<HTMLInputElement | null>>([]);
function commit(next: string) {
const clipped = next.replace(/\D/g, "").slice(0, length);
if (valueProp === undefined) setUncontrolled(clipped);
onValueChange?.(clipped);
}
function focusAt(index: number) {
refs.current[Math.max(0, Math.min(index, length - 1))]?.focus();
}
const indexes = Array.from({ length }, (_, index) => index);
const groups =
separatorAfter && separatorAfter > 0 && separatorAfter < length
? [indexes.slice(0, separatorAfter), indexes.slice(separatorAfter)]
: [indexes];
return (
<div className={cn("flex items-center gap-1", className)}>
{groups.map((group, groupIndex) => (
<React.Fragment key={group[0]}>
{groupIndex > 0 ? <MinusIcon className="icon" aria-hidden /> : null}
<SegmentedInput
disabled={disabled}
invalid={invalid}
aria-label="One-time code"
>
{group.map((index) => (
<SegmentedInputItem
key={index}
id={index === 0 ? id : undefined}
ref={(node) => {
refs.current[index] = node;
}}
value={value[index] ?? ""}
inputMode="numeric"
autoComplete={index === 0 ? "one-time-code" : "off"}
autoCapitalize="off"
spellCheck={false}
aria-label={`Digit ${index + 1} of ${length}`}
maxLength={length}
className="w-3 flex-none text-center"
onChange={(event) => {
const raw = event.target.value.replace(/\D/g, "");
if (!raw) {
commit(value.slice(0, index) + value.slice(index + 1));
return;
}
const next = (value.slice(0, index) + raw).slice(0, length);
commit(next);
focusAt(index + raw.length);
}}
onKeyDown={(event) => {
if (event.key === "Backspace" && !value[index] && index > 0) {
commit(value.slice(0, index - 1) + value.slice(index));
focusAt(index - 1);
}
if (event.key === "ArrowLeft") focusAt(index - 1);
if (event.key === "ArrowRight") focusAt(index + 1);
}}
onPaste={(event) => {
event.preventDefault();
const text = event.clipboardData
.getData("text")
.replace(/\D/g, "");
const next = (value.slice(0, index) + text).slice(0, length);
commit(next);
focusAt(index + text.length);
}}
/>
))}
</SegmentedInput>
</React.Fragment>
))}
{name ? <input type="hidden" name={name} value={value} /> : null}
</div>
);
}
export { InputOTP };