# Input OTP

> One-time code slots that take typing, paste and SMS autofill, with an opt-in animation that cascades codes in and a status for verifying.

Docs: https://hextaui.com/docs/input-otp
Markdown: https://hextaui.com/docs/input-otp.md

```tsx title="components/examples/input-otp/demo.tsx"
"use client"

import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
  type InputOTPStatus,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPDemo() {
  const [value, setValue] = React.useState("")
  const [status, setStatus] = React.useState<InputOTPStatus>("idle")
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  function verify(code: string) {
    setStatus("loading")
    clearTimeout(timer.current)
    timer.current = setTimeout(() => {
      if (code === "123456") {
        setStatus("success")
        return
      }
      setStatus("error")
      timer.current = setTimeout(() => {
        setValue("")
        setStatus("idle")
      }, 900)
    }, 1200)
  }

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-3">
      <Label htmlFor="input-otp-demo">Verification code</Label>
      <InputOTP
        id="input-otp-demo"
        length={6}
        variant="separate"
        animated
        status={status}
        value={value}
        onValueChange={(next) => {
          setValue(next)
          setStatus("idle")
        }}
        onValueComplete={verify}
        aria-describedby="input-otp-demo-hint"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p id="input-otp-demo-hint" className="text-sm text-muted-foreground">
        Type or paste 123456 to pass. Anything else fails.
      </p>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/input-otp.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/input-otp.tsx"
"use client"

import * as React from "react"
import {
  DirectionProvider,
  useDirection,
} from "@base-ui/react/direction-provider"
import { mergeProps } from "@base-ui/react/merge-props"
import { OTPField as OTPFieldPrimitive } from "@base-ui/react/otp-field"
import { useRender } from "@base-ui/react/use-render"
import { IconMinus } from "@tabler/icons-react"
import { cva } from "class-variance-authority"
import { cn } from "cn"

import { easeOut, prefersReducedMotion } from "@/lib/motion"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

type InputOTPStatus = "idle" | "loading" | "success" | "error"
type InputOTPVariant = "joined" | "separate"
type InputOTPSize = "sm" | "default" | "lg"

type InputOTPChange = {
  id: number
  kind: "type" | "fill" | "shift" | "clear"
  start: number
  end: number
}

type InputOTPContextValue = {
  animated: boolean
  mask: boolean
  variant: InputOTPVariant
  size: InputOTPSize
  status: InputOTPStatus
  invalid: boolean
  selected: boolean
  change: InputOTPChange
}

const InputOTPContext = React.createContext<InputOTPContextValue | null>(null)

function useInputOTPContext(part: string) {
  const context = React.useContext(InputOTPContext)
  if (!context) {
    throw new Error(`<${part}> must be used within <InputOTP>.`)
  }
  return context
}

const staggerStep = 45
const exitStep = 25
const maxStaggerSteps = 8
const maskCharacter = "•"

const initialChange: InputOTPChange = {
  id: 0,
  kind: "type",
  start: 0,
  end: 0,
}

function describeChange(
  previous: string,
  next: string,
  id: number
): InputOTPChange {
  const before = Array.from(previous)
  const after = Array.from(next)
  let start = 0
  while (
    start < before.length &&
    start < after.length &&
    before[start] === after[start]
  ) {
    start += 1
  }
  if (
    after.length === before.length - 1 &&
    after.join("") ===
      [...before.slice(0, start), ...before.slice(start + 1)].join("")
  ) {
    return { id, kind: "shift", start, end: before.length - 1 }
  }
  let entered = 0
  for (let index = start; index < after.length; index += 1) {
    if (after[index] !== before[index]) {
      entered += 1
    }
  }
  if (entered === 0) {
    return { id, kind: "clear", start, end: before.length - 1 }
  }
  return {
    id,
    kind: entered > 1 ? "fill" : "type",
    start,
    end: after.length - 1,
  }
}

const validationPatterns = {
  numeric: /[^\d]/g,
  alpha: /[^a-zA-Z]/g,
  alphanumeric: /[^a-zA-Z0-9]/g,
}

function normalizeCode(
  raw: string,
  length: number,
  validationType: OTPFieldPrimitive.Root.ValidationType,
  normalizeValue: ((value: string) => string) | undefined
) {
  const pattern =
    validationType === "none" ? null : validationPatterns[validationType]
  const filter = (value: string) =>
    pattern ? value.replace(pattern, "") : value
  let value = filter(raw.replace(/\s/g, ""))
  if (normalizeValue) {
    value = filter(normalizeValue(value))
  }
  return Array.from(value).slice(0, Math.max(length, 0)).join("")
}

function rawInput(details: OTPFieldPrimitive.Root.ChangeEventDetails) {
  if (details.reason === "input-paste") {
    return details.event.clipboardData?.getData("text/plain") ?? ""
  }
  const target = details.event.target
  return target instanceof HTMLInputElement ? target.value : ""
}

function makeDetails(
  reason: "input-change" | "input-clear",
  event: Event,
  target: EventTarget | null
) {
  let canceled = false
  let propagationAllowed = false
  return {
    reason,
    event,
    trigger: target instanceof Element ? target : undefined,
    cancel() {
      canceled = true
    },
    allowPropagation() {
      propagationAllowed = true
    },
    get isCanceled() {
      return canceled
    },
    get isPropagationAllowed() {
      return propagationAllowed
    },
  } as OTPFieldPrimitive.Root.ChangeEventDetails
}

const passiveKeys = new Set(["Shift", "Control", "Alt", "Meta", "CapsLock"])

function canAnimate() {
  return (
    typeof window !== "undefined" &&
    typeof HTMLElement !== "undefined" &&
    typeof HTMLElement.prototype.animate === "function"
  )
}

const inputOTPGroupVariants = cva("flex min-w-0 items-center", {
  variants: {
    variant: {
      joined: "rounded-md",
      separate: "",
    },
    size: {
      sm: "",
      default: "",
      lg: "",
    },
  },
  compoundVariants: [
    { variant: "separate", size: "sm", className: "gap-1.5" },
    { variant: "separate", size: "default", className: "gap-2" },
    { variant: "separate", size: "lg", className: "gap-2.5" },
  ],
  defaultVariants: {
    variant: "joined",
    size: "default",
  },
})

const inputOTPSlotVariants = cva(
  "group/input-otp-slot relative flex min-w-0 shrink items-center justify-center bg-transparent text-foreground tabular-nums inset-ring-(length:--hairline) inset-ring-input transition-[box-shadow,color,background-color] duration-150 ease-out-cubic select-none [-webkit-tap-highlight-color:transparent] has-[>input:focus]:z-10 has-[>input:focus]:ring-3 has-[>input:focus]:ring-focus-ring has-[>input:focus]:inset-ring-ring has-[>input[aria-invalid=true]]:inset-ring-destructive has-[>input[aria-invalid=true]:focus]:ring-destructive/20 has-[>input[readonly]]:not-data-[status=loading]:bg-muted/40 data-[selected]:bg-primary/10 data-[selected]:inset-ring-ring/70 data-[status=loading]:text-muted-foreground data-[status=success]:inset-ring-success data-[status=success]:has-[>input:focus]:ring-success/20 data-[status=success]:has-[>input:focus]:inset-ring-success motion-reduce:transition-none dark:bg-input/30 dark:has-[>input[aria-invalid=true]:focus]:ring-destructive/40 dark:has-[>input[readonly]]:not-data-[status=loading]:bg-input/20 dark:data-[selected]:bg-primary/20 dark:data-[status=success]:has-[>input:focus]:ring-success/30 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [@media(hover:hover)]:hover:not-has-[>input:focus]:not-has-[>input:disabled]:not-has-[>input[aria-invalid=true]]:not-data-[status=success]:z-1 [@media(hover:hover)]:hover:not-has-[>input:focus]:not-has-[>input:disabled]:not-has-[>input[aria-invalid=true]]:not-data-[status=success]:inset-ring-ring/70",
  {
    variants: {
      variant: {
        joined:
          "not-first:-ms-(--hairline) first:rounded-s-md last:rounded-e-md",
        separate: "rounded-md",
      },
      size: {
        sm: "size-8 text-sm pointer-coarse:size-11",
        default: "size-9 text-base pointer-coarse:size-11",
        lg: "size-10 text-lg pointer-coarse:size-12",
      },
    },
    defaultVariants: {
      variant: "joined",
      size: "default",
    },
  }
)

const inputOTPInputClassName =
  "absolute inset-0 m-0 size-full min-w-0 rounded-[inherit] border-0 bg-transparent p-0 text-center caret-foreground outline-none focus-visible:outline-hidden selection:bg-transparent autofill:transition-[background-color] autofill:duration-[100000s] disabled:cursor-not-allowed read-only:cursor-default"

const inputOTPCharClassName =
  "pointer-events-none absolute inset-0 flex items-center justify-center [animation-delay:calc(var(--input-otp-index,0)*90ms)] group-data-[status=loading]/input-otp-slot:motion-safe:animate-input-otp-wave group-data-[status=loading]/input-otp-slot:motion-reduce:animate-input-otp-pulse group-data-[status=success]/input-otp-slot:motion-safe:animate-input-otp-pop group-data-[status=success]/input-otp-slot:[animation-delay:calc(var(--input-otp-index,0)*40ms)]"

type InputOTPProps = Omit<OTPFieldPrimitive.Root.Props, "aria-invalid"> & {
  variant?: InputOTPVariant
  size?: InputOTPSize
  animated?: boolean
  status?: InputOTPStatus
  loadingLabel?: string
  successLabel?: string
  errorLabel?: string
  "aria-invalid"?: boolean | "true" | "false"
}

type InputOTPFrameProps = {
  rootProps: React.HTMLAttributes<HTMLDivElement> & {
    ref?: React.Ref<HTMLDivElement>
  }
  state: OTPFieldPrimitive.Root.State
  render: OTPFieldPrimitive.Root.Props["render"]
  settings: Omit<InputOTPContextValue, "change">
  announcement: string | null
}

function InputOTPFrame({
  rootProps,
  state,
  render,
  settings,
  announcement,
}: InputOTPFrameProps) {
  const [tracked, setTracked] = React.useState({
    value: state.value,
    change: initialChange,
  })
  let change = tracked.change
  if (tracked.value !== state.value) {
    change = describeChange(tracked.value, state.value, tracked.change.id + 1)
    setTracked({ value: state.value, change })
  }

  const rootRef = React.useRef<HTMLDivElement>(null)
  const { status, animated } = settings
  const previousStatus = React.useRef(status)

  React.useEffect(() => {
    const previous = previousStatus.current
    previousStatus.current = status
    const root = rootRef.current
    if (
      !root ||
      !animated ||
      status !== "error" ||
      previous === "error" ||
      prefersReducedMotion()
    ) {
      return
    }
    root.removeAttribute("data-shake")
    void root.offsetWidth
    root.setAttribute("data-shake", "")
    const timer = setTimeout(() => root.removeAttribute("data-shake"), 400)
    return () => {
      clearTimeout(timer)
      root.removeAttribute("data-shake")
    }
  }, [animated, status])

  React.useLayoutEffect(() => {
    const root = rootRef.current
    const active = root?.ownerDocument.activeElement
    if (state.value !== "" || !root || !active || !root.contains(active)) {
      return
    }
    const first = root.querySelector<HTMLInputElement>(
      "[data-slot=input-otp-input]"
    )
    if (first && active !== first) {
      first.focus()
    }
  }, [state.value])

  const { ref: forwardedRef, ...props } = rootProps
  const element = useRender({
    defaultTagName: "div",
    render:
      typeof render === "function"
        ? (renderProps) => render(renderProps, state)
        : render,
    ref: forwardedRef ? [forwardedRef, rootRef] : rootRef,
    props,
  })

  const context = React.useMemo(
    () => ({ ...settings, change }),
    [settings, change]
  )

  return (
    <InputOTPContext.Provider value={context}>
      {element}
      {announcement !== null && (
        <span data-slot="input-otp-status" role="status" className="sr-only">
          {announcement}
        </span>
      )}
    </InputOTPContext.Provider>
  )
}

function InputOTP({
  className,
  variant = "joined",
  size = "default",
  animated = false,
  status,
  loadingLabel = "Verifying code",
  successLabel = "Code verified",
  errorLabel = "Code is incorrect",
  mask = false,
  readOnly = false,
  render,
  "aria-invalid": ariaInvalid,
  value: valueProp,
  defaultValue = "",
  onValueChange,
  length,
  validationType = "numeric",
  normalizeValue,
  disabled = false,
  onFocus,
  onBlur,
  onKeyDownCapture,
  onMouseDownCapture,
  onCopyCapture,
  onCutCapture,
  ...props
}: InputOTPProps) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
  const controlled = valueProp !== undefined
  const value = controlled ? valueProp : uncontrolled
  const [selected, setSelected] = React.useState(false)
  const pendingFocus = React.useRef<{
    root: HTMLElement
    index: number
  } | null>(null)

  const focusSoon = (root: Element | null | undefined, index: number) => {
    if (root instanceof HTMLElement) {
      pendingFocus.current = { root, index }
    }
  }
  const locked = readOnly || status === "loading"

  const commit = (
    next: string,
    details: OTPFieldPrimitive.Root.ChangeEventDetails
  ) => {
    onValueChange?.(next, details)
    if (details.isCanceled) {
      return
    }
    if (!controlled) {
      setUncontrolled(next)
    }
  }

  const handleValueChange = (
    next: string,
    details: OTPFieldPrimitive.Root.ChangeEventDetails
  ) => {
    if (!selected) {
      commit(next, details)
      return
    }
    setSelected(false)
    const replacement =
      details.reason === "input-change" || details.reason === "input-paste"
        ? normalizeCode(
            rawInput(details),
            length,
            validationType,
            normalizeValue
          )
        : ""
    const target = details.event.target
    focusSoon(
      target instanceof Element
        ? target.closest("[data-slot=input-otp]")
        : null,
      Math.min(Array.from(replacement).length, length - 1)
    )
    commit(replacement, details)
  }

  const code = normalizeCode(value, length, validationType, normalizeValue)

  React.useLayoutEffect(() => {
    const pending = pendingFocus.current
    pendingFocus.current = null
    if (!pending || !pending.root.contains(document.activeElement)) {
      return
    }
    pending.root
      .querySelectorAll<HTMLInputElement>("[data-slot=input-otp-input]")
      [pending.index]?.focus()
  })
  const inherited = useDirection()
  const [domDirection, setDomDirection] = React.useState<"ltr" | "rtl">()
  const direction = domDirection ?? inherited
  const resolvedStatus = status ?? "idle"
  const invalid =
    resolvedStatus === "error" || ariaInvalid === true || ariaInvalid === "true"

  const settings = React.useMemo(
    () => ({
      animated,
      mask,
      variant,
      size,
      status: resolvedStatus,
      invalid,
      selected: selected && code !== "",
    }),
    [animated, mask, variant, size, resolvedStatus, invalid, selected, code]
  )

  const announcement =
    status === undefined
      ? null
      : resolvedStatus === "loading"
        ? loadingLabel
        : resolvedStatus === "success"
          ? successLabel
          : resolvedStatus === "error"
            ? errorLabel
            : ""

  return (
    <DirectionProvider direction={direction}>
      <OTPFieldPrimitive.Root
        data-slot="input-otp"
        data-variant={variant}
        data-size={size}
        data-status={status}
        data-animated={animated ? "" : undefined}
        aria-busy={resolvedStatus === "loading" ? true : undefined}
        mask={mask}
        value={value}
        onValueChange={handleValueChange}
        length={length}
        validationType={validationType}
        normalizeValue={normalizeValue}
        disabled={disabled}
        readOnly={locked}
        onKeyDownCapture={(event) => {
          onKeyDownCapture?.(event)
          if (event.defaultPrevented || disabled) {
            return
          }
          const command =
            (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey
          const key = event.key.toLowerCase()
          if (command && key === "a") {
            event.preventDefault()
            if (code === "") {
              return
            }
            setSelected(true)
            const first = event.currentTarget.querySelector<HTMLInputElement>(
              "[data-slot=input-otp-input]"
            )
            if (first && first !== event.target) {
              first.focus()
            } else {
              first?.select()
            }
            return
          }
          if (!selected || passiveKeys.has(event.key)) {
            return
          }
          if (command && (key === "c" || key === "x")) {
            return
          }
          if (event.key === "Backspace" || event.key === "Delete") {
            return
          }
          if (event.key.length === 1 && !command) {
            event.preventDefault()
            if (locked) {
              return
            }
            const replacement = normalizeCode(
              event.key,
              length,
              validationType,
              normalizeValue
            )
            if (replacement === "") {
              return
            }
            setSelected(false)
            focusSoon(event.currentTarget, Math.min(1, length - 1))
            commit(
              replacement,
              makeDetails("input-change", event.nativeEvent, event.target)
            )
            return
          }
          setSelected(false)
        }}
        onMouseDownCapture={(event) => {
          onMouseDownCapture?.(event)
          if (selected) {
            setSelected(false)
          }
        }}
        onCopyCapture={(event) => {
          onCopyCapture?.(event)
          if (!selected || event.defaultPrevented) {
            return
          }
          event.preventDefault()
          event.clipboardData.setData("text/plain", code)
        }}
        onCutCapture={(event) => {
          onCutCapture?.(event)
          if (!selected || event.defaultPrevented) {
            return
          }
          event.preventDefault()
          event.clipboardData.setData("text/plain", code)
          if (!locked) {
            setSelected(false)
            commit(
              "",
              makeDetails("input-clear", event.nativeEvent, event.target)
            )
            focusSoon(event.currentTarget, 0)
          }
        }}
        onBlur={(event) => {
          onBlur?.(event)
          if (
            selected &&
            !event.currentTarget.contains(event.relatedTarget as Node | null)
          ) {
            setSelected(false)
          }
        }}
        onFocus={(event) => {
          onFocus?.(event)
          const next =
            getComputedStyle(event.currentTarget).direction === "rtl"
              ? "rtl"
              : "ltr"
          if (next !== domDirection) {
            setDomDirection(next)
          }
        }}
        className={mergeClassName(
          "flex w-fit max-w-full min-w-0 items-center gap-2 data-shake:motion-safe:animate-button-shake data-disabled:cursor-not-allowed data-disabled:opacity-50",
          className
        )}
        render={(rootProps, state) => (
          <InputOTPFrame
            rootProps={rootProps}
            state={state}
            render={render}
            settings={settings}
            announcement={announcement}
          />
        )}
        {...props}
      />
    </DirectionProvider>
  )
}

type InputOTPGroupProps = useRender.ComponentProps<"div">

function InputOTPGroup({ className, render, ...props }: InputOTPGroupProps) {
  const { variant, size } = useInputOTPContext("InputOTPGroup")
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(inputOTPGroupVariants({ variant, size }), className),
      },
      props,
      { "data-slot": "input-otp-group" } as React.ComponentProps<"div">
    ),
  })
}

type InputOTPGhostState = {
  key: number
  char: string
  delay: number
  direction: "up" | "down"
}

function InputOTPGhost({
  ghost,
  onDone,
}: {
  ghost: InputOTPGhostState
  onDone: (key: number) => void
}) {
  const ref = React.useRef<HTMLSpanElement>(null)

  React.useLayoutEffect(() => {
    const element = ref.current
    if (!element) {
      return
    }
    const reduce = prefersReducedMotion()
    const offset = ghost.direction === "up" ? "-35%" : "35%"
    const animation = element.animate(
      reduce
        ? [{ opacity: 1 }, { opacity: 0 }]
        : [
            { opacity: 1, transform: "none", filter: "blur(0px)" },
            {
              opacity: 0,
              transform: `translateY(${offset}) scale(0.8)`,
              filter: "blur(3px)",
            },
          ],
      {
        duration: reduce ? 100 : 180,
        delay: reduce ? 0 : ghost.delay,
        easing: easeOut,
        fill: "both",
      }
    )
    animation.onfinish = () => onDone(ghost.key)
    return () => animation.cancel()
  }, [ghost, onDone])

  return (
    <span
      ref={ref}
      aria-hidden="true"
      data-slot="input-otp-ghost"
      className="pointer-events-none absolute inset-0 flex items-center justify-center"
    >
      {ghost.char}
    </span>
  )
}

type InputOTPSlotProps = Omit<
  OTPFieldPrimitive.Input.Props,
  "className" | "render"
> & {
  className?: ClassName<OTPFieldPrimitive.Input.State>
}

type InputOTPSlotFrameProps = {
  inputProps: React.InputHTMLAttributes<HTMLInputElement> & {
    ref?: React.Ref<HTMLInputElement>
  }
  state: OTPFieldPrimitive.Input.State
  className: InputOTPSlotProps["className"]
  labelled: boolean
}

function InputOTPSlotFrame({
  inputProps,
  state,
  className,
  labelled,
}: InputOTPSlotFrameProps) {
  const { animated, mask, variant, size, status, invalid, selected, change } =
    useInputOTPContext("InputOTPSlot")
  const { index, length } = state
  const char = state.value
  const display = mask && char ? maskCharacter : char

  const [shown, setShown] = React.useState(char)
  const [ghosts, setGhosts] = React.useState<InputOTPGhostState[]>([])
  if (char !== shown) {
    setShown(char)
    if (
      animated &&
      shown &&
      canAnimate() &&
      !(change.kind === "shift" && index > change.start)
    ) {
      setGhosts([
        ...ghosts.slice(-2),
        {
          key: change.id,
          char: mask ? maskCharacter : shown,
          delay:
            change.kind === "clear"
              ? Math.min(Math.max(change.end - index, 0), maxStaggerSteps) *
                exitStep
              : 0,
          direction: char && change.kind !== "shift" ? "up" : "down",
        },
      ])
    }
  }
  if (!animated && ghosts.length > 0) {
    setGhosts([])
  }

  const removeGhost = React.useCallback((key: number) => {
    setGhosts((current) => current.filter((ghost) => ghost.key !== key))
  }, [])

  const slotRef = React.useRef<HTMLDivElement>(null)
  const charRef = React.useRef<HTMLSpanElement>(null)
  const previousChar = React.useRef(char)
  const motion = React.useRef<Animation | null>(null)

  React.useLayoutEffect(() => {
    const previous = previousChar.current
    previousChar.current = char
    const element = charRef.current
    const slot = slotRef.current
    if (
      !animated ||
      !char ||
      char === previous ||
      !element ||
      !slot ||
      typeof element.animate !== "function"
    ) {
      return
    }
    motion.current?.cancel()
    const reduce = prefersReducedMotion()

    if (change.kind === "shift" && index >= change.start) {
      if (reduce) {
        return
      }
      const slots = slot
        .closest("[data-slot=input-otp]")
        ?.querySelectorAll("[data-slot=input-otp-slot]")
      const from = slots?.[index + 1]
      if (!from) {
        return
      }
      const distance =
        from.getBoundingClientRect().left - slot.getBoundingClientRect().left
      motion.current = element.animate(
        [{ transform: `translateX(${distance}px)` }, { transform: "none" }],
        { duration: 240, easing: easeOut }
      )
      return
    }

    const delay =
      change.kind === "fill"
        ? Math.min(Math.max(index - change.start, 0), maxStaggerSteps) *
          staggerStep
        : 0
    motion.current = element.animate(
      reduce
        ? [{ opacity: 0 }, { opacity: 1 }]
        : [
            {
              opacity: 0,
              transform: "translateY(40%) scale(0.8)",
              filter: "blur(4px)",
            },
            { opacity: 1, transform: "none", filter: "blur(0px)" },
          ],
      {
        duration: reduce ? 150 : 340,
        delay: reduce ? 0 : delay,
        easing: easeOut,
        fill: "backwards",
      }
    )
  }, [animated, change, char, index])

  React.useEffect(() => () => motion.current?.cancel(), [])

  const resolvedClassName =
    typeof className === "function" ? className(state) : className
  const slotLabel =
    !labelled &&
    (index > 0 ||
      (inputProps["aria-labelledby"] === undefined &&
        inputProps["aria-label"] === undefined))
      ? {
          "aria-label": `Character ${index + 1} of ${length}`,
          "aria-labelledby": undefined,
        }
      : null

  return (
    <div
      ref={slotRef}
      data-slot="input-otp-slot"
      data-filled={state.filled ? "" : undefined}
      data-selected={selected && state.filled ? "" : undefined}
      data-status={status === "idle" ? undefined : status}
      style={{ "--input-otp-index": index } as React.CSSProperties}
      className={cn(inputOTPSlotVariants({ variant, size }), resolvedClassName)}
    >
      <input
        {...inputProps}
        {...slotLabel}
        aria-invalid={invalid || inputProps["aria-invalid"] ? true : undefined}
        className={cn(
          inputOTPInputClassName,
          animated && "text-transparent [-webkit-text-fill-color:transparent]"
        )}
      />
      {animated && (
        <span
          ref={charRef}
          aria-hidden="true"
          data-slot="input-otp-char"
          className={inputOTPCharClassName}
        >
          {display}
        </span>
      )}
      {ghosts.map((ghost) => (
        <InputOTPGhost key={ghost.key} ghost={ghost} onDone={removeGhost} />
      ))}
    </div>
  )
}

function InputOTPSlot({ className, ...props }: InputOTPSlotProps) {
  useInputOTPContext("InputOTPSlot")
  const labelled =
    props["aria-label"] !== undefined || props["aria-labelledby"] !== undefined
  return (
    <OTPFieldPrimitive.Input
      data-slot="input-otp-input"
      {...props}
      render={(inputProps, state) => (
        <InputOTPSlotFrame
          inputProps={inputProps}
          state={state}
          className={className}
          labelled={labelled}
        />
      )}
    />
  )
}

function InputOTPSeparator({
  className,
  children,
  ...props
}: OTPFieldPrimitive.Separator.Props) {
  return (
    <OTPFieldPrimitive.Separator
      data-slot="input-otp-separator"
      className={mergeClassName(
        "flex shrink-0 items-center text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children ?? <IconMinus aria-hidden="true" />}
    </OTPFieldPrimitive.Separator>
  )
}

export {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
  inputOTPGroupVariants,
  inputOTPSlotVariants,
}
export type {
  InputOTPGroupProps,
  InputOTPProps,
  InputOTPSize,
  InputOTPSlotProps,
  InputOTPStatus,
  InputOTPVariant,
}
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
```

