Input OTP
Animated one-time password field built on Base UI OTP Field with focus border motion, character entrance, caret pulse, and OTPSlots auto-layout.
input-otp.tsx
"use client";
import type { OTPFieldInput, OTPFieldRoot } from "@base-ui/react/otp-field";
import { OTPFieldPreview as OTPField } from "@base-ui/react/otp-field";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
createContext,
Fragment,
forwardRef,
type ReactNode,
useContext,
useId,
useMemo,
} from "react";
import { cn } from "@/lib/utils";
const DEFAULT_OTP_LENGTH = 6;
const MAX_OTP_LENGTH = 12;
const controlCornerClassName =
"rounded-[min(var(--radius-md),12px)] supports-[corner-shape:squircle]:corner-squircle supports-[corner-shape:squircle]:rounded-[14px]";
const componentThemeClassName =
"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]";
const otpGroupClassName =
"flex w-full max-w-full items-center justify-center gap-2 sm:gap-3";
const otpSlotSizeClassNames = {
default: "size-11 shrink-0 text-lg tabular-nums sm:size-14 sm:text-xl",
sm: "size-9 shrink-0 text-base tabular-nums sm:size-10 sm:text-lg",
} as const;
const otpSlotBaseClassName =
"relative flex items-center justify-center overflow-hidden border-2 font-medium";
const springTransition = {
type: "spring" as const,
stiffness: 400,
damping: 25,
mass: 0.8,
};
const charSpring = {
type: "spring" as const,
stiffness: 500,
damping: 30,
mass: 0.8,
};
const charMorphTransition = {
...charSpring,
filter: { duration: 0.22, ease: [0.22, 1, 0.36, 1] as const },
opacity: { duration: 0.16, ease: "easeOut" as const },
};
const charMorphEnter = {
filter: "blur(8px)",
opacity: 0.55,
scale: 0.82,
y: 10,
};
const charMorphActive = {
filter: "blur(0px)",
opacity: 1,
scale: 1,
y: 0,
};
const charMorphExit = {
filter: "blur(6px)",
opacity: 0,
scale: 0.88,
y: -8,
};
const INSTANT_TRANSITION = { duration: 0 } as const;
type OTPSize = keyof typeof otpSlotSizeClassNames;
type OTPConfig = {
length: number;
mask: boolean;
prefersReducedMotion: boolean;
showInvalid: boolean;
size: OTPSize;
slotPlaceholder?: string;
};
const OTPLengthContext = createContext<number | null>(null);
const OTPConfigContext = createContext<OTPConfig>({
length: DEFAULT_OTP_LENGTH,
mask: false,
prefersReducedMotion: false,
showInvalid: false,
size: "default",
});
function useOTPConfig() {
return useContext(OTPConfigContext);
}
function useOTPLength() {
const length = useContext(OTPLengthContext);
if (process.env.NODE_ENV !== "production" && length === null) {
throw new Error("OTP slot components must be rendered inside <OTP>.");
}
return length ?? DEFAULT_OTP_LENGTH;
}
function setRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
function resolveOTPLength(length?: number, maxLength?: number) {
const raw = length ?? maxLength ?? DEFAULT_OTP_LENGTH;
if (!Number.isFinite(raw)) {
return DEFAULT_OTP_LENGTH;
}
const normalized = Math.floor(raw);
if (normalized < 1) {
return DEFAULT_OTP_LENGTH;
}
return Math.min(normalized, MAX_OTP_LENGTH);
}
function normalizeSeparators(
separatorAfter: number | number[] | undefined,
length: number
) {
if (separatorAfter === undefined) {
return new Set<number>();
}
const indices = Array.isArray(separatorAfter)
? separatorAfter
: [separatorAfter];
const normalized = indices.filter(
(index) => Number.isInteger(index) && index > 0 && index < length
);
return new Set(normalized);
}
function resolveAriaInvalid(
showInvalid: boolean,
ariaInvalidProp?: boolean | "false" | "true" | "grammar" | "spelling"
) {
if (showInvalid) {
return true;
}
if (ariaInvalidProp === true) {
return true;
}
return ariaInvalidProp;
}
function getOTPFieldMeta({
ariaDescribedByProp,
ariaInvalidProp,
description,
errorMessage,
generatedId,
id,
invalid,
label,
}: {
ariaDescribedByProp?: string;
ariaInvalidProp?: boolean | "false" | "true" | "grammar" | "spelling";
description?: ReactNode;
errorMessage?: ReactNode;
generatedId: string;
id?: string;
invalid: boolean;
label?: ReactNode;
}) {
const otpId = id ?? generatedId;
const descriptionId = description ? `${otpId}-description` : undefined;
const errorId = errorMessage ? `${otpId}-error` : undefined;
const showInvalid =
invalid || Boolean(errorMessage) || ariaInvalidProp === true;
const hasFieldChrome = Boolean(label || description || errorMessage);
return {
describedBy: [ariaDescribedByProp, descriptionId, errorId]
.filter(Boolean)
.join(" "),
descriptionId,
errorId,
hasFieldChrome,
otpId,
showInvalid,
};
}
function OTPFieldWrapper({
children,
description,
descriptionClassName,
descriptionId,
errorId,
errorMessage,
errorMessageClassName,
label,
labelClassName,
otpId,
required,
wrapperClassName,
}: {
children: ReactNode;
description?: ReactNode;
descriptionClassName?: string;
descriptionId?: string;
errorId?: string;
errorMessage?: ReactNode;
errorMessageClassName?: string;
label?: ReactNode;
labelClassName?: string;
otpId: string;
required: boolean;
wrapperClassName?: string;
}) {
return (
<div className={cn("flex w-full flex-col gap-2", wrapperClassName)}>
{label ? (
<label
className={cn("font-medium text-foreground text-sm", labelClassName)}
htmlFor={otpId}
>
{label}
{required ? (
<span aria-hidden className="text-destructive">
{" "}
*
</span>
) : null}
</label>
) : null}
{children}
{description ? (
<p
className={cn(
"text-muted-foreground text-xs leading-snug",
descriptionClassName
)}
id={descriptionId}
>
{description}
</p>
) : null}
{errorMessage ? (
<p
aria-live="polite"
className={cn(
"text-destructive text-xs leading-snug",
errorMessageClassName
)}
id={errorId}
role="alert"
>
{errorMessage}
</p>
) : null}
</div>
);
}
type OTPProps = React.ComponentPropsWithoutRef<typeof OTPField.Root> & {
containerClassName?: string;
description?: ReactNode;
descriptionClassName?: string;
errorMessage?: ReactNode;
errorMessageClassName?: string;
invalid?: boolean;
label?: ReactNode;
labelClassName?: string;
/** @deprecated Use `length` instead. */
maxLength?: number;
size?: OTPSize;
wrapperClassName?: string;
};
const otpRootBaseClassName =
"flex w-full max-w-full items-center justify-center gap-2 data-disabled:cursor-not-allowed data-disabled:opacity-50 sm:gap-3";
function resolveRootClassName(
className: OTPProps["className"],
containerClassName?: string
) {
const baseClassName = cn(otpRootBaseClassName, containerClassName);
if (typeof className === "function") {
return (state: OTPFieldRoot.State) => cn(baseClassName, className(state));
}
return cn(baseClassName, className);
}
const OTP = forwardRef<HTMLDivElement, OTPProps>(
(
{
"aria-describedby": ariaDescribedByProp,
"aria-invalid": ariaInvalidProp,
children,
className,
containerClassName,
description,
descriptionClassName,
errorMessage,
errorMessageClassName,
id,
invalid = false,
label,
labelClassName,
length,
mask = false,
maxLength,
required = false,
size = "default",
wrapperClassName,
...props
},
ref
) => {
const generatedId = useId();
const prefersReducedMotion = useReducedMotion() ?? false;
const resolvedLength = resolveOTPLength(length, maxLength);
const fieldMeta = getOTPFieldMeta({
ariaDescribedByProp,
ariaInvalidProp,
description,
errorMessage,
generatedId,
id,
invalid,
label,
});
const config = useMemo<OTPConfig>(
() => ({
length: resolvedLength,
mask: Boolean(mask),
prefersReducedMotion,
showInvalid: fieldMeta.showInvalid,
size,
}),
[fieldMeta.showInvalid, mask, prefersReducedMotion, resolvedLength, size]
);
const root = (
<div className={cn(componentThemeClassName, "w-full")} data-slot="otp">
<OTPConfigContext.Provider value={config}>
<OTPLengthContext.Provider value={resolvedLength}>
<OTPField.Root
aria-describedby={fieldMeta.describedBy || undefined}
aria-invalid={resolveAriaInvalid(
fieldMeta.showInvalid,
ariaInvalidProp
)}
className={resolveRootClassName(className, containerClassName)}
data-slot="otp-root"
id={fieldMeta.otpId}
length={resolvedLength}
mask={mask}
ref={ref}
required={required}
{...props}
>
{children}
</OTPField.Root>
</OTPLengthContext.Provider>
</OTPConfigContext.Provider>
</div>
);
if (!fieldMeta.hasFieldChrome) {
return root;
}
return (
<OTPFieldWrapper
description={description}
descriptionClassName={descriptionClassName}
descriptionId={fieldMeta.descriptionId}
errorId={fieldMeta.errorId}
errorMessage={errorMessage}
errorMessageClassName={errorMessageClassName}
label={label}
labelClassName={labelClassName}
otpId={fieldMeta.otpId}
required={required}
wrapperClassName={wrapperClassName}
>
{root}
</OTPFieldWrapper>
);
}
);
OTP.displayName = "OTP";
const OTPGroup = forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div
className={cn(otpGroupClassName, className)}
data-slot="otp-group"
ref={ref}
{...props}
/>
));
OTPGroup.displayName = "OTPGroup";
type OTPSlotsProps = {
className?: string;
/** Hint shown in empty slots until the active slot is focused. */
placeholder?: string;
/**
* Inserts `OTPSeparator` before the slot at each zero-based index.
* Pass a single index or an array for multiple groups.
*/
separatorAfter?: number | number[];
slotClassName?: string;
};
function OTPSlots({
className,
placeholder,
separatorAfter,
slotClassName,
}: OTPSlotsProps) {
const length = useOTPLength();
const separators = useMemo(
() => normalizeSeparators(separatorAfter, length),
[length, separatorAfter]
);
const config = useOTPConfig();
const mergedConfig = useMemo(
() => (placeholder ? { ...config, slotPlaceholder: placeholder } : config),
[config, placeholder]
);
return (
<OTPConfigContext.Provider value={mergedConfig}>
<OTPGroup className={className}>
{Array.from({ length }, (_, index) => (
<Fragment key={`otp-slot-${index}`}>
{separators.has(index) ? <OTPSeparator /> : null}
<OTPSlot
aria-label={
index === 0 ? undefined : `Character ${index + 1} of ${length}`
}
className={slotClassName}
placeholder={placeholder}
/>
</Fragment>
))}
</OTPGroup>
</OTPConfigContext.Provider>
);
}
OTPSlots.displayName = "OTPSlots";
type OTPSlotProps = React.ComponentPropsWithoutRef<typeof OTPField.Input> & {
/** Ignored. Slot order is determined by render order with Base UI OTP Field. */
index?: number;
};
type OTPSlotInputProps = React.ComponentPropsWithoutRef<"input"> & {
ref?: React.Ref<HTMLInputElement>;
};
type OTPSlotState = OTPFieldInput.State;
function resolveSlotClassName(
className: OTPSlotProps["className"],
state: OTPSlotState
) {
return typeof className === "function" ? className(state) : className;
}
function getSlotBorderColor({
isActive,
showInvalid,
slotInvalid,
}: {
isActive: boolean;
showInvalid: boolean;
slotInvalid: boolean;
}) {
if (showInvalid || slotInvalid) {
return "var(--color-destructive)";
}
if (isActive) {
return "var(--color-primary)";
}
return "var(--color-border)";
}
function OTPSlotCharacter({
char,
index,
prefersReducedMotion,
staticDisplay = false,
}: {
char: string;
index: number;
prefersReducedMotion: boolean;
staticDisplay?: boolean;
}) {
if (prefersReducedMotion || staticDisplay) {
return (
<span
aria-hidden
className="pointer-events-none inline-block select-none"
>
{char}
</span>
);
}
return (
<motion.span
animate={charMorphActive}
aria-hidden
className="pointer-events-none inline-block select-none"
exit={charMorphExit}
initial={charMorphEnter}
key={`${index}-${char}`}
style={{ willChange: "filter, opacity, transform" }}
transition={charMorphTransition}
>
{char}
</motion.span>
);
}
function OTPSlotCaret({
prefersReducedMotion,
size,
}: {
prefersReducedMotion: boolean;
size: OTPSize;
}) {
const caretClassName = cn(
"rounded-full bg-primary",
size === "sm" ? "h-4 w-[2px]" : "h-5 w-[2px] sm:h-6"
);
if (prefersReducedMotion) {
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className={caretClassName} />
</div>
);
}
return (
<motion.div
animate={{
opacity: 1,
scaleY: 1,
transition: {
opacity: { duration: 0.15 },
scaleY: {
type: "spring" as const,
stiffness: 400,
damping: 25,
},
},
}}
className="pointer-events-none absolute inset-0 flex items-center justify-center"
exit={{ opacity: 0, scaleY: 0, transition: { duration: 0.1 } }}
initial={{ opacity: 0, scaleY: 0 }}
>
<motion.div
animate={{ opacity: [1, 0.3, 1] }}
className={caretClassName}
transition={{
duration: 1.2,
ease: "easeInOut" as const,
repeat: Number.POSITIVE_INFINITY,
}}
/>
</motion.div>
);
}
function OTPSlotPlaceholderHint({
hint,
prefersReducedMotion,
}: {
hint: string;
prefersReducedMotion: boolean;
}) {
if (prefersReducedMotion) {
return (
<span
aria-hidden
className="pointer-events-none select-none text-muted-foreground/50"
>
{hint}
</span>
);
}
return (
<motion.span
animate={{ filter: "blur(0px)", opacity: 1 }}
aria-hidden
className="pointer-events-none select-none text-muted-foreground/50"
exit={{ filter: "blur(4px)", opacity: 0 }}
initial={{ filter: "blur(4px)", opacity: 0 }}
style={{ willChange: "filter, opacity" }}
transition={{
filter: { duration: 0.18, ease: [0.22, 1, 0.36, 1] },
opacity: { duration: 0.14, ease: "easeOut" },
}}
>
{hint}
</motion.span>
);
}
function getOTPSlotChar(state: OTPSlotState) {
return state.value;
}
function getOTPSlotDisplayChar(char: string, mask: boolean, disabled: boolean) {
if (disabled || !char) {
return "";
}
return mask ? "\u2022" : char;
}
function isOTPSlotActive(state: OTPSlotState, tabIndex: number | undefined) {
return !(state.readOnly || state.disabled) && (tabIndex ?? -1) === 0;
}
function shouldShowOTPSlotPlaceholderHint({
char,
isActive,
placeholder,
state,
}: {
char: string;
isActive: boolean;
placeholder?: string;
state: OTPSlotState;
}) {
return (
Boolean(placeholder) &&
!char &&
!isActive &&
!state.disabled &&
!state.readOnly
);
}
function getOTPSlotSurfaceClassName({
className,
resolvedInvalid,
size,
state,
}: {
className?: string;
resolvedInvalid: boolean;
size: OTPSize;
state: OTPSlotState;
}) {
return cn(
controlCornerClassName,
otpSlotBaseClassName,
otpSlotSizeClassNames[size],
"bg-background text-foreground",
resolvedInvalid && "ring-1 ring-destructive/20 dark:ring-destructive/40",
state.disabled && "cursor-not-allowed bg-muted/20",
state.readOnly && "cursor-default bg-muted/30",
className
);
}
function OTPSlotSurfaceDecorations({
displayChar,
placeholder,
prefersReducedMotion,
showCaret,
showPlaceholderHint,
showStaticValue,
size,
state,
}: {
displayChar: string;
placeholder?: string;
prefersReducedMotion: boolean;
showCaret: boolean;
showPlaceholderHint: boolean;
showStaticValue: boolean;
size: OTPSize;
state: OTPSlotState;
}) {
return (
<>
<AnimatePresence
mode={prefersReducedMotion || showStaticValue ? undefined : "popLayout"}
>
{displayChar ? (
<OTPSlotCharacter
char={displayChar}
index={state.index}
prefersReducedMotion={prefersReducedMotion}
staticDisplay={showStaticValue}
/>
) : null}
</AnimatePresence>
<AnimatePresence>
{showPlaceholderHint && placeholder ? (
<OTPSlotPlaceholderHint
hint={placeholder}
prefersReducedMotion={prefersReducedMotion}
/>
) : null}
</AnimatePresence>
<AnimatePresence>
{showCaret ? (
<OTPSlotCaret
prefersReducedMotion={prefersReducedMotion}
size={size}
/>
) : null}
</AnimatePresence>
</>
);
}
function OTPSlotSurface({
className,
inputClassName,
inputRef,
inputProps,
placeholder,
showInvalid,
slotRef,
state,
tabIndex,
}: {
className?: string;
inputClassName?: string;
inputProps: Omit<OTPSlotInputProps, "className" | "ref" | "tabIndex">;
inputRef: React.Ref<HTMLInputElement> | undefined;
placeholder?: string;
showInvalid: boolean;
slotRef: React.Ref<HTMLInputElement>;
state: OTPSlotState;
tabIndex: number | undefined;
}) {
const { mask, prefersReducedMotion, size } = useOTPConfig();
const char = getOTPSlotChar(state);
const isActive = isOTPSlotActive(state, tabIndex);
const slotInvalid = state.valid === false;
const resolvedInvalid = showInvalid || slotInvalid;
const displayChar = getOTPSlotDisplayChar(char, mask, state.disabled);
const showStaticValue = state.readOnly;
const showCaret = isActive && !char && !state.disabled && !state.readOnly;
const showPlaceholderHint = shouldShowOTPSlotPlaceholderHint({
char,
isActive,
placeholder,
state,
});
const borderColor = getSlotBorderColor({
isActive,
showInvalid: resolvedInvalid,
slotInvalid,
});
const motionTransition = prefersReducedMotion
? INSTANT_TRANSITION
: springTransition;
return (
<motion.div
animate={{ borderColor }}
className={getOTPSlotSurfaceClassName({
className,
resolvedInvalid,
size,
state,
})}
data-slot="otp-slot"
initial={{ borderColor: "var(--color-border)" }}
transition={motionTransition}
>
<input
{...inputProps}
aria-invalid={resolvedInvalid ? true : inputProps["aria-invalid"]}
className={cn(
"absolute inset-0 z-10 cursor-text touch-manipulation opacity-0",
state.disabled && "cursor-not-allowed",
state.readOnly && "cursor-default",
inputClassName
)}
placeholder={placeholder}
ref={(node) => {
setRef(inputRef, node);
setRef(slotRef, node);
}}
tabIndex={state.disabled ? -1 : tabIndex}
/>
<OTPSlotSurfaceDecorations
displayChar={displayChar}
placeholder={placeholder}
prefersReducedMotion={prefersReducedMotion}
showCaret={showCaret}
showPlaceholderHint={showPlaceholderHint}
showStaticValue={showStaticValue}
size={size}
state={state}
/>
</motion.div>
);
}
const OTPSlot = forwardRef<HTMLInputElement, OTPSlotProps>(
({ index: _index, className, placeholder, ...props }, ref) => {
const { showInvalid, slotPlaceholder } = useOTPConfig();
const resolvedPlaceholder = placeholder ?? slotPlaceholder;
if (process.env.NODE_ENV !== "production" && _index !== undefined) {
console.warn(
"[OTP] The `index` prop on <OTPSlot> is ignored. Slot order is determined by render order."
);
}
return (
<OTPField.Input
{...props}
placeholder={resolvedPlaceholder}
render={(inputProps, state) => {
const {
className: inputClassName,
ref: inputRef,
tabIndex,
...resolvedInputProps
} = inputProps;
return (
<OTPSlotSurface
className={resolveSlotClassName(className, state)}
inputClassName={inputClassName}
inputProps={resolvedInputProps}
inputRef={inputRef}
placeholder={resolvedPlaceholder}
showInvalid={showInvalid}
slotRef={ref}
state={state}
tabIndex={tabIndex}
/>
);
}}
/>
);
}
);
OTPSlot.displayName = "OTPSlot";
const OTPSeparator = forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<typeof OTPField.Separator>
>(({ className, ...props }, ref) => (
<OTPField.Separator
{...props}
className={cn("text-muted-foreground/40", className)}
render={(separatorProps) => {
const {
className: separatorClassName,
ref: separatorRef,
...resolvedSeparatorProps
} = separatorProps;
return (
<div
{...resolvedSeparatorProps}
className={cn(separatorClassName, "pointer-events-none select-none")}
data-slot="otp-separator"
ref={(node) => {
setRef(separatorRef, node);
setRef(ref, node);
}}
>
<svg
aria-hidden
className="size-2.5 sm:size-3"
fill="none"
viewBox="0 0 12 12"
>
<circle cx="2" cy="6" fill="currentColor" r="1.5" />
<circle cx="10" cy="6" fill="currentColor" r="1.5" />
</svg>
</div>
);
}}
/>
));
OTPSeparator.displayName = "OTPSeparator";
export type { OTPProps, OTPSlotProps, OTPSlotsProps, OTPSize };
export { OTP, OTPGroup, OTPSeparator, OTPSlot, OTPSlots };
No selection tags yet — empty means no evidence, not lack of support.
DependenciesBundle weight · medium
- ui-primitives
- @base-ui/react
- animation
- motion