component — inputs

Input OTP

One-time-password / verification-code input built on the input-otp package, styled with the quebi design system. Each slot is an underlined cell and the active slot's line thickens; supports separators, invalid, and disabled states. The whole control is one input rather than one per slot: click a slot you have already typed to put the caret on it, but a slot ahead of the code is not selectable — clicking there, like typing, moves to the first empty slot, because a half-entered code has no representation with a gap in the middle.

  • form
  • input
  • otp
  • code
  • verification
  • interactive

05 examples

Default

A six-digit code in a single group. One input sits under all six slots: click a digit you have typed to correct it, and anything further right lands on the first empty slot.

Sizes

xs (30px), sm (38px), and the default md (42px) — a slot is as tall as a field of the same step, and square. The size reaches the slots through the field-size context, so it is written once on InputOTP and a code field in a table cell is small without anyone saying so.

With separator

Two groups of three split by a separator.

Disabled

Controlled

Reads the current value as you type.

Enter your code

source

input-otp.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { use, useCallback, useRef } from "react"
import { FieldSizeContext, useFieldSizing } from "@/lib/field-size"
import { cn } from "@/lib/utils"

/**
 * The slot the pointer is over, read from the slot's own `data-index` rather
 * than from its position among its siblings — the test is geometric, so it
 * stays correct in RTL and under any layout that splits the slots into groups.
 *
 * A slot the pointer is inside wins. Failing that, only the horizontal extent
 * is tested: the slots usually sit in one row under an input that covers the
 * whole container, so the column is what identifies a slot, and a container
 * styled taller than its slots would otherwise reject a click that plainly
 * aimed at one. The two-axis pass comes first for a row that wraps — a long
 * code such as an IBAN in a narrow column — where one column holds a slot on
 * every line.
 */
function slotIndexAt(input: HTMLInputElement, clientX: number, clientY: number): number | null {
  const container = input.closest("[data-input-otp-container]")
  if (!container) return null
  const slots = Array.from(
    container.querySelectorAll<HTMLElement>('[data-slot="input-otp-slot"]'),
  ).map((slot) => ({ slot, rect: slot.getBoundingClientRect() }))
  const inColumn = ({ rect }: { rect: DOMRect }) =>
    clientX >= rect.left && clientX <= rect.right
  const hit =
    slots.find((entry) => inColumn(entry) && clientY >= entry.rect.top && clientY <= entry.rect.bottom) ??
    slots.find(inColumn)
  if (!hit) return null
  const index = Number(hit.slot.dataset.index)
  return Number.isInteger(index) ? index : null
}

/**
 * The field size scale, as a square slot expresses it: a slot is as tall as a
 * field of the same step (`xs` 30px, `sm` 38px, `md` 42px and the default) and
 * exactly as wide, because a digit box is square.
 *
 * `md` was 40px before task #186, a step of its own that matched nothing.
 *
 * Written out rather than imported from `input.tsx`, as in `select.tsx` and
 * `number-field.tsx`: three strings are not worth making `Input` a registry
 * dependency of this file.
 */
const inputOtpSlotSizeStyles = {
  xs: "size-7.5 text-xs",
  sm: "size-9.5 text-sm",
  md: "size-10.5 text-sm",
} as const

type InputOtpSize = keyof typeof inputOtpSlotSizeStyles

/**
 * `OTPInput`'s props are a union — either `children` or a `render` callback,
 * never both — so the omit has to distribute over it. A plain
 * `Omit<Props, "size">` collapses the two branches into one object that has
 * both keys optional, and the component then rejects every call.
 */
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never

type InputOTPProps = DistributiveOmit<React.ComponentPropsWithoutRef<typeof OTPInput>, "size"> & {
  /**
   * Slot height and width. Shadows the `<input size>` attribute, which sizes a
   * field in characters and means nothing to a control whose single input is
   * stretched invisibly across every slot.
   */
  size?: InputOtpSize
}

/**
 * InputOTP — quebi design system
 *
 * One-time-password / verification-code input built on the `input-otp`
 * package. Each slot is its own cell, drawn like `Input` — boxed by default,
 * underlined inside `quebi-editorial`: the active slot's frame turns `focus`
 * and doubles, no ring. Invalid turns the frames `danger`; disabled dims the
 * whole control.
 *
 * `size` reaches the slots through `FieldSizeContext` rather than through a
 * prop, because the slots are written by the caller — `<InputOTP><InputOTPSlot
 * index={0} />…</InputOTP>` — and nothing this component renders is between
 * them. That is the same channel a table cell uses, so a code field in a cell
 * is `sm` with nobody saying so.
 */