```tsx
<label htmlFor="code">Verification code</label>
<InputOTP id="code" length={6}>
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
</InputOTP>
```

Render one `<InputOTPSlot />` per character and set `length` to the same number. Slots find their position on their own, so there is no `index` prop to keep in sync.

## Composition

```text
InputOTP
├── InputOTPGroup
│   └── InputOTPSlot
├── InputOTPSeparator
└── InputOTPGroup
    └── InputOTPSlot
```

## Examples

### Joined

The default look. Each `<InputOTPGroup />` joins its slots into one strip with shared edges.

```tsx title="components/examples/input-otp/basic.tsx"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPBasic() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-2">
      <Label htmlFor="input-otp-basic">Verification code</Label>
      <InputOTP id="input-otp-basic" length={6}>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}
```

### Separate

`variant="separate"` gives every slot its own rounded box with a gap between them.

```tsx title="components/examples/input-otp/separate.tsx"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPSeparate() {
  return (
    <InputOTP length={6} variant="separate" aria-label="Verification code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}
```

### Sizes

`sm`, `default` and `lg` match the input and button heights. On touch screens every size grows to at least 44px with a 16px font.

```tsx title="components/examples/input-otp/sizes.tsx"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPSizes() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-4">
      <InputOTP length={4} size="sm" aria-label="Small code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <InputOTP length={4} aria-label="Default code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <InputOTP length={4} size="lg" aria-label="Large code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}
```

