Skip to content

Verification code

An OTP flow with a resend cooldown and a failure state that does not clear the entered code.

AuthenticationintermediateFeaturedauthotptwo-factorresend

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import { useState } from 'react'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Field } from '@/components/ui/field'
import { OTPField } from '@/components/ui/otp-field'
import { useDemoForm } from '@/hooks/use-demo-form'
import { pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Verification code
 *
 * Wraps the OTP primitive in a real flow, including the two things that make
 * verification screens usable: a resend control with a visible cooldown, and a
 * failure state that does not clear the entered code.
 *
 * Demo code: 481902.
 */
export default function VerificationForm() {
  const [cooldown, setCooldown] = useState(0)

  const form = useDemoForm({
    schema: {
      code: {
        validators: [required('Code'), pattern(/^\d{6}$/, 'Enter all six digits.')],
      },
    },
    simulateServerError: (values) =>
      values.code === '481902' ? null : 'That code is incorrect or has expired. Request a new one.',
  })

  const resend = () => {
    setCooldown(30)
    const tick = setInterval(() => {
      setCooldown((current) => {
        if (current <= 1) {
          clearInterval(tick)
          return 0
        }
        return current - 1
      })
    }, 1000)
  }

  return (
    <FormShell
      title="Verify your email"
      description="We sent a six-digit code to priya@northwind.example."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Verify"
      submittingLabel="Verifying"
      onReset={form.reset}
      successTitle="Email verified"
      width="sm"
    >
      <Field
        name="code"
        label="Verification code"
        required
        error={form.error('code')}
        hint="Demo code: 481902 — pasting the whole code fills every box."
      >
        {(field) => (
          <OTPField
            id={field.id}
            name={field.name}
            aria-describedby={field['aria-describedby']}
            invalid={Boolean(form.error('code')) || form.status === 'error'}
            onValueChange={(value) => form.setValue('code', value)}
          />
        )}
      </Field>

      <div className="flex items-center gap-3">
        <Button type="button" variant="outline" size="sm" onClick={resend} disabled={cooldown > 0}>
          Resend code
        </Button>
        {cooldown > 0 ? (
          <span aria-live="polite" className="text-xs text-ink-muted">
            You can request another in {cooldown}s
          </span>
        ) : null}
      </div>

      <Alert tone="neutral">No message is sent. The code is checked entirely in the browser.</Alert>
    </FormShell>
  )
}

components/blocks/forms/verification.tsx

'use client'

import { useState } from 'react'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Field } from '@/components/ui/field'
import { OTPField } from '@/components/ui/otp-field'
import { useDemoForm } from '@/hooks/use-demo-form'
import { pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Verification code
 *
 * Wraps the OTP primitive in a real flow, including the two things that make
 * verification screens usable: a resend control with a visible cooldown, and a
 * failure state that does not clear the entered code.
 *
 * Demo code: 481902.
 */
export default function VerificationForm() {
  const [cooldown, setCooldown] = useState(0)

  const form = useDemoForm({
    schema: {
      code: {
        validators: [required('Code'), pattern(/^\d{6}$/, 'Enter all six digits.')],
      },
    },
    simulateServerError: (values) =>
      values.code === '481902' ? null : 'That code is incorrect or has expired. Request a new one.',
  })

  const resend = () => {
    setCooldown(30)
    const tick = setInterval(() => {
      setCooldown((current) => {
        if (current <= 1) {
          clearInterval(tick)
          return 0
        }
        return current - 1
      })
    }, 1000)
  }

  return (
    <FormShell
      title="Verify your email"
      description="We sent a six-digit code to priya@northwind.example."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Verify"
      submittingLabel="Verifying"
      onReset={form.reset}
      successTitle="Email verified"
      width="sm"
    >
      <Field
        name="code"
        label="Verification code"
        required
        error={form.error('code')}
        hint="Demo code: 481902 — pasting the whole code fills every box."
      >
        {(field) => (
          <OTPField
            id={field.id}
            name={field.name}
            aria-describedby={field['aria-describedby']}
            invalid={Boolean(form.error('code')) || form.status === 'error'}
            onValueChange={(value) => form.setValue('code', value)}
          />
        )}
      </Field>

      <div className="flex items-center gap-3">
        <Button type="button" variant="outline" size="sm" onClick={resend} disabled={cooldown > 0}>
          Resend code
        </Button>
        {cooldown > 0 ? (
          <span aria-live="polite" className="text-xs text-ink-muted">
            You can request another in {cooldown}s
          </span>
        ) : null}
      </div>

      <Alert tone="neutral">No message is sent. The code is checked entirely in the browser.</Alert>
    </FormShell>
  )
}

components/ui/otp-field.tsx

'use client'

import { useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react'
import { cn } from '@/lib/cn'

/**
 * OTPField
 *
 * A segmented one-time-code entry. The behaviour people actually expect is
 * fiddly, so it is all handled here: typing advances, Backspace on an empty box
 * steps back, arrow keys move without editing, and pasting a full code fills
 * every box at once instead of dropping six characters into the first one.
 *
 * `autoComplete="one-time-code"` on the first box lets iOS and Android offer
 * the SMS code, and a visually hidden label describes each position.
 */
export interface OTPFieldProps {
  length?: number
  name?: string
  onComplete?: (code: string) => void
  onValueChange?: (code: string) => void
  disabled?: boolean
  invalid?: boolean
  'aria-describedby'?: string
  className?: string
  id?: string
}

export function OTPField({
  length = 6,
  name = 'code',
  onComplete,
  onValueChange,
  disabled,
  invalid,
  className,
  id = 'otp',
  ...aria
}: OTPFieldProps) {
  const [digits, setDigits] = useState<string[]>(() => Array.from({ length }, () => ''))
  const refs = useRef<Array<HTMLInputElement | null>>([])

  const commit = (next: string[]) => {
    setDigits(next)
    const code = next.join('')
    onValueChange?.(code)
    if (code.length === length && !next.includes('')) onComplete?.(code)
  }

  const focusAt = (index: number) => {
    const target = refs.current[Math.max(0, Math.min(length - 1, index))]
    target?.focus()
    target?.select()
  }

  const handleChange = (index: number, raw: string) => {
    const char = raw.replace(/\D/g, '').slice(-1)
    const next = [...digits]
    next[index] = char
    commit(next)
    if (char) focusAt(index + 1)
  }

  const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'Backspace') {
      if (digits[index]) {
        const next = [...digits]
        next[index] = ''
        commit(next)
        return
      }
      event.preventDefault()
      const next = [...digits]
      next[Math.max(0, index - 1)] = ''
      commit(next)
      focusAt(index - 1)
    } else if (event.key === 'ArrowLeft') {
      event.preventDefault()
      focusAt(index - 1)
    } else if (event.key === 'ArrowRight') {
      event.preventDefault()
      focusAt(index + 1)
    }
  }

  const handlePaste = (index: number, event: ClipboardEvent<HTMLInputElement>) => {
    event.preventDefault()
    const pasted = event.clipboardData.getData('text').replace(/\D/g, '')
    if (!pasted) return
    const next = [...digits]
    for (let i = 0; i < pasted.length && index + i < length; i += 1) {
      next[index + i] = pasted[i] ?? ''
    }
    commit(next)
    focusAt(index + pasted.length)
  }

  return (
    <div
      className={cn('flex items-center gap-2', className)}
      role="group"
      aria-label="One-time code"
    >
      {digits.map((digit, index) => (
        <div key={index} className="contents">
          {index === Math.floor(length / 2) && length % 2 === 0 ? (
            <span className="px-1 text-ink-subtle" aria-hidden="true">
              –
            </span>
          ) : null}
          <input
            ref={(el) => {
              refs.current[index] = el
            }}
            id={index === 0 ? id : `${id}-${index}`}
            name={`${name}-${index + 1}`}
            inputMode="numeric"
            autoComplete={index === 0 ? 'one-time-code' : 'off'}
            maxLength={1}
            value={digit}
            disabled={disabled}
            aria-label={`Digit ${index + 1} of ${length}`}
            aria-invalid={invalid || undefined}
            aria-describedby={index === 0 ? aria['aria-describedby'] : undefined}
            onChange={(event) => handleChange(index, event.target.value)}
            onKeyDown={(event) => handleKeyDown(index, event)}
            onPaste={(event) => handlePaste(index, event)}
            onFocus={(event) => event.target.select()}
            className={cn(
              'size-11 rounded-md border bg-surface text-center font-mono text-lg text-ink',
              'transition-colors duration-150 ease-standard',
              'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-ink-subtle',
              invalid ? 'border-danger bg-danger-soft' : 'border-line hover:border-line-strong',
            )}
          />
        </div>
      ))}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Wraps the OTP primitive in a real flow. The two things that make verification screens usable are a resend control with a visible cooldown and a failure that preserves what was typed. Demo code: 481902.

  • Pasting a full code fills every box — the single most common OTP bug.
  • The cooldown is announced politely, so a screen-reader user knows why the button is disabled.
  • Validation runs on completion, not on every digit.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Idle
  • Incomplete
  • Incorrect code
  • Resend cooldown
  • Success

Accessibility

Per-digit labels
Each box is labelled “Digit n of 6”.
Cooldown announcement
The remaining time is in a polite live region.
Preserved input
A rejected code stays in the field so it can be corrected rather than retyped.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.

All forms