{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "two-factor-setup",
  "type": "registry:ui",
  "title": "Two-factor Setup",
  "description": "Scan, verify and save recovery codes in one card that glides between steps.",
  "categories": [
    "onboarding"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/two-factor-setup.tsx",
      "content": "\"use client\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport { Field } from \"@base-ui/react/field\";\nimport { OTPField } from \"@base-ui/react/otp-field\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, ArrowLeft, ChevronDown, Download } from \"@/lib/icons\";\nimport { ease, spring, stagger } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type TwoFactorStep = \"scan\" | \"verify\" | \"recovery\" | \"done\";\nconst order: TwoFactorStep[] = [\"scan\", \"verify\", \"recovery\", \"done\"];\n\n/** \"JBSWY3DPEHPK3PXP\" → \"JBSW Y3DP EHPK 3PXP\", so a key typed by hand can be checked in chunks. */\nexport const formatSecret = (secret: string) => secret.replace(/\\s+/g, \"\").replace(/(.{4})/g, \"$1 \").trim();\n\nexport type TwoFactorSetupProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The base32 secret, for people who can't scan. */\n  secret: string;\n  /** Shown on the scan step and in the downloaded file, e.g. \"Northwind\". */\n  issuer?: string;\n  /** The account the authenticator will label, e.g. the email. */\n  account?: string;\n  /** Your real QR code (an <img> or <svg> of the otpauth URL). Without it a placeholder is drawn from the secret. */\n  qrCode?: React.ReactNode;\n  /** Check the code. Resolve the recovery codes (or nothing, to use `recoveryCodes`); throw with a message to show it. */\n  onVerify: (code: string) => Promise<string[] | void> | string[] | void;\n  recoveryCodes?: string[];\n  onDone?: () => void;\n  step?: TwoFactorStep;\n  defaultStep?: TwoFactorStep;\n  onStepChange?: (step: TwoFactorStep) => void;\n  codeLength?: number;\n};\n\nexport function TwoFactorSetup({\n  secret,\n  issuer = \"your account\",\n  account,\n  qrCode,\n  onVerify,\n  recoveryCodes: codesProp = [],\n  onDone,\n  step: stepProp,\n  defaultStep = \"scan\",\n  onStepChange,\n  codeLength = 6,\n  className,\n  ...rest\n}: TwoFactorSetupProps) {\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const [step, setStep] = useControllableState<TwoFactorStep>({ value: stepProp, defaultValue: defaultStep, onChange: onStepChange });\n  const [code, setCode] = useState(\"\");\n  const [busy, setBusy] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [rejected, setRejected] = useState(false);\n  const [codes, setCodes] = useState<string[]>(codesProp);\n  const [saved, setSaved] = useState(false);\n  const [nudge, setNudge] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n\n  const [shownStep, setShownStep] = useState(step);\n  const [dir, setDir] = useState(1);\n  if (shownStep !== step) {\n    setShownStep(step);\n    setDir(order.indexOf(step) >= order.indexOf(shownStep) ? 1 : -1);\n  }\n\n  const ticket = useRef(0);\n  const clearTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(clearTimer.current), []);\n\n  // Card height glides between steps; at rest it's auto so errors grow it freely.\n  const card = useRef<HTMLDivElement>(null);\n  const scanRef = useRef<HTMLElement>(null);\n  const verifyRef = useRef<HTMLElement>(null);\n  const recoveryRef = useRef<HTMLElement>(null);\n  const doneRef = useRef<HTMLElement>(null);\n  const stepEl = (s: TwoFactorStep) => ({ scan: scanRef, verify: verifyRef, recovery: recoveryRef, done: doneRef })[s].current;\n  const settled = useRef(0);\n  const moving = useRef<AnimationPlaybackControls | null>(null);\n  const lastStep = useRef(step);\n  useEffect(() => {\n    const el = card.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => {\n      if (!moving.current) settled.current = el.offsetHeight;\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  useLayoutEffect(() => {\n    if (lastStep.current === step) return;\n    lastStep.current = step;\n    const el = card.current;\n    const target = stepEl(step);\n    const header = el?.querySelector<HTMLElement>(\"[data-progress]\");\n    if (!el || !target || reduce) return;\n    const cs = getComputedStyle(el);\n    const chrome = [\"paddingTop\", \"paddingBottom\", \"borderTopWidth\", \"borderBottomWidth\", \"rowGap\"].reduce((n, k) => n + (parseFloat(cs[k as \"paddingTop\"]) || 0), 0);\n    const from = moving.current ? el.offsetHeight : settled.current;\n    const to = chrome + (header?.offsetHeight ?? 0) + target.offsetHeight;\n    moving.current?.stop();\n    if (!from || Math.abs(from - to) < 1) return;\n    const controls = animate(el, { height: [from, to] }, { duration: 0.36, ease: ease.inOut });\n    moving.current = controls;\n    controls.then(() => {\n      if (moving.current !== controls) return;\n      moving.current = null;\n      el.style.height = \"\";\n      settled.current = el.offsetHeight;\n    });\n  }, [step, reduce]);\n\n  const focusedStep = useRef(step);\n  useEffect(() => {\n    if (focusedStep.current === step) return;\n    focusedStep.current = step;\n    stepEl(step)?.querySelector<HTMLElement>(\"[data-autofocus]\")?.focus({ preventScroll: true });\n  }, [step]);\n\n  const go = (next: TwoFactorStep) => {\n    setError(null);\n    setRejected(false);\n    setStep(next);\n  };\n\n  async function verify(value: string) {\n    if (busy) return;\n    if (value.length < codeLength) return setError(`Enter all ${codeLength} digits`);\n    const mine = ++ticket.current;\n    setBusy(true);\n    setError(null);\n    try {\n      const result = await onVerify(value);\n      if (mine !== ticket.current) return;\n      setBusy(false);\n      if (Array.isArray(result)) setCodes(result);\n      go(\"recovery\");\n      setAnnounce(\"Code accepted. Save your recovery codes.\");\n    } catch (err) {\n      if (mine !== ticket.current) return;\n      setBusy(false);\n      setError(err instanceof Error && err.message ? err.message : \"That code didn’t match. Codes change every 30 seconds; try the current one.\");\n      setRejected(true);\n      // Leave the wrong code up long enough to read, then clear it for the next one.\n      clearTimer.current = window.setTimeout(() => {\n        if (mine !== ticket.current) return;\n        setCode(\"\");\n        setRejected(false);\n        verifyRef.current?.querySelector<HTMLElement>(\"[data-autofocus]\")?.focus();\n      }, 900);\n    }\n  }\n\n  function download() {\n    const lines = [\n      `${issuer} recovery codes${account ? ` for ${account}` : \"\"}`,\n      `Saved ${new Date().toLocaleString()}`,\n      \"\",\n      ...codes,\n      \"\",\n      \"Each code works once. Keep this file somewhere only you can reach.\",\n    ];\n    const url = URL.createObjectURL(new Blob([lines.join(\"\\n\")], { type: \"text/plain\" }));\n    const a = document.createElement(\"a\");\n    a.href = url;\n    a.download = `${issuer.toLowerCase().replace(/[^a-z0-9]+/g, \"-\")}-recovery-codes.txt`;\n    a.click();\n    window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n    markSaved();\n  }\n  // Copying or downloading counts as saving: the box ticks itself, and can still be unticked.\n  const markSaved = () => {\n    setSaved(true);\n    setNudge(false);\n  };\n\n  const index = Math.min(order.indexOf(step), 2);\n  const secretId = `${uid}-secret`;\n\n  return (\n    <div\n      ref={card}\n      data-step={step}\n      className={cn(\n        \"relative flex w-full max-w-[380px] flex-col gap-5 overflow-hidden rounded-xl border border-line bg-raised p-5 text-left shadow-[var(--shadow)] sm:p-6\",\n        className,\n      )}\n      {...rest}\n    >\n      <Progress index={index} done={step === \"done\"} reduce={!!reduce} />\n\n      <div className=\"relative\">\n        <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n          {step === \"scan\" && (\n            <Step key=\"scan\" direction={dir} reduce={!!reduce} ref={scanRef}>\n              <Header title=\"Scan the QR code\" description={`Open your authenticator app and add ${issuer}${account ? ` for ${account}` : \"\"}.`} />\n              <div className=\"flex flex-col items-center gap-3\">\n                <div className=\"grid size-[168px] place-items-center rounded-lg border border-line-2 bg-raised p-3 text-fg shadow-[var(--shadow)] dark:bg-fg dark:text-frame\">\n                  {qrCode ?? <QrPlaceholder seed={secret} reduce={!!reduce} />}\n                </div>\n              </div>\n              <Collapsible.Root className=\"flex flex-col\">\n                <Collapsible.Trigger className=\"group/key -mx-1.5 inline-flex h-7 w-fit items-center gap-1 rounded-md px-1.5 text-[12.5px] text-fg-2 outline-none transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\">\n                  Can’t scan it? Enter a key instead\n                  <ChevronDown size={14} className=\"transition-transform duration-200 ease-out group-data-[panel-open]/key:rotate-180 motion-reduce:transition-none\" />\n                </Collapsible.Trigger>\n                <Collapsible.Panel className=\"h-(--collapsible-panel-height) overflow-hidden transition-[height,opacity] duration-220 ease-out-expo data-ending-style:h-0 data-ending-style:opacity-0 data-starting-style:h-0 data-starting-style:opacity-0 motion-reduce:transition-none\">\n                  <div className=\"pt-2\">\n                    <div className=\"flex items-center gap-2 rounded-lg border border-line bg-frame py-1 pl-3 pr-1\">\n                      <code id={secretId} className=\"min-w-0 flex-1 break-all font-mono text-[12.5px] tracking-[0.04em] text-fg\">\n                        {formatSecret(secret)}\n                      </code>\n                      <CopyButton value={secret.replace(/\\s+/g, \"\")} size=\"sm\" variant=\"ghost\" iconOnly label=\"Copy setup key\" aria-describedby={secretId} />\n                    </div>\n                    <p className=\"pt-1.5 text-[12px] text-fg-3\">Time-based, 6 digits. Spaces don’t matter.</p>\n                  </div>\n                </Collapsible.Panel>\n              </Collapsible.Root>\n              <PrimaryButton onClick={() => go(\"verify\")}>Continue</PrimaryButton>\n            </Step>\n          )}\n\n          {step === \"verify\" && (\n            <Step key=\"verify\" direction={dir} reduce={!!reduce} ref={verifyRef}>\n              <Header title=\"Enter the code\" description={`Type the ${codeLength}-digit code your authenticator shows for ${issuer}.`} />\n              <form\n                noValidate\n                onSubmit={(e) => {\n                  e.preventDefault();\n                  verify(code);\n                }}\n                aria-busy={busy || undefined}\n                className=\"flex flex-col gap-4\"\n              >\n                <Field.Root invalid={!!error} className=\"flex flex-col gap-1.5\">\n                  <Field.Label className=\"sr-only\">Authenticator code</Field.Label>\n                  <CodeCells\n                    id={`${uid}-code`}\n                    length={codeLength}\n                    value={code}\n                    onChange={(v) => {\n                      setCode(v);\n                      if (error && !rejected) setError(null);\n                    }}\n                    onComplete={verify}\n                    busy={busy}\n                    rejected={rejected}\n                    reduce={!!reduce}\n                  />\n                  <Message message={error ?? undefined} reduce={!!reduce} />\n                </Field.Root>\n                <PrimaryButton type=\"submit\" busy={busy}>\n                  Verify\n                </PrimaryButton>\n              </form>\n              <div className=\"flex min-h-7 items-center\">\n                <TextButton\n                  onClick={() => {\n                    ticket.current++;\n                    setBusy(false);\n                    setCode(\"\");\n                    go(\"scan\");\n                  }}\n                  className=\"group/back\"\n                >\n                  <ArrowLeft size={14} className=\"transition-transform duration-200 ease-out group-hover/back:-translate-x-0.5\" />\n                  Back to the QR code\n                </TextButton>\n              </div>\n            </Step>\n          )}\n\n          {step === \"recovery\" && (\n            <Step key=\"recovery\" direction={dir} reduce={!!reduce} ref={recoveryRef}>\n              <Header\n                title=\"Save your recovery codes\"\n                description=\"If you lose your phone, each of these gets you in once. Store them somewhere only you can reach.\"\n                autoFocus\n              />\n              <div className=\"flex flex-col gap-2\">\n                <ol aria-label=\"Recovery codes\" className=\"grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border border-line bg-frame px-4 py-3\">\n                  {codes.map((c, i) => (\n                    <motion.li\n                      key={c}\n                      className=\"flex items-baseline gap-2 font-mono text-[12.5px] tracking-[0.02em] text-fg tabular\"\n                      initial={reduce ? false : { opacity: 0, y: 4 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      transition={{ duration: 0.24, ease: ease.out, delay: 0.12 + Math.min(i, 8) * stagger.items * 1.5 }}\n                    >\n                      <span aria-hidden className=\"w-3 text-right text-[10.5px] text-fg-4\">\n                        {i + 1}\n                      </span>\n                      {c}\n                    </motion.li>\n                  ))}\n                </ol>\n                <div className=\"grid grid-cols-2 gap-2\">\n                  <CopyButton value={codes.join(\"\\n\")} label=\"Copy all\" copiedLabel=\"Copied\" className=\"w-full\" onCopied={markSaved} />\n                  <DownloadButton onDownload={download} />\n                </div>\n              </div>\n              <label className=\"flex cursor-default items-start gap-2.5 text-[12.5px] leading-[18px] text-fg-2\">\n                <Checkbox.Root\n                  checked={saved}\n                  onCheckedChange={(c) => {\n                    setSaved(c);\n                    if (c) setNudge(false);\n                  }}\n                  className={cn(\n                    \"relative mt-px grid size-4 shrink-0 place-items-center rounded-[5px] border border-line-2 bg-raised outline-none\",\n                    \"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-90\",\n                    \"data-checked:border-fg data-checked:bg-fg data-checked:text-frame\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                    \"before:absolute before:-inset-3 before:content-[''] pointer-fine:before:hidden\",\n                    nudge && \"border-warning\",\n                  )}\n                >\n                  <Checkbox.Indicator keepMounted className=\"grid place-items-center\">\n                    <svg width=\"12\" height=\"12\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n                      <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={false} animate={{ pathLength: saved ? 1 : 0, opacity: saved ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.22, ease: ease.out }} />\n                    </svg>\n                  </Checkbox.Indicator>\n                </Checkbox.Root>\n                I’ve saved these codes somewhere safe\n              </label>\n              <Collapse open={nudge} reduce={!!reduce} className=\"-mt-2\">\n                <p role=\"alert\" className=\"flex items-start gap-1.5 text-[12px] leading-4 text-warning\">\n                  <Alert size={14} className=\"mt-px shrink-0\" />\n                  Copy or download the codes first. You won’t see them again.\n                </p>\n              </Collapse>\n              <PrimaryButton\n                onClick={() => {\n                  if (!saved) return setNudge(true);\n                  go(\"done\");\n                  setAnnounce(\"Two-factor authentication is on.\");\n                }}\n              >\n                Finish setup\n              </PrimaryButton>\n            </Step>\n          )}\n\n          {step === \"done\" && (\n            <Step key=\"done\" direction={dir} reduce={!!reduce} ref={doneRef} className=\"items-center gap-4 py-2 text-center\">\n              <DoneMark reduce={!!reduce} />\n              <div className=\"flex flex-col items-center gap-1\">\n                <h2 data-autofocus tabIndex={-1} className=\"text-[15px] font-medium tracking-[-0.015em] text-fg outline-none\">\n                  Two-factor authentication is on\n                </h2>\n                <p className=\"text-pretty text-[12.5px] leading-[18px] text-fg-2\">\n                  Next time you sign in on a new device, {issuer} will ask for a code from your authenticator.\n                </p>\n              </div>\n              {onDone && (\n                <PrimaryButton onClick={onDone} className=\"mt-1\">\n                  Done\n                </PrimaryButton>\n              )}\n            </Step>\n          )}\n        </AnimatePresence>\n      </div>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\n// Three segments; the current one fills from the left. Done fills them all.\nfunction Progress({ index, done, reduce }: { index: number; done: boolean; reduce: boolean }) {\n  const labels = [\"Scan\", \"Verify\", \"Save codes\"];\n  return (\n    <div data-progress className=\"flex flex-col gap-2\">\n      <div className=\"flex items-center justify-between font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">\n        <span className=\"tabular\">\n          {done ? (\n            \"Complete\"\n          ) : (\n            <>\n              Step <NumberFlow value={index + 1} animated={!reduce} /> of 3\n            </>\n          )}\n        </span>\n        <span className=\"text-fg-4\">{done ? \"\" : labels[index]}</span>\n      </div>\n      <div className=\"grid grid-cols-3 gap-1\" role=\"progressbar\" aria-label=\"Setup progress\" aria-valuemin={0} aria-valuemax={3} aria-valuenow={done ? 3 : index} aria-valuetext={done ? \"Complete\" : `Step ${index + 1} of 3, ${labels[index]}`}>\n        {labels.map((l, i) => (\n          <span key={l} className=\"h-1 overflow-hidden rounded-full bg-line-2\">\n            <motion.span\n              className=\"block h-full origin-left rounded-full bg-fg\"\n              initial={false}\n              animate={{ scaleX: done || i <= index ? 1 : 0 }}\n              transition={reduce ? { duration: 0 } : { duration: 0.4, ease: ease.inOut, delay: i === index ? 0.1 : 0 }}\n            />\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n\ntype StepProps = { direction: number; reduce: boolean; className?: string; children: React.ReactNode; ref?: React.Ref<HTMLElement> };\n\nfunction Step({ direction, reduce, className, children, ref }: StepProps) {\n  const present = useIsPresent();\n  const variants = {\n    enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: 20 * d, filter: \"blur(2px)\" }),\n    center: { opacity: 1, x: 0, filter: \"blur(0px)\" },\n    exit: (d: number) =>\n      reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, x: -12 * d, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } },\n  };\n  return (\n    <motion.section\n      ref={ref}\n      custom={direction}\n      variants={variants}\n      initial=\"enter\"\n      animate=\"center\"\n      exit=\"exit\"\n      inert={!present}\n      transition={reduce ? { duration: 0.15 } : { duration: 0.32, ease: ease.out, delay: 0.07, filter: { duration: 0.2, delay: 0.07 } }}\n      className={cn(\"flex w-full flex-col gap-5\", className)}\n    >\n      {children}\n    </motion.section>\n  );\n}\n\nfunction Header({ title, description, autoFocus }: { title: string; description: string; autoFocus?: boolean }) {\n  return (\n    <div className=\"flex flex-col gap-1\">\n      <h2 data-autofocus={autoFocus ? \"\" : undefined} tabIndex={autoFocus ? -1 : undefined} className=\"text-balance text-[15px] font-medium leading-5 tracking-[-0.015em] text-fg outline-none\">\n        {title}\n      </h2>\n      <p className=\"text-pretty text-[12.5px] leading-[18px] text-fg-2\">{description}</p>\n    </div>\n  );\n}\n\n// A stand-in QR drawn from the secret: finder squares in three corners and a\n// stable scatter of modules, so it looks right without encoding anything.\nfunction QrPlaceholder({ seed, reduce }: { seed: string; reduce: boolean }) {\n  const clip = useId();\n  const n = 25;\n  const d = useMemo(() => {\n    let h = 2166136261;\n    for (const ch of seed) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);\n    const rand = () => {\n      h ^= h << 13;\n      h ^= h >>> 17;\n      h ^= h << 5;\n      return ((h >>> 0) % 1000) / 1000;\n    };\n    const inFinder = (x: number, y: number) => (x < 8 && y < 8) || (x >= n - 8 && y < 8) || (x < 8 && y >= n - 8);\n    let path = \"\";\n    for (let y = 0; y < n; y++)\n      for (let x = 0; x < n; x++) {\n        if (inFinder(x, y)) continue;\n        const timing = (x === 6 || y === 6) && (x + y) % 2 === 0;\n        if (timing || rand() < 0.46) path += `M${x} ${y}h1v1h-1z`;\n      }\n    for (const [fx, fy] of [[0, 0], [n - 7, 0], [0, n - 7]])\n      path += `M${fx} ${fy}h7v7h-7zM${fx + 1} ${fy + 1}v5h5v-5zM${fx + 2} ${fy + 2}h3v3h-3z`;\n    return path;\n  }, [seed]);\n  return (\n    <svg viewBox={`0 0 ${n} ${n}`} className=\"size-full\" aria-label=\"QR code for your authenticator app\" role=\"img\" shapeRendering=\"crispEdges\">\n      <defs>\n        <clipPath id={clip}>\n          {/* Draws in top to bottom once, like it's being printed for you. Same first frame on server and client. */}\n          <motion.rect x=\"0\" y=\"0\" width={n} initial={{ height: 0 }} animate={{ height: n }} transition={reduce ? { duration: 0 } : { duration: 0.7, ease: ease.out, delay: 0.1 }} />\n        </clipPath>\n      </defs>\n      <path d={d} fill=\"currentColor\" fillRule=\"evenodd\" clipPath={`url(#${clip})`} />\n    </svg>\n  );\n}\n\nfunction CodeCells({\n  id,\n  length,\n  value,\n  onChange,\n  onComplete,\n  busy,\n  rejected,\n  reduce,\n}: {\n  id: string;\n  length: number;\n  value: string;\n  onChange: (v: string) => void;\n  onComplete: (v: string) => void;\n  busy: boolean;\n  rejected: boolean;\n  reduce: boolean;\n}) {\n  const [active, setActive] = useState<number | null>(null);\n  const group = length % 3 === 0 && length > 3 ? 3 : 0;\n  return (\n    <OTPField.Root\n      id={id}\n      length={length}\n      value={value}\n      onValueChange={(v) => onChange(v)}\n      onValueComplete={(v) => onComplete(v)}\n      readOnly={busy || rejected}\n      className=\"flex w-full items-center gap-1.5 sm:gap-2\"\n    >\n      {Array.from({ length }, (_, i) => {\n        const char = value[i] ?? \"\";\n        return (\n          <Fragment key={i}>\n            {group > 0 && i > 0 && i % group === 0 && <OTPField.Separator className=\"h-px w-2 shrink-0 rounded-full bg-fg-4\" />}\n            <div className=\"relative h-11 min-w-0 flex-1\">\n              <OTPField.Input\n                data-autofocus={i === 0 ? \"\" : undefined}\n                aria-label={i === 0 ? undefined : `Digit ${i + 1} of ${length}`}\n                onFocus={() => setActive(i)}\n                onBlur={() => setActive((a) => (a === i ? null : a))}\n                className={cn(\n                  \"block size-full rounded-lg border bg-raised text-center text-transparent caret-transparent outline-none selection:bg-transparent! selection:text-transparent!\",\n                  \"transition-[border-color,background-color,box-shadow] duration-150 ease-out read-only:cursor-default\",\n                  rejected\n                    ? \"border-danger/60 bg-danger-soft\"\n                    : cn(\"hover:border-fg-4 focus:border-fg-3 focus:ring-3 focus:ring-fg/8\", char ? \"border-fg-4\" : \"border-line-2\"),\n                )}\n              />\n              <span aria-hidden className={cn(\"pointer-events-none absolute inset-0 grid place-items-center text-[17px] font-medium tabular\", rejected ? \"text-danger\" : \"text-fg\")}>\n                <AnimatePresence initial={false}>\n                  {char && (\n                    <motion.span\n                      key={char + i}\n                      className=\"col-start-1 row-start-1\"\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.85, filter: \"blur(2px)\" }}\n                      animate={busy && !reduce ? { opacity: [1, 0.35, 1], y: 0, scale: 1, filter: \"blur(0px)\" } : { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n                      exit={reduce ? { opacity: 0, transition: { duration: 0.08 } } : { opacity: 0, scale: 0.7, filter: \"blur(2px)\", transition: { duration: 0.1, ease: ease.in } }}\n                      transition={\n                        busy && !reduce\n                          ? { opacity: { duration: 1, repeat: Infinity, ease: \"easeInOut\", delay: i * 0.08 } }\n                          : reduce\n                            ? { duration: 0.12 }\n                            : spring.pop\n                      }\n                    >\n                      {char}\n                    </motion.span>\n                  )}\n                </AnimatePresence>\n                {active === i && !char && !busy && <span className=\"col-start-1 row-start-1 h-5 w-px animate-caret bg-fg\" />}\n              </span>\n            </div>\n          </Fragment>\n        );\n      })}\n    </OTPField.Root>\n  );\n}\n\nfunction Collapse({ open, reduce, className, children }: { open: boolean; reduce: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <AnimatePresence initial={false}>\n      {open && (\n        <motion.div\n          initial={{ height: 0, opacity: 0 }}\n          animate={{ height: \"auto\", opacity: 1 }}\n          exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0.1 : 0.16, ease: ease.in } }}\n          transition={reduce ? { duration: 0.12, height: { duration: 0 } } : { duration: 0.22, ease: ease.out }}\n          className={cn(\"overflow-hidden\", className)}\n        >\n          {children}\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction Message({ message, reduce }: { message?: string; reduce: boolean }) {\n  return (\n    <Collapse open={!!message} reduce={reduce}>\n      <Field.Error match className=\"flex items-start gap-1.5 pt-0.5 text-[12px] leading-4 text-danger\">\n        <Alert size={14} className=\"mt-px shrink-0\" />\n        <span className=\"min-w-0 text-pretty\">{message}</span>\n      </Field.Error>\n    </Collapse>\n  );\n}\n\nfunction PrimaryButton({ busy = false, className, children, type = \"button\", onClick, ...rest }: React.ComponentProps<\"button\"> & { busy?: boolean }) {\n  return (\n    <button\n      type={type}\n      aria-disabled={busy || undefined}\n      data-busy={busy ? \"\" : undefined}\n      onClick={(e) => (busy ? e.preventDefault() : onClick?.(e))}\n      className={cn(\n        \"relative grid h-9 w-full select-none place-items-center rounded-lg bg-fg px-3 text-[13px] font-medium tracking-[-0.005em] text-frame outline-none pointer-coarse:h-11\",\n        \"transition-[background-color,scale] duration-150 ease-out hover:bg-fg/90 active:scale-[0.985] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"data-busy:cursor-default data-busy:bg-fg/85 data-busy:active:scale-100\",\n        className,\n      )}\n      {...rest}\n    >\n      <span className={cn(\"col-start-1 row-start-1 transition-[opacity,scale,filter] duration-150 ease-out\", busy && \"scale-95 opacity-0 blur-[2px] motion-reduce:scale-100 motion-reduce:blur-none\")}>{children}</span>\n      <span aria-hidden className={cn(\"col-start-1 row-start-1 grid place-items-center transition-opacity duration-150\", busy ? \"opacity-100\" : \"opacity-0\")}>\n        {busy && (\n          <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" className=\"animate-spin [animation-duration:0.7s]\">\n            <circle cx=\"8\" cy=\"8\" r=\"5.75\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.5\" />\n            <path d=\"M13.75 8A5.75 5.75 0 0 0 8 2.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n          </svg>\n        )}\n      </span>\n      {busy && <span className=\"sr-only\">Checking…</span>}\n    </button>\n  );\n}\n\nfunction TextButton({ className, children, ...rest }: React.ComponentProps<\"button\">) {\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        \"-mx-1.5 inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 text-[12.5px] text-fg-2 outline-none\",\n        \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97]\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </button>\n  );\n}\n\n// Matches the copy button beside it: the arrow drops into the tray, then a tick.\nfunction DownloadButton({ onDownload }: { onDownload: () => void }) {\n  const reduce = useReducedMotion();\n  const [done, setDone] = useState(false);\n  const timer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n  return (\n    <button\n      type=\"button\"\n      onClick={() => {\n        onDownload();\n        setDone(true);\n        window.clearTimeout(timer.current);\n        timer.current = window.setTimeout(() => setDone(false), 2000);\n      }}\n      className={cn(\n        \"group/dl inline-flex h-8 w-full select-none items-center justify-center gap-2 rounded-lg border border-line-2 bg-raised px-2.5 text-[12.5px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n        \"transition-[background-color,border-color,scale] duration-150 ease-out hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n      )}\n    >\n      <span className=\"relative grid size-4 place-items-center\">\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={done ? \"done\" : \"idle\"}\n            className=\"absolute inset-0 grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            {done ? (\n              <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n                <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.32, ease: ease.out, delay: 0.04 }} />\n              </svg>\n            ) : (\n              <Download className=\"transition-transform duration-200 ease-out group-hover/dl:translate-y-px\" />\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <span className=\"grid\">\n        {[\"Download\", \"Downloaded\"].map((l) => (\n          <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1\">\n            {l}\n          </span>\n        ))}\n        <span className=\"col-start-1 row-start-1 text-left\">{done ? \"Downloaded\" : \"Download\"}</span>\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {done ? \"Recovery codes downloaded\" : \"\"}\n      </span>\n    </button>\n  );\n}\n\nfunction DoneMark({ reduce }: { reduce: boolean }) {\n  return (\n    <motion.span\n      aria-hidden\n      className=\"grid size-11 place-items-center rounded-full bg-success-soft text-success\"\n      initial={reduce ? false : { scale: 0.8, opacity: 0 }}\n      animate={{ scale: 1, opacity: 1 }}\n      transition={spring.bouncy}\n    >\n      <svg width=\"22\" height=\"22\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        {/* A shield with a tick: the account is protected now. */}\n        <path d=\"M8 1.9 3 3.75v3.9c0 3.1 2.1 5.4 5 6.45 2.9-1.05 5-3.35 5-6.45v-3.9z\" opacity={0.5} />\n        <motion.path\n          d=\"m5.6 8.1 1.7 1.7 3.2-3.5\"\n          initial={reduce ? false : { pathLength: 0, opacity: 0 }}\n          animate={{ pathLength: 1, opacity: 1 }}\n          transition={{ duration: 0.4, ease: ease.out, delay: 0.14, opacity: { duration: 0.01, delay: 0.14 } }}\n        />\n      </svg>\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/two-factor-setup"
}