### Animated

`animated` is off by default. With it, typed characters rise in, deleted ones sink out while the rest slide over, and a whole code from autofill, paste or your own state cascades in slot by slot. Press Fill code to see the cascade.

```tsx title="components/examples/input-otp/animated.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPAnimated() {
  const [value, setValue] = React.useState("")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-4">
      <InputOTP
        length={6}
        variant="separate"
        animated
        value={value}
        onValueChange={setValue}
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => setValue("482913")}>
          Fill code
        </Button>
        <Button variant="ghost" size="sm" onClick={() => setValue("")}>
          Clear
        </Button>
      </div>
    </div>
  )
}
```

### Status

`status` shows the result of checking the code. `loading` locks the slots and marks the field busy, `error` marks every slot invalid and `success` turns the edges green. Each one is announced. With `animated`, loading runs a wave, error shakes once and success pops the characters.

```tsx title="components/examples/input-otp/status.tsx"
"use client"

import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  type InputOTPStatus,
} from "@/components/ui/input-otp"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const statuses: InputOTPStatus[] = ["idle", "loading", "success", "error"]

export function InputOTPStatusExample() {
  const [status, setStatus] = React.useState<InputOTPStatus>("loading")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-4">
      <InputOTP
        length={6}
        variant="separate"
        animated
        status={status}
        defaultValue="381904"
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <ToggleGroup
        aria-label="Status"
        size="sm"
        value={[status]}
        onValueChange={(next) => {
          if (next[0]) {
            setStatus(next[0] as InputOTPStatus)
          }
        }}
      >
        {statuses.map((item) => (
          <ToggleGroupItem key={item} value={item}>
            {item}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
    </div>
  )
}
```

