# Verification Code

> A one-time code screen with a resend countdown, wrong-code and success states, in wording that never reveals whether an address exists: one card, beside what to look for in the inbox, or a row inside your own form.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/verification-code).

- Page, with a live preview: https://shadcn.exchange/r/miseui/verification-code
- Screenshot: https://shadcn.exchange/shot/miseui/verification-code/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/verification-code.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/verification-code.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/motion.json`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/input-otp.json`

## Files

### `registry/miseui/blocks/verification-code.tsx`

```tsx
import type * as React from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { VerificationInline } from "./verification-code-inline"
import { CodeForm, VerifiedScreen, type CodeFormProps, type VerifiedProps } from "./verification-code-parts"
import { VerificationSplit } from "./verification-code-split"
import { useVerification, WRONG_CODE } from "./verification-code-state"

type VerificationCodeVariant = "card" | "split" | "inline"

const PREVIEW_DELAY_MS = 900
const PREVIEW_CODE = "246810"
const DEFAULT_LENGTH = 6
const DEFAULT_RESEND_SECONDS = 30
const DEFAULT_TIPS: readonly string[] = ["Not there after a minute? Look in spam or promotions.", "Each new code replaces the one before it."]

/** Stands in until you pass onVerify, so the preview reaches the wrong-code and success states. Replace it. */
function previewVerify(code: string) {
  return new Promise<void>((resolve, reject) => {
    window.setTimeout(() => (code === PREVIEW_CODE ? resolve() : reject(new Error(WRONG_CODE))), PREVIEW_DELAY_MS)
  })
}

function previewResend() {
  return new Promise<void>((resolve) => {
    window.setTimeout(resolve, PREVIEW_DELAY_MS)
  })
}

interface VerificationCodeProps extends Omit<React.ComponentProps<"section">, "title"> {
  /** `card` one card on the grey ground, `split` the form beside what to look for in the inbox, `inline` a row inside your own form. Anything else is `card`. */
  variant?: VerificationCodeVariant
  /** Where the code went. It is only repeated back, in words that do not say whether it has an account. */
  destination?: string
  title?: string
  /** The quieter second part of the heading. The default never says the address exists, so this screen cannot be used to test addresses. */
  note?: string
  length?: number
  /** Check the code here. Return a promise to get the checking state. A rejection's message is announced and shown, so keep it about the code. */
  onVerify?: (code: string) => void | Promise<void>
  /** Send another. A rejection's message is announced and shown. */
  onResend?: () => void | Promise<void>
  /** How long before another code may be requested. Long enough for mail to arrive. */
  resendAfterSeconds?: number
  /** An error you already know about, such as too many tries. */
  error?: string
  /** Under the field, for anything true about the code, such as how long it lasts. */
  hint?: string
  /** Puts the caret in the field on arrival. Turn it on when this is the whole screen. */
  autoFocus?: boolean
  verifyLabel?: string
  /** Where "Use a different email" goes. Null removes it. */
  changeHref?: string | null
  changeLabel?: string
  sentNote?: string
  verifiedTitle?: string
  verifiedNote?: string
  continueLabel?: string
  onContinue?: () => void
  /** `split` only: the name and subject on the real message. */
  sender?: string
  subject?: string
  tips?: readonly string[]
}

interface CardProps extends React.ComponentProps<"section"> {
  title: string
  note: string
  form: CodeFormProps
  verified: VerifiedProps
}

function VerificationCard({ title, note, form, verified, className, ...props }: CardProps) {
  return (
    <section data-slot="verification-code" data-variant="card" className={cn("@container flex w-full flex-col bg-muted dark:bg-background", className)} {...props}>
      <div className="flex min-h-[640px] flex-1 flex-col items-center justify-center px-4 py-12 @md:px-6">
        <div className="flex w-full max-w-sm flex-col gap-6 rounded-2xl border bg-background p-6 @md:p-8 dark:bg-card">
          {form.flow.verified ? (
            <VerifiedScreen {...verified} />
          ) : (
            <>
              <h2 className="text-display text-2xl leading-[1.12]! text-balance wrap-break-word">
                {title} <span className="text-muted-foreground">{note}</span>
              </h2>
              <CodeForm {...form} />
            </>
          )}
        </div>
      </div>
    </section>
  )
}

function VerificationCode({
  variant,
  destination = "maya@northgate.studio",
  title = "Enter your code.",
  note,
  length = DEFAULT_LENGTH,
  onVerify = previewVerify,
  onResend = previewResend,
  resendAfterSeconds = DEFAULT_RESEND_SECONDS,
  error,
  hint,
  autoFocus,
  verifyLabel = "Verify",
  changeHref = "#change-email",
  changeLabel = "Use a different email",
  sentNote = "A new code is on its way, if this address has an account.",
  verifiedTitle = "Code accepted.",
  verifiedNote = "You can carry on.",
  continueLabel = "Continue",
  onContinue,
  sender = "Paperline",
  subject = "Your Paperline code",
  tips = DEFAULT_TIPS,
  ...props
}: VerificationCodeProps) {
  const flow = useVerification({ length, onVerify, onResend, resendAfterSeconds, error })
  const quiet = note ?? `If ${destination} has an account, a ${length}-digit code is on its way.`
  const form = { flow, label: "Verification code", length, hint, autoFocus, verifyLabel, changeHref, changeLabel, sentNote }
  const verified = { title: verifiedTitle, note: verifiedNote, continueLabel, onContinue }
  if (variant === "split") return <VerificationSplit title={title} note={quiet} form={form} verified={verified} sender={sender} subject={subject} tips={tips} {...props} />
  if (variant === "inline") return <VerificationInline flow={flow} label={title} note={quiet} length={length} hint={hint} autoFocus={autoFocus} sentNote={sentNote} verifiedTitle={verifiedTitle} {...props} />
  return <VerificationCard title={title} note={quiet} form={form} verified={verified} {...props} />
}

export { VerificationCode, type VerificationCodeProps, type VerificationCodeVariant }
```