export function InputOTP({
  className,
  containerClassName,
  onPointerDown,
  size: sizeProp,
  ...props
}: InputOTPProps) {
  const sizing = useFieldSizing({ size: sizeProp })
  const inputRef = useRef<HTMLInputElement>(null)

  /**
   * Put the caret on the slot that was clicked, when the value reaches that
   * far.
   *
   * `OTPInput` is a single `<input>` stretched across every slot, and it draws
   * its text transparently at `letter-spacing: -.5em`, so the whole value
   * occupies a few pixels at the left edge: the caret the browser places from
   * a click has nothing to do with the slot under the pointer, and the end of
   * the value is what almost every click resolves to. `OTPInput`'s own
   * `onFocus` then snaps the caret to the end of the value regardless. Between
   * them, clicking a digit you had already typed selected the last one
   * instead, and there was no way to go back and fix a slot with the mouse.
   *
   * Deriving the offset from the slot geometry fixes that. A slot *past* the
   * end of the value stays unaddressable and is meant to: a caret cannot sit
   * where the string has no character, so those clicks keep landing on the
   * first empty slot — a code is typed left to right and a value with a hole
   * in it has no representation here.
   */
  const handlePointerDown = useCallback(
    (event: React.PointerEvent<HTMLInputElement>) => {
      onPointerDown?.(event)
      const input = inputRef.current
      if (!input || input.disabled || event.defaultPrevented || event.button !== 0) return
      const index = slotIndexAt(input, event.clientX, event.clientY)
      if (index === null || index >= input.value.length) return
      // Focusing the input, and the snap to end-of-value inside that, are the
      // pointerdown default action — both run after this handler returns, so
      // the correction has to wait for them. A frame later they are done.
      requestAnimationFrame(() => {
        if (document.activeElement !== input) return
        input.setSelectionRange(index, index + 1, "forward")
      })
    },
    [onPointerDown],
  )

  return (
    <FieldSizeContext value={sizing}>
      <OTPInput
        data-slot="input-otp"
        containerClassName={cn(
          "flex items-center gap-2 has-disabled:opacity-50",
          containerClassName,
        )}
        className={cn("disabled:cursor-not-allowed", className)}
        {...props}
        ref={inputRef}
        onPointerDown={handlePointerDown}
      />
    </FieldSizeContext>
  )
}

export function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="input-otp-group"
      className={cn("flex items-center gap-2", className)}
      {...props}
    />
  )
}

export function InputOTPSlot({
  index,
  className,
  size: sizeProp,
  ...props
}: React.ComponentProps<"div"> & {
  index: number
  /** Slot height and width. Matches `Input`'s scale — see `@/lib/field-size`. */
  size?: InputOtpSize
}) {
  const inputOTPContext = use(OTPInputContext)
  const { char, placeholderChar, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
  const { size } = useFieldSizing({ size: sizeProp })

  return (
    <div
      data-slot="input-otp-slot"
      // Which slot this is, for the pointer hit test in `InputOTP` — the one
      // place that has to map a click back to an offset in the value.
      data-index={index}
      data-active={isActive}
      className={cn(
        "relative flex items-center justify-center text-quebi-fg",
        inputOtpSlotSizeStyles[size],
        "quebi-field outline-none",
        "transition-[border-color,box-shadow] duration-150",
        "data-[active=true]:shadow-(--q-field-focus)",
        "aria-invalid:[--q-field-edge:var(--q-danger)] data-[active=true]:aria-invalid:shadow-(--q-field-focus-danger)",
        className,
      )}
      {...props}
    >
      {char ?? (
        // `InputOTP`'s `placeholder`, one character per slot. `input-otp`
        // clears every one of them as soon as the first character is typed.
        <span aria-hidden="true" className="text-quebi-fg-subtle">
          {placeholderChar}
        </span>
      )}
      {hasFakeCaret && (
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <div className="h-4 w-px animate-caret-blink bg-quebi-fg duration-1000" />
        </div>
      )}
    </div>
  )
}

export function InputOTPSeparator({ className, ...props }: React.ComponentProps<"div">) {
  return (
    // The dash between two groups of slots is decorative: the slots either
    // side already carry the accessible structure, and a `separator` role here
    // announced a divider that means nothing to someone typing a code. Hidden
    // from the accessibility tree it is what it looks like — a visual gap.
    <div
      data-slot="input-otp-separator"
      aria-hidden="true"
      className={cn("text-quebi-fg-subtle", className)}
      {...props}
    >
      <Minus className="size-4" />
    </div>
  )
}