### Controlled

Pass `value` and `onValueChange`. The value is always the filtered code, never longer than `length`.

```tsx title="components/examples/input-otp/controlled.tsx"
"use client"

import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPControlled() {
  const [value, setValue] = React.useState("")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-3">
      <InputOTP
        length={6}
        value={value}
        onValueChange={setValue}
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p className="text-sm text-muted-foreground tabular-nums">
        {value === "" ? "Enter your code." : `You entered: ${value}`}
      </p>
    </div>
  )
}
```

### Form

With a `name`, the code is submitted with the form. `autoSubmit` submits as soon as the last slot fills, so an autofilled code signs people in without another tap.

```tsx title="components/examples/input-otp/form.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPForm() {
  const [submitted, setSubmitted] = React.useState<string>()

  return (
    <form
      className="flex max-w-full min-w-0 flex-col items-start gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get("code")))
      }}
    >
      <Label htmlFor="input-otp-form">Sign-in code</Label>
      <InputOTP id="input-otp-form" name="code" length={6} required autoSubmit>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          Continue
        </Button>
        <p role="status" className="text-sm text-muted-foreground tabular-nums">
          {submitted ? `Submitted ${submitted}` : null}
        </p>
      </div>
    </form>
  )
}
```

### With Field

Inside a `<Field />` the label, description and error are linked for you. Enter anything but 000000 to see the error.