### `registry/miseui/blocks/verification-code-inline.tsx`

```tsx
import type * as React from "react"
import { Loader2 } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { CodeField, ResendButton, ResendNote, VerifiedMark, type VerificationCopy } from "./verification-code-parts"
import type { Verification } from "./verification-code-state"

interface InlineProps extends React.ComponentProps<"section">, VerificationCopy {
  flow: Verification
  label: string
  note: string
  sentNote: string
  verifiedTitle: string
}

/** No button: the last digit submits, and a row inside someone else's form should not bring a second primary action. */
function VerificationInline({ flow, label, note, length, hint, autoFocus, sentNote, verifiedTitle, className, ...props }: InlineProps) {
  const submit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    void flow.verify(flow.code)
  }
  return (
    <section data-slot="verification-code" data-variant="inline" className={cn("@container w-full", className)} {...props}>
      {flow.verified ? (
        <div role="status" className="flex items-center gap-3">
          <VerifiedMark className="size-8 [&_svg]:size-4" />
          <p className="text-[15px]">{verifiedTitle}</p>
        </div>
      ) : (
        <form noValidate onSubmit={submit} className="grid grid-cols-[minmax(0,1fr)] gap-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(0,18rem)] @2xl:gap-6">
          <div className="min-w-0">
            <p className="text-[15px] leading-normal font-medium text-balance">{label}</p>
            <p className="mt-1 text-[13px] leading-relaxed text-pretty wrap-break-word text-muted-foreground">{note}</p>
          </div>
          <div className="flex min-w-0 flex-col gap-1">
            <CodeField flow={flow} label={label} labelHidden otpVariant="joined" length={length} hint={hint} autoFocus={autoFocus} />
            <div className="flex min-h-9 flex-wrap items-center justify-between gap-x-2 pointer-coarse:min-h-10">
              <ResendButton flow={flow} className="-ms-3" />
              {flow.checking ? (
                <span role="status" className="inline-flex items-center gap-2 text-[13px] text-muted-foreground">
                  <Loader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" />
                  Checking
                </span>
              ) : null}
            </div>
            <ResendNote flow={flow} sentNote={sentNote} />
          </div>
        </form>
      )}
    </section>
  )
}

export { VerificationInline }
```

### `registry/miseui/blocks/verification-code-parts.tsx`

