react-formbridge
Browse documentation
Field buildersv2.0.0

field.otp()

One-time-password builder for short verification codes. Use its headless controller to render individual character cells.

  • length() fixes the exact code length; controller.otpLength tells your UI how many cells to show
  • digitsOnly(), lettersOnly() and alphanumeric() restrict the accepted character set; your UI chooses the matching keyboard hint
  • mask() hides the typed value behind a display character (e.g. •) while keeping the real value in form state
  • groups() defines the value length; pass the same visual grouping to your application-owned component
  • Manage cell refs in your UI to move focus forward after input, backward on Backspace, and distribute pasted codes
  • Combine with validateOn: 'onChange' at hook level for instant validation as the user types
import { useRef, useState } from 'react'
import { field, useFormBridge } from '@runilib/react-formbridge'

const schema = {
  code: field.otp('Verification code')
    .groups([3, 3], '-')
    .digitsOnly()
    .required(),
  plainCode: field.otp('Verification code without groups')
    .length(6)
    .digitsOnly()
    .required(),
}

function OtpField({ form, name, groups = [6], separator = '-' }) {
  const otp = form.fieldController(name)
  const inputRefs = useRef([])

  if (!otp.visible) return null

  let offset = 0

  const setCharacters = (startIndex, input) => {
    const characters = input.replace(/\D/g, '').slice(0, otp.otpLength - startIndex)

    characters.split('').forEach((character, localIndex) => {
      otp.setDigit(startIndex + localIndex, character)
    })

    if (characters.length > 0) {
      const nextIndex = Math.min(startIndex + characters.length, otp.otpLength - 1)
      queueMicrotask(() => inputRefs.current[nextIndex]?.focus())
    }
  }

  return (
    <fieldset style={{ border: 0, padding: 0, margin: 0 }}>
      <legend style={{ marginBottom: 8 }}>
        {otp.label}{otp.required ? ' *' : ''}
      </legend>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        {groups.map((size, groupIndex) => {
          const start = offset
          offset += size

          return (
            <div key={groupIndex} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              {groupIndex > 0 ? <span aria-hidden>{separator}</span> : null}
              {Array.from({ length: size }, (_, localIndex) => {
                const index = start + localIndex

                return (
                  <input
                    key={index}
                    ref={(node) => {
                      inputRefs.current[index] = node
                      if (index === 0) otp.registerFocusable(node)
                    }}
                    aria-label={`Character ${index + 1} of ${otp.otpLength}`}
                    value={otp.digits[index] ?? ''}
                    inputMode="numeric"
                    autoComplete={index === 0 ? 'one-time-code' : 'off'}
                    maxLength={1}
                    disabled={otp.disabled}
                    onChange={(event) => setCharacters(index, event.target.value)}
                    onKeyDown={(event) => {
                      if (
                        event.key === 'Backspace' &&
                        !otp.digits[index] &&
                        index > 0
                      ) {
                        inputRefs.current[index - 1]?.focus()
                      }
                    }}
                    onPaste={(event) => {
                      event.preventDefault()
                      setCharacters(index, event.clipboardData.getData('text'))
                    }}
                    onBlur={otp.onBlur}
                    style={{
                      width: 42,
                      height: 48,
                      boxSizing: 'border-box',
                      textAlign: 'center',
                      fontSize: 20,
                      border: `1px solid ${otp.error ? '#dc2626' : '#9ca3af'}`,
                      borderRadius: 8,
                    }}
                  />
                )
              })}
            </div>
          )
        })}
      </div>
      {otp.error ? <p role="alert" style={{ color: '#dc2626' }}>{otp.error}</p> : null}
    </fieldset>
  )
}