```tsx title="components/examples/input-otp/field.tsx"
"use client"

import * as React from "react"

import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPField() {
  const [value, setValue] = React.useState("")
  const [error, setError] = React.useState<string>()

  return (
    <Field invalid={error !== undefined} className="w-fit">
      <FieldLabel>Verification code</FieldLabel>
      <InputOTP
        length={6}
        value={value}
        onValueChange={(next) => {
          setValue(next)
          setError(undefined)
        }}
        onValueComplete={(code) => {
          if (code !== "000000") {
            setError("That code has expired. Request a new one.")
          }
        }}
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <FieldDescription>We sent it to ada@example.com.</FieldDescription>
      <FieldError errors={error ? [{ message: error }] : []} />
    </Field>
  )
}
```

### Invalid

`aria-invalid` on the root marks every slot. Link the message with `aria-describedby`.

```tsx title="components/examples/input-otp/invalid.tsx"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPInvalid() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-2">
      <InputOTP
        length={6}
        defaultValue="111111"
        aria-invalid
        aria-label="Verification code"
        aria-describedby="input-otp-invalid-error"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p id="input-otp-invalid-error" className="text-sm text-destructive">
        That code doesn’t match. Check the latest message.
      </p>
    </div>
  )
}
```

### Letters and numbers

`validationType="alphanumeric"` accepts recovery and invite codes, and `normalizeValue` upper-cases them as they are typed or pasted.