```tsx
import type * as React from "react"
import { useEffect, useId, useRef } from "react"
import { Check, Loader2 } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { InputOTP, type InputOTPVariant } from "@/registry/miseui/ui/input-otp"
import type { Verification } from "./verification-code-state"

const MARK_START = { opacity: 0, scale: 0.6 }
const MARK_END = { opacity: 1, scale: 1 }

interface VerificationCopy {
  length: number
  /** Under the field, for anything true about the code: how long it lasts, or the example code in a preview. */
  hint?: string
  autoFocus?: boolean
}

interface CodeFieldProps extends VerificationCopy {
  flow: Verification
  label: string
  otpVariant?: InputOTPVariant
  /** For a label placed outside the field, such as in a row. */
  labelHidden?: boolean
}

/** The field and everything said about it. The error is role alert, so a screen reader hears it without leaving the field. */
function CodeField({ flow, label, length, hint, autoFocus, otpVariant = "boxes", labelHidden }: CodeFieldProps) {
  const fieldId = useId()
  const errorId = useId()
  const fieldRef = useRef<HTMLInputElement>(null)
  // The field was disabled during the check and lost focus. After a wrong code it is the next thing to use.
  useEffect(() => {
    if (flow.error) fieldRef.current?.focus()
  }, [flow.error])
  return (
    <div className="flex min-w-0 flex-col gap-2">
      <label htmlFor={fieldId} className={labelHidden ? "sr-only" : "text-[13px] leading-5 font-medium"}>
        {label}
      </label>
      <InputOTP
        ref={fieldRef}
        id={fieldId}
        aria-describedby={flow.error ? errorId : undefined}
        length={length}
        variant={otpVariant}
        value={flow.code}
        error={Boolean(flow.error)}
        disabled={flow.checking}
        autoFocus={autoFocus}
        onValueChange={flow.change}
        onComplete={(value) => void flow.verify(value)}
        className="flex w-full"
      />
      {flow.error ? (
        <p id={errorId} role="alert" className="text-[13px] leading-snug text-pretty text-destructive">
          {flow.error}
        </p>
      ) : null}
      {hint ? <p className="text-[13px] leading-snug text-pretty text-muted-foreground">{hint}</p> : null}
    </div>
  )
}

/** aria-disabled and not disabled: pressing this starts the wait, and a disabled button would drop keyboard focus. */
function ResendButton({ flow, className }: { flow: Verification; className?: string }) {
  const waiting = flow.secondsLeft > 0 || flow.sending
  return (
    <Button
      type="button"
      variant="ghost"
      aria-disabled={waiting || undefined}
      aria-busy={flow.sending || undefined}
      onClick={() => void flow.resend()}
      className={cn("tabular-nums pointer-coarse:h-10", waiting && "text-muted-foreground hover:bg-transparent hover:text-muted-foreground", className)}
    >
      {flow.sending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
      {flow.secondsLeft > 0 ? `Send a new code in ${flow.secondsLeft}s` : "Send a new code"}
    </Button>
  )
}

/** Always mounted, so the live region exists before there is anything to say. The countdown itself stays out of it. */
function ResendNote({ flow, sentNote }: { flow: Verification; sentNote: string }) {
  return (
    <>
      <p role="status" className="text-[13px] leading-5 text-pretty text-muted-foreground empty:sr-only">
        {flow.sent ? sentNote : ""}
      </p>
      {flow.resendError ? (
        <p role="alert" className="text-[13px] leading-snug text-pretty text-destructive">
          {flow.resendError}
        </p>
      ) : null}
    </>
  )
}

interface CodeFormProps extends VerificationCopy {
  flow: Verification
  label: string
  verifyLabel: string
  changeHref: string | null
  changeLabel: string
  sentNote: string
}

function CodeForm({ flow, label, length, hint, autoFocus, verifyLabel, changeHref, changeLabel, sentNote }: CodeFormProps) {
  const submit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    void flow.verify(flow.code)
  }
  return (
    <form noValidate onSubmit={submit} className="flex flex-col gap-4">
      <CodeField flow={flow} label={label} length={length} hint={hint} autoFocus={autoFocus} />
      <Button type="submit" variant="mise" aria-busy={flow.checking || undefined} disabled={flow.checking} className="w-full pointer-coarse:h-10">
        {flow.checking ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
        {flow.checking ? "Checking the code" : verifyLabel}
      </Button>
      <div className="flex flex-wrap items-center justify-between gap-x-2">
        <ResendButton flow={flow} className="-mx-3" />
        {changeHref ? (
          <Button asChild variant="ghost" className="-mx-3 text-muted-foreground pointer-coarse:h-10">
            <a href={changeHref}>{changeLabel}</a>
          </Button>
        ) : null}
      </div>
      <ResendNote flow={flow} sentNote={sentNote} />
    </form>
  )
}

/** Emerald and not the accent: this is a result, and the accent may itself be red. */
function VerifiedMark({ className }: { className?: string }) {
  const reduceMotion = useReducedMotion()
  return (
    <motion.span
      data-testid="verified-mark"
      className={cn("grid size-12 place-items-center rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", className)}
      initial={reduceMotion ? false : MARK_START}
      animate={MARK_END}
      transition={SPRING_DEFAULT}
    >
      <Check aria-hidden="true" className="size-6" />
    </motion.span>
  )
}

interface VerifiedProps {
  title: string
  note: string
  continueLabel: string
  onContinue?: () => void
}

function VerifiedScreen({ title, note, continueLabel, onContinue }: VerifiedProps) {
  const headingRef = useRef<HTMLHeadingElement>(null)
  // The form has just gone, so this takes focus: a screen reader hears the result instead of silence.
  useEffect(() => headingRef.current?.focus(), [])
  return (
    <div className="flex flex-col items-start gap-6">
      <VerifiedMark />
      <h2 ref={headingRef} tabIndex={-1} className="text-display text-3xl leading-[1.12]! text-balance outline-none">
        {title} <span className="text-muted-foreground">{note}</span>
      </h2>
      <Button variant="mise" onClick={onContinue} className="w-full pointer-coarse:h-10">
        {continueLabel}
      </Button>
    </div>
  )
}

export { CodeField, CodeForm, ResendButton, ResendNote, VerifiedMark, VerifiedScreen, type CodeFormProps, type VerifiedProps, type VerificationCopy }
```

