BetaPublic beta — Read the release notes
Documentation

Input OTP

Basic input otp example demonstrating core behavior.

Package
@solidiom/input-otp
Version
0.0.1-next.0
Status
stable

Examples

import * as InputOtp from "@solidiom/input-otp"

;<InputOtp.Root
  maxLength={6}
  pattern="^[0-9]*$"
  onComplete={(value) => console.log("Code entered:", value)}
>
  <InputOtp.Group>
    <InputOtp.Slot index={0} />
    <InputOtp.Slot index={1} />
    <InputOtp.Slot index={2} />
  </InputOtp.Group>

  <InputOtp.Group>
    <InputOtp.Slot index={3} />
    <InputOtp.Slot index={4} />
    <InputOtp.Slot index={5} />
  </InputOtp.Group>
</InputOtp.Root>

Each Slot displays one character from the OTP value. Use Group to visually separate segments. The pattern prop restricts allowed characters. The onComplete callback fires when all slots are filled.

View source
        export function Root(props: InputOTPRootProps) {
  let inputRef: HTMLInputElement | undefined
  const inputId = createStableId("otp-input")

  const [internalValue, setInternalValue] = createSignal(props.defaultValue ?? "")
  const [isFocused, setIsFocused] = createSignal(false)

  const value = () => {
    if (props.value !== undefined) {
      return props.value() ?? ""
    }
    return internalValue()
  }

  const activeIndex = () => {
    const len = value().length
    return Math.min(len, props.maxLength - 1)
  }

  const patternRegex = props.pattern ? new RegExp(props.pattern) : undefined

  const setValue = (next: string) => {
    const truncated = next.slice(0, props.maxLength)

    // Validate each character
    if (patternRegex) {
      const valid = truncated.split("").every((ch) => patternRegex.test(ch))
      if (!valid) return
    }

    if (props.value === undefined) {
      setInternalValue(truncated)
    }
    props.onValueChange?.(truncated)

    if (truncated.length === props.maxLength) {
      props.onComplete?.(truncated)
    }
  }

  const handleInput = (e: InputEvent) => {
    const target = e.target as HTMLInputElement
    setValue(target.value)
  }

  const handleKeyDown = (e: KeyboardEvent) => {
    if (props.disabled) {
      e.preventDefault()
      return
    }
    // Allow navigation keys
    if (
      e.key === "Backspace" ||
      e.key === "Delete" ||
      e.key === "ArrowLeft" ||
      e.key === "ArrowRight"
    ) {
      return
    }
  }

  const handlePaste = (e: ClipboardEvent) => {
    e.preventDefault()
    if (props.disabled) return
    const pasted = e.clipboardData?.getData("text/plain") ?? ""
    setValue(pasted)
  }

  const focus = () => {
    inputRef?.focus()
  }

  return (
    <InputOTPContext
      value={{
        value,
        maxLength: props.maxLength,
        activeIndex,
        isFocused,
        pattern: patternRegex,
        disabled: props.disabled,
        focus,
      }}
    >
      <div
        class={props.class}
        style={props.style}
        onClick={focus}
        {...applySemanticAttrs({
          scope: "input-otp",
          part: "root",
          disabled: props.disabled,
        })}
      >
        {/* Hidden input drives the actual value */}
        <input
          ref={inputRef}
          id={inputId}
          type="text"
          inputmode="numeric"
          autocomplete="one-time-code"
          maxlength={props.maxLength}
          value={value()}
          disabled={props.disabled}
          onInput={handleInput}
          onKeyDown={handleKeyDown}
          onPaste={handlePaste}
          onFocus={() => setIsFocused(true)}
          onBlur={() => setIsFocused(false)}
          aria-label="One-time password"
          style={{
            position: "absolute",
            width: "1px",
            height: "1px",
            padding: "0",
            margin: "-1px",
            overflow: "hidden",
            clip: "rect(0, 0, 0, 0)",
            "white-space": "nowrap",
            "border-width": "0",
          }}
        />
        {props.children}
      </div>
    </InputOTPContext>
  )
}