```tsx title="components/examples/input-otp/alphanumeric.tsx"
"use client"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPAlphanumeric() {
  return (
    <InputOTP
      length={8}
      validationType="alphanumeric"
      normalizeValue={(value) => value.toUpperCase()}
      aria-label="Recovery code"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}
```

### Masked

`mask` hides each character, for PINs. Turn autofill off with `autoComplete="off"` when the value is not a one-time code.

```tsx title="components/examples/input-otp/masked.tsx"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPMasked() {
  return (
    <InputOTP
      length={4}
      mask
      animated
      variant="separate"
      autoComplete="off"
      aria-label="PIN"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}
```

### Custom separator

Group the slots any way you like and pass your own icon to `<InputOTPSeparator />`.

```tsx title="components/examples/input-otp/custom-separator.tsx"
import { IconPointFilled } from "@tabler/icons-react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPCustomSeparator() {
  return (
    <InputOTP length={6} variant="separate" aria-label="Pairing code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator>
        <IconPointFilled aria-hidden="true" />
      </InputOTPSeparator>
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator>
        <IconPointFilled aria-hidden="true" />
      </InputOTPSeparator>
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}
```

### Disabled

A disabled field can’t be focused or edited.

```tsx title="components/examples/input-otp/disabled.tsx"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPDisabled() {
  return (
    <InputOTP
      length={6}
      defaultValue="12"
      disabled
      aria-label="Verification code"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}
```