### `registry/miseui/blocks/verification-code-split.tsx`

```tsx
import type * as React from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { CodeForm, VerifiedScreen, type CodeFormProps, type VerifiedProps } from "./verification-code-parts"

interface SplitProps extends React.ComponentProps<"section"> {
  title: string
  note: string
  form: CodeFormProps
  verified: VerifiedProps
  /** The name on the email, so it can be picked out of an inbox. */
  sender: string
  subject: string
  /** What can go wrong while waiting. Keep them true for your own mail. */
  tips: readonly string[]
}

/** A sketch of the message, not a real one: the code stays out of it, because nobody should read a code off this page. */
function MailFragment({ sender, subject, length }: { sender: string; subject: string; length: number }) {
  return (
    <div data-testid="mail-fragment" aria-hidden="true" className="rounded-2xl border bg-background p-4 shadow-[0_16px_32px_-16px_rgb(0_0_0/0.25)] dark:bg-card dark:shadow-none">
      <p className="truncate text-[13px] font-medium">{sender}</p>
      <p className="truncate text-[13px] text-muted-foreground">{subject}</p>
      <div className="mt-4 flex gap-1">
        {Array.from({ length }, (_, index) => (
          <span key={index} className="h-8 min-w-0 flex-1 rounded-md bg-muted dark:bg-background" />
        ))}
      </div>
    </div>
  )
}

/** Beside the form, because the person is about to leave for their inbox: they should know what to look for before they go. */
function VerificationSplit({ title, note, form, verified, sender, subject, tips, className, ...props }: SplitProps) {
  return (
    <section data-slot="verification-code" data-variant="split" className={cn("@container flex w-full flex-col bg-background", className)} {...props}>
      <div className="grid min-h-[640px] flex-1 grid-cols-[minmax(0,1fr)] @3xl:grid-cols-2">
        <div className="flex items-center justify-center px-4 py-12 @3xl:px-12">
          <div className="flex w-full max-w-sm flex-col gap-8">
            {form.flow.verified ? (
              <VerifiedScreen {...verified} />
            ) : (
              <>
                <h2 className="text-display text-3xl leading-[1.12]! text-balance wrap-break-word">
                  {title} <span className="text-muted-foreground">{note}</span>
                </h2>
                <CodeForm {...form} />
              </>
            )}
          </div>
        </div>
        <div className="flex items-center justify-center bg-muted px-4 py-12 @3xl:px-12">
          <div className="flex w-full max-w-sm flex-col gap-6">
            <h3 className="text-title text-xl text-balance">
              What to look for. <span className="text-muted-foreground">A message with this name and subject.</span>
            </h3>
            <MailFragment sender={sender} subject={subject} length={form.length} />
            <ul className="flex flex-col gap-2 text-[13px] leading-relaxed text-pretty text-muted-foreground">
              {tips.map((tip) => (
                <li key={tip}>{tip}</li>
              ))}
            </ul>
          </div>
        </div>
      </div>
    </section>
  )
}

export { VerificationSplit }
```

