Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

OTP Field

A one-time password input made of single-character slots.

Rendered by React 19.3.1

Loading React…
import * as React from 'react'
import { OTPField } from '@hamur/react/otp-field'

export default function OTPFieldExample() {
  const [code, setCode] = React.useState('')
  const [entered, setEntered] = React.useState<string | null>(null)
  const onCodeChange = (value: string) => {
    setCode(value)
    setEntered(null)
  }
  return (
    <div className="stack">
      <label htmlFor="otp-code">Verification code</label>
      <OTPField.Root
        id="otp-code"
        className="otp"
        length={6}
        onValueChange={onCodeChange}
        onValueComplete={setEntered}
      >
        {[0, 1, 2, 3, 4, 5].map((index) => (
          <React.Fragment key={index}>
            {index === 3 ? (
              <OTPField.Separator className="otp-separator" />
            ) : null}
            <OTPField.Input
              className="input otp-input"
              aria-label={index === 0 ? undefined : `Character ${index + 1} of 6`}
            />
          </React.Fragment>
        ))}
      </OTPField.Root>
      <p className="muted">{entered ? `Code entered: ${entered}` : `${code.length} of 6 digits`}</p>
    </div>
  )
}
405Tests passing in React · 0 failing
10.8 KiBSize
Not measured yetMemory
Not measured yetSpeed
productdevbook and 10 upstream authors9 commits · last

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<OTPField.Root>
  <OTPField.Separator />
  <OTPField.Input />
</OTPField.Root>

Examples

Alphanumeric

validationType accepts letters and digits for recovery or invite codes.

OTPFieldAlphanumeric.tsx
import { OTPField } from '@hamur/react/otp-field'

const CODE_LENGTH = 6
const slots = Array.from({ length: CODE_LENGTH }, (_, index) => index)

export default function OTPFieldAlphanumericExample() {
  return (
    <div className="field">
      <label htmlFor="otp-alphanumeric">Recovery code</label>
      <OTPField.Root
        id="otp-alphanumeric"
        className="otp"
        length={CODE_LENGTH}
        validationType="alphanumeric"
        aria-describedby="otp-alphanumeric-description"
      >
        {slots.map((index) => (
          <OTPField.Input
            key={index}
            className="input otp-input"
            aria-label={index === 0 ? undefined : `Character ${index + 1} of ${CODE_LENGTH}`}
          />
        ))}
      </OTPField.Root>
      <p id="otp-alphanumeric-description" className="muted">
        Accept letters and numbers for backup codes such as <code className="code">A7C9XZ</code>.
      </p>
    </div>
  )
}

Grouped

Separator splits the slots into groups, such as 123-456.

OTPFieldGrouped.tsx
import { OTPField } from '@hamur/react/otp-field'

const OTP_LENGTH = 6

export default function OTPFieldGroupedExample() {
  return (
    <div className="field">
      <label htmlFor="otp-grouped">Verification code</label>
      <OTPField.Root id="otp-grouped" className="otp" length={OTP_LENGTH}>
        <div className="otp-group">
          {[0, 1, 2].map((index) => (
            <OTPField.Input
              key={index}
              className="input otp-input"
              aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
            />
          ))}
        </div>
        <OTPField.Separator className="otp-separator" />
        <div className="otp-group">
          {[3, 4, 5].map((index) => (
            <OTPField.Input
              key={index}
              className="input otp-input"
              aria-label={`Character ${index + 1} of ${OTP_LENGTH}`}
            />
          ))}
        </div>
      </OTPField.Root>
    </div>
  )
}

Custom Sanitize

normalizeValue uppercases what is typed before it is accepted.

OTPFieldCustomSanitize.tsx
import * as React from 'react'
import { OTPField } from '@hamur/react/otp-field'

const CODE_LENGTH = 6
const slots = Array.from({ length: CODE_LENGTH }, (_, index) => index)

function normalizeRecoveryCode(value: string) {
  return value.toUpperCase()
}

/** A shake on the focused slot and a polite message when a character is rejected. */
function useInvalidFeedback() {
  const [focusedIndex, setFocusedIndex] = React.useState(0)
  const [invalidPulse, setInvalidPulse] = React.useState(0)
  const [statusMessage, setStatusMessage] = React.useState('')
  const timeout = React.useRef<ReturnType<typeof setTimeout> | null>(null)
  const skipClear = React.useRef(false)

  React.useEffect(
    () => () => {
      if (timeout.current != null) clearTimeout(timeout.current)
    },
    [],
  )

  function handleValueChange() {
    if (skipClear.current) {
      skipClear.current = false
      return
    }
    if (timeout.current != null) clearTimeout(timeout.current)
    timeout.current = null
    setInvalidPulse(0)
    setStatusMessage('')
  }

  function handleValueInvalid(value: string) {
    skipClear.current = true
    setInvalidPulse((current) => current + 1)
    setStatusMessage(`Unsupported characters were ignored from ${value}.`)
    if (timeout.current != null) clearTimeout(timeout.current)
    timeout.current = setTimeout(() => {
      timeout.current = null
      setInvalidPulse(0)
    }, 400)
  }

  return {
    activeInvalidIndex: invalidPulse > 0 ? focusedIndex : -1,
    invalidClass:
      invalidPulse === 0 ? '' : invalidPulse % 2 === 0 ? 'otp-invalid-b' : 'otp-invalid-a',
    statusMessage,
    setFocusedIndex,
    handleValueChange,
    handleValueInvalid,
  }
}