### Right to left

Slots fill from the right and the arrow keys follow what you see. Give slots after the first a translated `aria-label`. Set `dir="ltr"` on the field to keep a code left to right in a right-to-left page.

```tsx title="components/examples/input-otp/rtl.tsx"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPRtl() {
  return (
    <div
      dir="rtl"
      className="flex max-w-full min-w-0 flex-col items-start gap-2"
    >
      <Label htmlFor="input-otp-rtl">رمز التحقق</Label>
      <InputOTP id="input-otp-rtl" length={6} animated>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot aria-label="الخانة ٢ من ٦" />
          <InputOTPSlot aria-label="الخانة ٣ من ٦" />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot aria-label="الخانة ٤ من ٦" />
          <InputOTPSlot aria-label="الخانة ٥ من ٦" />
          <InputOTPSlot aria-label="الخانة ٦ من ٦" />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves focus into the field, to the first empty slot, and out again. Only one slot is in the tab order. |
| `←` `→` | Moves to the previous or next slot, in visual order in right-to-left layouts. |
| `Home` `↑` | Moves to the first slot. |
| `End` `↓` | Moves to the slot after the last character. |
| `Backspace` | Deletes the character in the slot, or the one before it when the slot is empty. Later characters move back. |
| `Delete` | Deletes the character in the slot and keeps focus there. |
| `Ctrl` `Backspace` | Clears the whole code. ⌘ Backspace on macOS. |
| `Ctrl` `A` | Selects the whole code (⌘ A on macOS). Backspace or Delete then clears it and returns to the first slot, typing or pasting replaces it, and Ctrl C copies all of it. Any other key or a click ends the selection. |

## Accessibility

- Each slot is a real input. The first takes its name from your `<label>` or `aria-label`; the others are named "Character 2 of 6" and so on. Pass `aria-label` on a slot to translate it.
- The first slot has `autocomplete="one-time-code"`, so iOS and macOS offer codes from Messages and Mail, Android offers SMS codes, and password managers can fill it. A whole code that lands in one slot is spread across all of them. The animated cascade runs for every source, including codes you set from the WebOTP API.
- Whenever the code becomes empty while a slot has focus, such as after a wrong code is cleared, focus moves back to the first slot so the next attempt starts in the right place.
- When `status` is set, a hidden live region next to the field announces it. Change the words with `loadingLabel`, `successLabel` and `errorLabel`.
- With `animated`, characters are drawn on a layer hidden from screen readers while the inputs keep the real value. Under reduced motion, characters only fade and the status wave becomes a gentle pulse.

## API reference

Built on the Base UI OTP field. Every Base UI prop is passed through.

### InputOTP

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `length` | `number` | – | Required. The number of slots; render the same number of InputOTPSlot parts. |
| `variant` | `"joined" \| "separate"` | `"joined"` |  |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |  |
| `animated` | `boolean` | `false` | Animate characters in and out, cascade multi-character input and animate the status. |
| `status` | `"idle" \| "loading" \| "success" \| "error"` | – | The result of checking the code. Loading makes the slots read-only. |
| `loadingLabel` | `string` | `"Verifying code"` |  |
| `successLabel` | `string` | `"Code verified"` |  |
| `errorLabel` | `string` | `"Code is incorrect"` |  |
| `value` | `string` | – |  |
| `defaultValue` | `string` | – |  |
| `onValueChange` | `(value: string, details) => void` | – |  |
| `onValueComplete` | `(value: string, details) => void` | – | Called when the last slot fills. |
| `onValueInvalid` | `(value: string, details) => void` | – | Called when typed or pasted characters are rejected. |
| `validationType` | `"numeric" \| "alpha" \| "alphanumeric" \| "none"` | `"numeric"` |  |
| `normalizeValue` | `(value: string) => string` | – | Runs after filtering. Keep it idempotent. |
| `inputMode` | `string` | – | Defaults from validationType. |
| `autoComplete` | `string` | `"one-time-code"` |  |
| `autoSubmit` | `boolean` | `false` |  |
| `mask` | `boolean` | `false` |  |
| `aria-invalid` | `boolean` | – | Marks every slot invalid. |
| `name` | `string` | – |  |
| `form` | `string` | – |  |
| `id` | `string` | – | Goes on the first slot, so a label's htmlFor points at it. |
| `disabled` | `boolean` | `false` |  |
| `readOnly` | `boolean` | `false` |  |
| `required` | `boolean` | `false` |  |
| `className` | `string \| (state) => string` | – |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="input-otp"` | The root. |
| `data-variant="joined" \| "separate"` | The current variant. |
| `data-size` | The current size. |
| `data-status` | The status, when one is set. |
| `data-animated` | Present when animated is on. |
| `data-shake` | Present while the field shakes after the status turns to error. |
| `data-complete` | Present when every slot is filled. |
| `data-filled` | Present when any slot is filled. |
| `data-focused` | Present while a slot has focus. |
| `data-disabled` | Present when disabled. |
| `data-readonly` | Present when read-only, including while loading. |
| `data-required` | Present when required. |
| `data-invalid / data-valid / data-touched / data-dirty` | Field state, inside a Field. |
| `data-slot="input-otp-status"` | The hidden live region, a sibling of the root. |

### InputOTPGroup

A plain element that lays out a run of slots. In the joined variant its slots share edges.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="input-otp-group"` | Target the group in CSS. |

### InputOTPSlot

A box holding one input. `className` goes on the box; every other prop goes on the input.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` | `"Character N of M"` | Ignored on the first slot, which uses the label. |
| `className` | `string \| (state) => string` | – | The state has the slot's index, value, filled and the field state. |
| `placeholder` | `string` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="input-otp-slot"` | The box. |
| `data-filled` | Present when the slot has a character. |
| `data-status` | The root's status, when not idle. |
| `--input-otp-index` | The slot's position, used to stagger the status motion. |
| `data-slot="input-otp-input"` | The input inside, with Base UI's data-filled, data-focused, data-complete and field attributes. |
| `data-slot="input-otp-char"` | The drawn character when animated. |

### InputOTPSeparator

A separator with a minus icon. Pass children to use another icon.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |  |
| `className` | `string \| (state) => string` | – |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="input-otp-separator"` | Target the separator in CSS. |

### inputOTPSlotVariants

The class names behind a slot and a group (`inputOTPGroupVariants`). Call them with `{ variant, size }`.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