export function OtpPlaygroundWeb() {
  const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null)
  const form = useFormBridge(schema, {
    validateOn: 'onChange',
  })
  const { Form, fieldController, state } = form
  const code = String(state.values.code ?? '')
  const plainCode = String(state.values.plainCode ?? '')

  return (
    <div
      style={{
        fontFamily: 'sans-serif',
        padding: 20,
        background: '#f5f7fb',
        display: 'grid',
        gap: 16,
      }}
    >
      <div>
        <h3 style={{ margin: '0 0 8px' }}>Interactive OTP field</h3>
        <p style={{ margin: 0, color: '#4b5563' }}>
          Type a 6-digit code to see grouped cells, live validation, and the submitted payload.
        </p>
      </div>

      <Form
        onSubmit={async (values) => {
          setSubmitted(values)
        }}
      >
        <div style={{ display: 'grid', gap: 12 }}>
          <OtpField form={form} name="code" groups={[3, 3]} />
          <p style={{ margin: 0, color: '#4b5563' }}>
            Grouped progress: <strong>{code.length}</strong> / 6
          </p>
          <OtpField form={form} name="plainCode" />
          <p style={{ margin: 0, color: '#4b5563' }}>
            Plain progress: <strong>{plainCode.length}</strong> / 6
          </p>
          <button type="submit">Verify code</button>
        </div>
      </Form>

      <div style={{ display: 'grid', gap: 12 }}>
        <div
          style={{
            border: '1px solid #d6d9e0',
            borderRadius: 12,
            padding: 12,
            background: '#fff',
          }}
        >
          <strong>Live values</strong>
          <pre style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
            {JSON.stringify(state.values, null, 2)}
          </pre>
        </div>

        <div
          style={{
            border: '1px solid #d6d9e0',
            borderRadius: 12,
            padding: 12,
            background: '#fff',
          }}
        >
          <strong>Last submit</strong>
          <pre style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
            {JSON.stringify(submitted, null, 2)}
          </pre>
        </div>
      </div>
    </div>
  )
}

export default OtpPlaygroundWeb

Defaults, inheritance & field methods

OTP-specific methods:

MethodTypeDescription
length(length, message?)length: numberFixes the exact expected code length and syncs the descriptor min/max values.
digitsOnly(message?)message?: stringRejects non-digit characters and hints a numeric keyboard.
lettersOnly(message?)message?: stringRestricts the value to ASCII letters (A-Z, a-z). Disallowed keystrokes are dropped before reaching form state.
alphanumeric(message?)message?: stringAllows letters and digits (A-Z, a-z, 0-9). Useful for invitation codes mixing both.
mask(char?)char?: string (default '•')Configures the masking character your application-owned OTP component should display while the real value stays in form state.
groups(sizes, separator?)sizes: number[], separator?: string (default '-')Configures logical groups (e.g. [3, 2] for ___-__). The total length becomes the sum of the sizes; your UI renders the separator.

Recipes

Patterns that showcase otp-specific strengths.

Standard 6-digit verification

Verification6.tsxtsx
1const schema = {
2 code: field.otp('Verification code')
3 .length(6)
4 .digitsOnly()
5 .required(),
6}

Short 4-digit PIN

Pin4.tsxtsx
1const schema = {
2 pin: field.otp('PIN')
3 .length(4)
4 .digitsOnly('Digits only')
5 .required(),
6}

Alphanumeric invitation code

InviteCode.tsxtsx
1const schema = {
2 inviteCode: field.otp('Invitation code')
3 .length(6)
4 .alphanumeric()
5 .required(),
6}

Letters-only access code

AccessCode.tsxtsx
1const schema = {
2 accessCode: field.otp('Access code')
3 .length(4)
4 .lettersOnly()
5 .required(),
6}

Masked verification code

MaskedOtp.tsxtsx
1const schema = {
2 code: field.otp('Verification code')
3 .length(6)
4 .digitsOnly()
5 .mask()
6 .required(),
7}

Grouped layout with a separator

GroupedOtp.tsxtsx
1const schema = {
2 code: field.otp('Verification code')
3 .groups([3, 2], '-')
4 .digitsOnly()
5 .required(),
6}

Auto-submit when the code is full

AutoSubmitOtp.tsxtsx
1const schema = {
2 code: field.otp('Verification code').length(6).digitsOnly().required(),
3}
4
5const form = useFormBridge(schema, {
6 validateOn: 'onChange',
7})
8 const { Form, fieldController, state, submit } = form
9
10useEffect(() => {
11 if (state.values.code?.length === 6 && state.isValid) {
12 submit()
13 }
14}, [state.values.code, state.isValid, submit])
15
16<Form onSubmit={verifyCode}>
17 <OtpField form={form} name="code" />
18</Form>