export default function OTPFieldCustomSanitizeExample() {
  const feedback = useInvalidFeedback()
  return (
    <div className="field">
      <label htmlFor="otp-sanitize">Recovery code</label>
      <OTPField.Root
        id="otp-sanitize"
        className="otp"
        length={CODE_LENGTH}
        validationType="alphanumeric"
        normalizeValue={normalizeRecoveryCode}
        aria-describedby="otp-sanitize-description"
        onValueChange={feedback.handleValueChange}
        onValueInvalid={feedback.handleValueInvalid}
      >
        {slots.map((index) => (
          <OTPField.Input
            key={index}
            className={`input otp-input ${feedback.activeInvalidIndex === index ? feedback.invalidClass : ''}`.trim()}
            aria-label={index === 0 ? undefined : `Character ${index + 1} of ${CODE_LENGTH}`}
            onFocus={() => feedback.setFocusedIndex(index)}
          />
        ))}
      </OTPField.Root>
      <p id="otp-sanitize-description" className="muted">
        Letters and digits only. Letters are converted to uppercase.
      </p>
      <span aria-live="polite" className="sr-only">
        {feedback.statusMessage}
      </span>
    </div>
  )
}

Password

mask hides the characters while they are typed.

OTPFieldPassword.tsx
import { OTPField } from '@hamur/react/otp-field'

const CODE_LENGTH = 6
const slots = Array.from({ length: CODE_LENGTH }, (_, index) => index)

export default function OTPFieldPasswordExample() {
  return (
    <div className="field">
      <label htmlFor="otp-password">Access code</label>
      <OTPField.Root
        id="otp-password"
        className="otp"
        length={CODE_LENGTH}
        mask
        aria-describedby="otp-password-description"
      >
        {slots.map((index) => (
          <OTPField.Input
            key={index}
            className="input otp-input"
            aria-label={index === 0 ? undefined : `Character ${index + 1} of ${CODE_LENGTH}`}
          />
        ))}
      </OTPField.Root>
      <p id="otp-password-description" className="muted">
        Use <code className="code">mask</code> to obscure the code on shared screens.
      </p>
    </div>
  )
}

API reference

19 props across 3 parts, in React syntax. className, style, render are listed once, under Shared props.

InputOTPField.Input

An individual OTP character input. Renders an <input>.

Input data attributes
Data attributeDescription
data-completePresent when all slots are filled.
data-filledPresent when the input contains a character.
data-disabledPresent when the OTP field is disabled.
data-readonlyPresent when the OTP field is readonly.
data-requiredPresent when the OTP field is required.
data-validPresent when the OTP field is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the OTP field is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the OTP field has been touched (when wrapped in Field.Root).
data-dirtyPresent when the OTP field's value has changed (when wrapped in Field.Root).
data-focusedPresent when any OTP field input is focused.

RootOTPField.Root

Groups all OTP field parts and manages their state. Renders a <div>.

Root props
PropTypeDefault
nameIdentifies the field when a form is submitted.string—
defaultValueThe uncontrolled OTP value when the component is initially rendered.string—
valueThe OTP value.string—
onValueChangeCallback fired when the OTP value changes.((value: string, eventDetails: OTPFieldRootChangeEventDetails) => void)—
autoCompleteThe input autocomplete attribute.string'one-time-code'
autoSubmitWhether to submit the owning form when the OTP becomes complete.booleanfalse
formA string specifying the form element with which the hidden input is associated.string—
inputModeThe virtual keyboard hint applied to the slot inputs and hidden validation input."search" | "text" | "none" | "tel" | "url" | "email" | "numeric" | "decimal"—
lengthrequiredThe number of OTP input slots.number—
maskWhether the slot inputs should mask entered characters.booleanfalse
normalizeValueFunction that normalizes the OTP value after whitespace and validationType filtering.((value: string) => string)—
onValueCompleteCallback function that is fired when the OTP value becomes complete, or when a complete value((value: string, eventDetails: OTPFieldRootCompleteEventDetails) => void)—
onValueInvalidCallback fired when entered text contains characters that are rejected by validation or((value: string, eventDetails: OTPFieldRootInvalidEventDetails) => void)—
validationTypeThe type of input validation to apply to the OTP value.OTPValidationType'numeric'
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the field value.booleanfalse
requiredWhether the user must enter a value before submitting a form.booleanfalse
idThe id of the first input element.string—
Root data attributes
Data attributeDescription
data-completePresent when all slots are filled.
data-disabledPresent when the OTP field is disabled.
data-readonlyPresent when the OTP field is readonly.
data-requiredPresent when the OTP field is required.
data-validPresent when the OTP field is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the OTP field is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the OTP field has been touched (when wrapped in Field.Root).
data-dirtyPresent when the OTP field's value has changed (when wrapped in Field.Root).
data-filledPresent when the OTP field contains at least one character.
data-focusedPresent when one of the OTP field inputs is focused.

SeparatorOTPField.Separator

A separator element accessible to screen readers. Renders a <div>.

Separator props
PropTypeDefault
orientationThe orientation of the separator.Orientation'horizontal'
Separator data attributes
Data attributeDescription
data-orientationIndicates the orientation of the separator.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
Fills the slot and moves to the next oneInput
ArrowLeftArrowRightMoves between slotsInput
HomeMoves to the first slotInput
EndMoves to the first empty slot at the endInput
BackspaceDeletes the character and moves to the previous slotInput
DeleteDeletes the character and stays in the slotInput

Accessibility

What the parts set
PartSetsSo that
Rootrole="group", aria-labelledby to the Field labelthe slots are read as one named field
Inputtabindex="0" on the active slot onlyTab passes the whole code in one step
Inputthe field's label on the first slot, your aria-label on the otherseach slot can be named, such as "Character 2 of 6"
Inputaria-invalid when validation failsthe error state is read on every slot
Separatorrole="separator"a visual break between groups of slots is announced as such

Focus order

  1. The field is one tab stop: the active slot, which is the first empty one.
  2. Typing and the arrow keys move between slots; Tab leaves the field.