{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "passkey-button",
  "type": "registry:ui",
  "title": "Passkey Button",
  "description": "Passkey sign-in whose glyph scans while the device prompt is open, with calm fallbacks.",
  "categories": [
    "onboarding"
  ],
  "dependencies": [
    "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"
  ],
  "files": [
    {
      "path": "components/ui/passkey-button.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert } from \"@/lib/icons\";\nimport { ease, spring, swap } from \"@/lib/motion\";\n\nexport type PasskeyState = \"idle\" | \"prompting\" | \"success\" | \"cancelled\" | \"error\";\n\nconst noop = () => () => {};\n/** Whether this browser can do WebAuthn at all. Renders as supported on the server, then checks. */\nexport function usePasskeySupport() {\n  return useSyncExternalStore(\n    noop,\n    () => typeof window !== \"undefined\" && typeof window.PublicKeyCredential !== \"undefined\",\n    () => true,\n  );\n}\n\n// WebAuthn reports a dismissed or timed-out prompt as NotAllowedError. That's the\n// person choosing not to, not a failure, so it gets calm copy and no red.\nconst isCancel = (error: unknown) => error instanceof DOMException && (error.name === \"NotAllowedError\" || error.name === \"AbortError\");\n\nexport type PasskeyButtonProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onError\"> & {\n  /**\n   * Run the ceremony: call navigator.credentials.get with the signal, then verify on\n   * your server. Resolve on success; reject to show the state. The signal aborts if\n   * the button unmounts.\n   */\n  onAuthenticate: (signal: AbortSignal) => Promise<unknown>;\n  onSuccess?: () => void;\n  onError?: (error: unknown) => void;\n  /** Offered when passkeys aren't available, or after a cancel or failure. */\n  onFallback?: () => void;\n  fallbackLabel?: string;\n  label?: string;\n  promptingLabel?: string;\n  successLabel?: string;\n  retryLabel?: string;\n  /** Override detection, e.g. when your server knows this account has no passkey. */\n  supported?: boolean;\n  variant?: \"primary\" | \"secondary\";\n  disabled?: boolean;\n};\n\nexport function PasskeyButton({\n  onAuthenticate,\n  onSuccess,\n  onError,\n  onFallback,\n  fallbackLabel = \"Sign in another way\",\n  label = \"Sign in with passkey\",\n  promptingLabel = \"Waiting for passkey…\",\n  successLabel = \"Signed in\",\n  retryLabel = \"Try passkey again\",\n  supported: supportedProp,\n  variant = \"secondary\",\n  disabled = false,\n  className,\n  ...rest\n}: PasskeyButtonProps) {\n  const reduce = useReducedMotion();\n  const detected = usePasskeySupport();\n  const supported = supportedProp ?? detected;\n  const [state, setState] = useState<PasskeyState>(\"idle\");\n  const [message, setMessage] = useState<string | null>(null);\n  const controller = useRef<AbortController | null>(null);\n  const messageId = useId();\n  useEffect(() => () => controller.current?.abort(), []);\n\n  async function run() {\n    if (state === \"prompting\" || state === \"success\" || !supported || disabled) return;\n    controller.current?.abort();\n    const ac = new AbortController();\n    controller.current = ac;\n    setState(\"prompting\");\n    setMessage(null);\n    try {\n      await onAuthenticate(ac.signal);\n      if (ac.signal.aborted) return;\n      setState(\"success\");\n      onSuccess?.();\n    } catch (error) {\n      if (ac.signal.aborted) return;\n      onError?.(error);\n      if (isCancel(error)) {\n        setState(\"cancelled\");\n        setMessage(\"The passkey request was cancelled or timed out.\");\n      } else {\n        setState(\"error\");\n        setMessage(error instanceof Error && error.message ? error.message : \"Couldn’t sign in with that passkey.\");\n      }\n    }\n  }\n\n  const unavailable = !supported;\n  const text = state === \"prompting\" ? promptingLabel : state === \"success\" ? successLabel : state === \"cancelled\" || state === \"error\" ? retryLabel : label;\n  const note = unavailable ? \"This browser can’t use passkeys.\" : message;\n  const showFallback = !!onFallback && (unavailable || state === \"cancelled\" || state === \"error\");\n\n  return (\n    <div data-state={unavailable ? \"unsupported\" : state} className={cn(\"flex w-full flex-col gap-2\", className)} {...rest}>\n      <button\n        type=\"button\"\n        data-state={unavailable ? \"unsupported\" : state}\n        data-variant={variant}\n        aria-busy={state === \"prompting\" || undefined}\n        aria-disabled={unavailable || disabled || state === \"prompting\" || state === \"success\" || undefined}\n        aria-describedby={note ? messageId : undefined}\n        onClick={run}\n        className={cn(\n          \"group/passkey relative inline-flex h-9 w-full select-none items-center justify-center gap-2 rounded-lg px-3 text-[13px] font-medium tracking-[-0.005em] outline-none pointer-coarse:h-11\",\n          \"transition-[background-color,border-color,color,opacity,scale] duration-150 ease-out active:scale-[0.98] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          variant === \"primary\"\n            ? \"bg-fg text-frame hover:bg-fg/90 data-[state=prompting]:bg-fg/85\"\n            : \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover data-[state=prompting]:bg-hover\",\n          \"data-[state=prompting]:cursor-default data-[state=prompting]:active:scale-100 data-[state=success]:cursor-default data-[state=success]:active:scale-100\",\n          (unavailable || disabled) && \"pointer-events-none opacity-50\",\n        )}\n      >\n        {/* The glyph and label stay centered as a pair: when the label changes length the glyph glides to its new spot. */}\n        <motion.span layout=\"position\" transition={reduce ? { duration: 0 } : spring.snappy} className=\"relative grid size-5 shrink-0 place-items-center\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={state === \"success\" ? \"check\" : \"key\"}\n              className=\"absolute inset-0 grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : swap.initial}\n              animate={swap.animate}\n              exit={reduce ? { opacity: 0 } : swap.exit}\n              transition={reduce ? { duration: 0.12 } : spring.pop}\n            >\n              {state === \"success\" ? <DrawnCheck reduce={!!reduce} /> : <PasskeyGlyph scanning={state === \"prompting\"} reduce={!!reduce} />}\n            </motion.span>\n          </AnimatePresence>\n        </motion.span>\n        <span className=\"relative flex min-w-0\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={text}\n              layout=\"position\"\n              className=\"min-w-0 truncate\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n              transition={reduce ? { duration: 0.12, layout: { duration: 0 } } : { duration: 0.22, ease: ease.out, layout: spring.snappy }}\n            >\n              {text}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </button>\n\n      <AnimatePresence initial={false}>\n        {(note || showFallback) && (\n          <motion.div\n            key=\"note\"\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=\"overflow-hidden\"\n          >\n            <div className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 px-px pb-px pt-0.5\">\n              {note && (\n                <p\n                  id={messageId}\n                  role={state === \"error\" ? \"alert\" : undefined}\n                  className={cn(\"flex min-w-0 items-start gap-1.5 text-[12px] leading-4\", state === \"error\" && !unavailable ? \"text-danger\" : \"text-fg-2\")}\n                >\n                  {state === \"error\" && !unavailable && <Alert size={14} className=\"mt-px shrink-0\" />}\n                  <span className=\"min-w-0 text-pretty\">{note}</span>\n                </p>\n              )}\n              {showFallback && (\n                <button\n                  type=\"button\"\n                  onClick={onFallback}\n                  className={cn(\n                    // Drawn as a text link so it lines up with the note; the hit area extends past the text.\n                    \"relative shrink-0 rounded-sm text-[12.5px] font-medium text-fg underline decoration-fg-4 underline-offset-[3px] outline-none\",\n                    \"transition-[text-decoration-color,scale] duration-150 ease-out hover:decoration-fg-2 active:scale-[0.97]\",\n                    \"before:absolute before:-inset-x-1.5 before:-inset-y-2.5 before:content-['']\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  )}\n                >\n                  {fallbackLabel}\n                </button>\n              )}\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {state === \"prompting\" ? promptingLabel : state === \"success\" ? successLabel : state === \"cancelled\" ? message : \"\"}\n      </span>\n    </div>\n  );\n}\n\n// A person with a key. While the browser's passkey prompt is open, a bright band\n// sweeps up and down through it inside a viewfinder, like the scan happening on the device.\nfunction PasskeyGlyph({ scanning, reduce }: { scanning: boolean; reduce: boolean }) {\n  const clip = useId();\n  const shape = (\n    <>\n      <circle cx=\"8.25\" cy=\"7\" r=\"2.5\" />\n      <path d=\"M3.75 15.25c.45-2.5 2.2-3.9 4.5-3.9 1.2 0 2.25.38 3.05 1.05\" />\n      <circle cx=\"13.75\" cy=\"9.75\" r=\"1.75\" />\n      <path d=\"M13.75 11.5v4.25M13.75 13.75h1.4M13.75 15.25h1\" />\n    </>\n  );\n  return (\n    <svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <g className={cn(\"transition-opacity duration-200\", scanning && (reduce ? \"animate-pulse-soft\" : \"opacity-45\"))}>{shape}</g>\n      <AnimatePresence>\n        {scanning && !reduce && (\n          <motion.g key=\"scan\" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.12 } }}>\n            <defs>\n              <clipPath id={clip}>\n                <motion.rect x=\"0\" width=\"20\" height=\"5\" initial={{ y: 1 }} animate={{ y: [1, 15, 1] }} transition={{ duration: 1.6, ease: ease.inOut, repeat: Infinity }} />\n              </clipPath>\n            </defs>\n            <g clipPath={`url(#${clip})`}>{shape}</g>\n            <motion.path\n              d=\"M2.5 0h15\"\n              strokeWidth={0.9}\n              opacity={0.55}\n              initial={{ y: 3.5 }}\n              animate={{ y: [3.5, 17.5, 3.5] }}\n              transition={{ duration: 1.6, ease: ease.inOut, repeat: Infinity }}\n            />\n            {/* Viewfinder corners close in around the glyph */}\n            <motion.path\n              d=\"M1 5V3a2 2 0 0 1 2-2h2M15 1h2a2 2 0 0 1 2 2v2M19 15v2a2 2 0 0 1-2 2h-2M5 19H3a2 2 0 0 1-2-2v-2\"\n              strokeWidth={1.2}\n              style={{ originX: 0.5, originY: 0.5 }}\n              initial={{ scale: 1.25, opacity: 0 }}\n              animate={{ scale: 1, opacity: 1 }}\n              transition={spring.pop}\n            />\n          </motion.g>\n        )}\n      </AnimatePresence>\n    </svg>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0, opacity: 0 }}\n        animate={{ pathLength: 1, opacity: 1 }}\n        transition={{ duration: 0.34, ease: ease.out, delay: 0.06, opacity: { duration: 0.01, delay: 0.06 } }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/passkey-button"
}