### `registry/miseui/blocks/verification-code-state.ts`

```ts
import { useEffect, useState } from "react"

const WRONG_CODE = "That code did not work. Check it, or send a new one."
const RESEND_FAILED = "Could not send a new code. Check your connection and try again."
const TICK_MS = 1000

/** Reads the clock rather than counting ticks, because a background tab throttles timers and a count would run slow. */
function useCountdown(seconds: number) {
  const [round, setRound] = useState(0)
  const [secondsLeft, setSecondsLeft] = useState(seconds)
  useEffect(() => {
    if (seconds <= 0) return
    const end = Date.now() + seconds * 1000
    const id = window.setInterval(() => {
      const left = Math.max(0, Math.ceil((end - Date.now()) / 1000))
      setSecondsLeft(left)
      if (left === 0) window.clearInterval(id)
    }, TICK_MS)
    return () => window.clearInterval(id)
  }, [round, seconds])
  const restart = () => {
    setSecondsLeft(seconds)
    setRound((current) => current + 1)
  }
  return { secondsLeft, restart }
}

/** The wait before another code may go out, and the sending itself. */
function useResend(onResend: () => void | Promise<void>, seconds: number, onSent: () => void) {
  const { secondsLeft, restart } = useCountdown(seconds)
  const [sending, setSending] = useState(false)
  const [sent, setSent] = useState(false)
  const [resendError, setResendError] = useState<string>()

  const resend = async () => {
    if (secondsLeft > 0 || sending) return
    setSending(true)
    setSent(false)
    setResendError(undefined)
    try {
      await onResend()
      setSent(true)
      restart()
      onSent()
    } catch (problem) {
      setResendError(problem instanceof Error ? problem.message : RESEND_FAILED)
    } finally {
      setSending(false)
    }
  }

  return { secondsLeft, sending, sent, resendError, resend, clearSent: () => setSent(false) }
}

interface VerificationOptions {
  length: number
  onVerify: (code: string) => void | Promise<void>
  onResend: () => void | Promise<void>
  resendAfterSeconds: number
  error?: string
}

function useVerification({ length, onVerify, onResend, resendAfterSeconds, error: outerError }: VerificationOptions) {
  const [code, setCode] = useState("")
  const [checking, setChecking] = useState(false)
  const [verified, setVerified] = useState(false)
  const [localError, setLocalError] = useState<string>()
  const clearCode = () => {
    setCode("")
    setLocalError(undefined)
  }
  const { clearSent, ...resend } = useResend(onResend, resendAfterSeconds, clearCode)

  const verify = async (value: string) => {
    if (checking || verified) return
    if (value.length < length) return setLocalError(`Enter all ${length} digits.`)
    setChecking(true)
    setLocalError(undefined)
    clearSent()
    try {
      await onVerify(value)
      setVerified(true)
    } catch (problem) {
      // Cleared, so the next digit starts a new try instead of landing after the wrong one.
      setCode("")
      setLocalError(problem instanceof Error ? problem.message : WRONG_CODE)
    } finally {
      setChecking(false)
    }
  }

  const change = (value: string) => {
    setCode(value)
    setLocalError(undefined)
  }

  return { code, checking, verified, error: localError ?? outerError, change, verify, ...resend }
}

type Verification = ReturnType<typeof useVerification>

export { useCountdown, useVerification, WRONG_CODE, type Verification }
```
