{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "login-centered",
  "type": "registry:block",
  "title": "Centered login",
  "description": "A passkey-first login card on a quiet ring backdrop that morphs through email and code.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/button.json",
    "https://irisui.dev/r/input.json",
    "https://irisui.dev/r/otp-input.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/login-centered/login-centered.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type FormEvent,\n  type PointerEvent,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionStyle,\n  type Transition,\n  type Variants,\n} from \"motion/react\";\nimport { Check, FingerprintPattern, Mail } from \"lucide-react\";\nimport { Button } from \"../../button/button\";\nimport { Input } from \"../../input/input\";\nimport { OtpInput } from \"../../otp-input/otp-input\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { cn } from \"../../lib/utils\";\n\ntype Step = \"passkey\" | \"email\" | \"code\" | \"done\";\ntype Mode = \"sign-in\" | \"sign-up\";\ntype Provider = \"Google\" | \"Apple\" | \"GitHub\";\ntype PasskeyPhase = \"idle\" | \"waiting\" | \"verified\";\nexport type LoginMethod = \"Passkey\" | \"Email code\" | Provider;\nexport interface LoginCenteredProps {\n  /** The code the simulated email contains. */\n  demoCode?: string;\n  /** Fill the viewport (100dvh, no frame) when the block is the whole page. */\n  fullScreen?: boolean;\n  /** The account a simulated passkey or single sign-on resolves to. */\n  demoEmail?: string;\n  termsHref?: string;\n  privacyHref?: string;\n  /** Called once the simulated sign in or sign up succeeds. */\n  onSignIn?: (email: string, method: LoginMethod, mode: Mode) => void;\n  className?: string;\n}\ntype StepCustom = { direction: number; reduce: boolean };\n\n/* Shared class strings. Container steps follow the `login-centered` container: 520 px and 400 px. */\nconst ringBase = \"fill-none stroke-1 motion-reduce:[transform:none]\";\nconst ringClass = `${ringBase} [transform:translate(calc(var(--dx,0px)*var(--k,1)),calc(var(--dy,0px)*var(--k,1)))]`;\nconst axisClass = `${ringBase} stroke-line-soft [transform:translate(var(--dx,0px),var(--dy,0px))]`;\nconst barClass = \"flex min-w-0 items-center justify-between gap-4 px-6 @max-[520px]/login-centered:px-4\";\nconst stepClass = \"grid min-w-0 p-8 @max-[520px]/login-centered:p-6 @max-[400px]/login-centered:px-5\";\nconst headingClass =\n  \"mb-6 grid gap-2 [&_h2]:m-0 [&_h2]:font-display [&_h2]:text-3xl [&_h2]:leading-display [&_h2]:font-normal [&_h2]:tracking-display [&_h2]:text-balance @max-[400px]/login-centered:[&_h2]:text-2xl [&_p]:m-0 [&_p]:text-sm [&_p]:leading-body [&_p]:text-pretty [&_p]:text-ink-2\";\nconst addressClass = \"text-ink [overflow-wrap:anywhere]\";\nconst textButtonClass =\n  \"relative mx-0 mt-4 -mb-2 inline-flex min-h-control-sm cursor-pointer items-center justify-self-center gap-2 rounded-button px-3 text-sm leading-body font-medium text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[color,background-color] duration-fast ease-standard not-aria-disabled:hover:bg-hover not-aria-disabled:hover:text-ink not-aria-disabled:active:bg-hover not-aria-disabled:active:text-ink aria-disabled:cursor-default aria-disabled:font-normal aria-disabled:text-ink-3 motion-reduce:transition-none\";\n\nconst RESEND_SECONDS = 30;\n/** The official four-colour Google \"G\"; Apple and GitHub ship monochrome marks, drawn in currentColor. */\nfunction GoogleLogo({ className }: { className?: string }) {\n  return (\n    <svg className={className} width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n      <path\n        fill=\"#4285F4\"\n        d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z\"\n      />\n      <path\n        fill=\"#34A853\"\n        d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"\n      />\n      <path\n        fill=\"#FBBC05\"\n        d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"\n      />\n      <path\n        fill=\"#EA4335\"\n        d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"\n      />\n    </svg>\n  );\n}\n\nconst providers: Provider[] = [\"Google\", \"Apple\", \"GitHub\"];\n/** Monochrome provider marks in currentColor, so they stay neutral in both themes. */\nconst providerMarks: Record<Provider, string> = {\n  Google:\n    \"M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z\",\n  Apple:\n    \"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701\",\n  GitHub:\n    \"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\",\n};\n/** Concentric rings behind the card. Outer rings drift further, so the set reads as depth rather than a flat pattern. */\nconst rings = Array.from({ length: 14 }, (_, index) => ({\n  r: 224 + index * 60,\n  k: 1 + index * 0.34,\n  major: index % 3 === 2,\n}));\nconst isEmail = (value: string) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/.test(value.trim());\n\n/** Steps rise into place out of a small blur; going back, they settle down from above. */\nconst stepMotion: Variants = {\n  enter: ({ direction, reduce }: StepCustom) =>\n    reduce ? { opacity: 0 } : { opacity: 0, y: direction * 12, filter: `blur(${motionTokens.blur.soft}px)` },\n  center: ({ reduce }: StepCustom) => ({\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: reduce\n      ? { duration: motionTokens.duration.instant }\n      : {\n          y: motionTokens.spring.smooth,\n          opacity: { duration: motionTokens.duration.standard, ease: motionTokens.ease.enter, delay: 0.06 },\n          filter: { duration: motionTokens.duration.standard, ease: motionTokens.ease.enter, delay: 0.04 },\n        },\n  }),\n  exit: ({ direction, reduce }: StepCustom) =>\n    reduce\n      ? { opacity: 0, transition: { duration: 0 } }\n      : {\n          opacity: 0,\n          y: direction * -8,\n          filter: `blur(${motionTokens.blur.soft}px)`,\n          transition: { duration: motionTokens.duration.exit, ease: motionTokens.ease.standard },\n        },\n};\nconst swap = (reduce: boolean) => ({\n  initial: reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.soft}px)` },\n  animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: reduce\n    ? { opacity: 0, transition: { duration: 0 } }\n    : {\n        opacity: 0,\n        y: -4,\n        filter: `blur(${motionTokens.blur.subtle}px)`,\n        transition: { duration: motionTokens.duration.fast },\n      },\n  transition: (reduce\n    ? { duration: motionTokens.duration.instant }\n    : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }) as Transition,\n});\n/** Both header labels share one cell and cross-fade in place, so the switch never changes width when the mode flips. */\nconst labelState = (active: boolean, reduce: boolean) =>\n  active\n    ? { opacity: 1, y: 0, filter: \"blur(0px)\" }\n    : { opacity: 0, y: reduce ? 0 : -6, filter: `blur(${reduce ? 0 : motionTokens.blur.subtle}px)` };\nconst labelTransition = (reduce: boolean): Transition =>\n  reduce\n    ? { duration: motionTokens.duration.instant }\n    : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\n/** Seconds roll down while the timer runs and back up when a new code restarts it. */\nconst roll: Variants = {\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.7 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${0.7 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\n};\n\nfunction RollingTime({ seconds, reduce }: { seconds: number; reduce: boolean }) {\n  const [shown, setShown] = useState({ seconds, direction: 1 });\n  if (shown.seconds !== seconds) setShown({ seconds, direction: seconds < shown.seconds ? 1 : -1 });\n  const text = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, \"0\")}`;\n  const transition: Transition = reduce\n    ? { duration: 0 }\n    : {\n        y: motionTokens.spring.snappy,\n        opacity: { duration: motionTokens.duration.fast },\n        filter: { duration: motionTokens.duration.fast },\n      };\n  return (\n    <span className=\"inline-flex tabular-nums\">\n      {text.split(\"\").map((character, index) => (\n        <span key={index} className=\"relative inline-flex justify-center *:inline-block\">\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={shown.direction}>\n            <motion.span\n              key={character}\n              custom={shown.direction}\n              variants={roll}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={transition}\n            >\n              {character}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n\n/** Two rings leave the fingerprint while the device is asked, then stop on their own. */\nfunction PasskeyPulse({ reduce }: { reduce: boolean }) {\n  return (\n    <span className=\"relative inline-grid place-items-center\">\n      {!reduce &&\n        [0, 1].map((index) => (\n          <motion.span\n            key={index}\n            className=\"pointer-events-none absolute -inset-0.5 rounded-full border-[1.5px] border-current\"\n            initial={{ scale: 0.7, opacity: 0 }}\n            animate={{ scale: [0.7, 2.1], opacity: [0.6, 0] }}\n            transition={{ duration: 1.2, ease: [...motionTokens.ease.standard], delay: index * 0.6 }}\n          />\n        ))}\n      <FingerprintPattern size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n    </span>\n  );\n}\n\n/** The card follows its content on a spring only while the view changes; otherwise it stays auto, so field messages open without lag. */\nfunction useViewHeight(view: string, reduce: boolean) {\n  const track = useRef<HTMLDivElement>(null);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const measured = useRef(0);\n  const gliding = useRef(false);\n  const lastView = useRef(view);\n  const glide = useCallback(\n    (to: number) => {\n      gliding.current = true;\n      animate(height, to, {\n        ...motionTokens.spring.smooth,\n        onComplete: () => {\n          gliding.current = false;\n          height.jump(\"auto\");\n        },\n      });\n    },\n    [height],\n  );\n  useEffect(() => {\n    const node = track.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      measured.current = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n      if (gliding.current) glide(measured.current);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [glide]);\n  useLayoutEffect(() => {\n    if (lastView.current === view) return;\n    lastView.current = view;\n    const current = height.get();\n    const from = typeof current === \"number\" ? current : measured.current;\n    const to = track.current?.offsetHeight ?? 0;\n    if (reduce || !from || !to) {\n      gliding.current = false;\n      height.jump(\"auto\");\n      return;\n    }\n    if (current === \"auto\") height.jump(from);\n    glide(to);\n  }, [view, reduce, height, glide]);\n  return { track, height };\n}\n\n/** Pointer drift for the line backdrop: fine pointers with hover only, never under reduced motion. */\nfunction useDrift(reduce: boolean) {\n  const pointerX = useMotionValue(0);\n  const pointerY = useMotionValue(0);\n  const x = useSpring(pointerX, { visualDuration: 0.9, bounce: 0 });\n  const y = useSpring(pointerY, { visualDuration: 0.9, bounce: 0 });\n  const dx = useTransform(x, (value) => `${value.toFixed(2)}px`);\n  const dy = useTransform(y, (value) => `${value.toFixed(2)}px`);\n  const canHover = useRef(false);\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const query = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const sync = () => {\n      canHover.current = query.matches;\n    };\n    sync();\n    query.addEventListener(\"change\", sync);\n    return () => query.removeEventListener(\"change\", sync);\n  }, []);\n  const onPointerMove = (event: PointerEvent<HTMLElement>) => {\n    if (reduce || !canHover.current || event.pointerType !== \"mouse\") return;\n    const box = event.currentTarget.getBoundingClientRect();\n    pointerX.set(((event.clientX - box.left) / box.width - 0.5) * -2);\n    pointerY.set(((event.clientY - box.top) / box.height - 0.5) * -2);\n  };\n  const onPointerLeave = () => {\n    pointerX.set(0);\n    pointerY.set(0);\n  };\n  return { style: { \"--dx\": dx, \"--dy\": dy } as MotionStyle, onPointerMove, onPointerLeave };\n}\n\nexport function LoginCentered({\n  demoCode = \"482913\",\n  fullScreen = false,\n  demoEmail = \"katerina@studio.example\",\n  termsHref = \"#terms\",\n  privacyHref = \"#privacy\",\n  onSignIn,\n  className,\n}: LoginCenteredProps) {\n  const reduce = !!useReducedMotion();\n  const [step, setStep] = useState<Step>(\"passkey\");\n  const [mode, setMode] = useState<Mode>(\"sign-in\");\n  const [direction, setDirection] = useState(1);\n  const [passkey, setPasskey] = useState<PasskeyPhase>(\"idle\");\n  const [busy, setBusy] = useState<null | \"email\" | \"code\" | Provider>(null);\n  const [verified, setVerified] = useState(false);\n  const [email, setEmail] = useState(\"\");\n  const [touched, setTouched] = useState(false);\n  const [attempted, setAttempted] = useState(false);\n  const [code, setCode] = useState(\"\");\n  const [codeError, setCodeError] = useState(\"\");\n  const [resendIn, setResendIn] = useState(RESEND_SECONDS);\n  const [account, setAccount] = useState(demoEmail);\n  const [method, setMethod] = useState<LoginMethod>(\"Passkey\");\n  const [status, setStatus] = useState(\"\");\n  const passkeyRef = useRef<HTMLButtonElement>(null);\n  const emailRef = useRef<HTMLInputElement>(null);\n  const codeRef = useRef<HTMLDivElement>(null);\n  const doneRef = useRef<HTMLHeadingElement>(null);\n  const focusNext = useRef<Step | null>(null);\n  const timers = useRef<number[]>([]);\n  const view = step === \"email\" ? `email-${mode}` : step;\n  const { track, height } = useViewHeight(view, reduce);\n  const drift = useDrift(reduce);\n\n  const emailError =\n    (touched || attempted) && !isEmail(email)\n      ? email.trim()\n        ? \"Enter a full address, like name@example.com.\"\n        : \"Enter your email address.\"\n      : \"\";\n  const custom: StepCustom = { direction, reduce };\n  const signingUp = mode === \"sign-up\";\n\n  useEffect(() => {\n    const pending = timers;\n    return () => pending.current.forEach(window.clearTimeout);\n  }, []);\n  useEffect(() => {\n    if (step !== \"code\" || resendIn <= 0) return;\n    const timer = window.setTimeout(() => setResendIn((seconds) => Math.max(0, seconds - 1)), 1000);\n    return () => window.clearTimeout(timer);\n  }, [step, resendIn]);\n  useEffect(() => {\n    const target = focusNext.current;\n    focusNext.current = null;\n    if (target === \"passkey\") passkeyRef.current?.focus();\n    if (target === \"email\") emailRef.current?.focus();\n    if (target === \"done\") doneRef.current?.focus();\n    if (view === \"code\") codeRef.current?.querySelector(\"input\")?.focus();\n  }, [view]);\n\n  function later(run: () => void, ms: number) {\n    timers.current.push(window.setTimeout(run, ms));\n  }\n  /** Any new direction cancels whatever the previous one was still waiting on. */\n  function settle() {\n    timers.current.forEach(window.clearTimeout);\n    timers.current = [];\n    setBusy(null);\n    setVerified(false);\n  }\n  function go(next: Step, towards: number, focus: Step | null = next === \"code\" ? null : next) {\n    focusNext.current = focus;\n    setDirection(towards);\n    if (next === \"passkey\") setPasskey(\"idle\");\n    setStep(next);\n  }\n  const focusCode = () => codeRef.current?.querySelector(\"input\")?.focus();\n\n  function complete(address: string, how: LoginMethod) {\n    settle();\n    setAccount(address);\n    setMethod(how);\n    go(\"done\", 1);\n    setStatus(\n      `${signingUp ? \"Account created\" : \"Signed in\"} with ${how === \"Email code\" ? \"an email code\" : how === \"Passkey\" ? \"a passkey\" : how}`,\n    );\n    onSignIn?.(address, how, mode);\n  }\n\n  function startPasskey() {\n    if (passkey !== \"idle\" || busy) return;\n    setPasskey(\"waiting\");\n    setStatus(\"Waiting for your passkey\");\n    later(() => {\n      setPasskey(\"verified\");\n      setStatus(\"Passkey verified\");\n      later(() => complete(demoEmail, \"Passkey\"), 640);\n    }, 1700);\n  }\n\n  function cancelPasskey() {\n    settle();\n    setPasskey(\"idle\");\n    setStatus(\"Passkey request canceled\");\n    passkeyRef.current?.focus();\n  }\n\n  function switchToEmail() {\n    if (passkey === \"verified\" || busy) return;\n    settle();\n    setPasskey(\"idle\");\n    setAttempted(false);\n    go(\"email\", 1);\n    setStatus(\"We will email you a sign in code\");\n  }\n\n  function signInWith(provider: Provider) {\n    if (busy || passkey !== \"idle\") return;\n    setBusy(provider);\n    setStatus(`Opening ${provider}`);\n    later(() => complete(demoEmail, provider), 900);\n  }\n\n  function submitEmail(event: FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    if (busy) return;\n    setAttempted(true);\n    if (!isEmail(email)) {\n      emailRef.current?.focus();\n      return;\n    }\n    setBusy(\"email\");\n    setStatus(\"Sending a code\");\n    later(() => {\n      const address = email.trim().toLowerCase();\n      setBusy(null);\n      setAccount(address);\n      setCode(\"\");\n      setCodeError(\"\");\n      setResendIn(RESEND_SECONDS);\n      go(\"code\", 1);\n      setStatus(`Code sent to ${address}`);\n    }, 800);\n  }\n\n  function verify(value: string) {\n    if (busy || verified) return;\n    if (value.length < 6) {\n      setCodeError(\"Enter all 6 digits.\");\n      focusCode();\n      return;\n    }\n    setBusy(\"code\");\n    setStatus(\"Checking code\");\n    later(() => {\n      setBusy(null);\n      if (value !== demoCode) {\n        setCode(\"\");\n        setCodeError(\"That code didn't match. Check the latest email and try again.\");\n        setStatus(\"Code didn't match\");\n        focusCode();\n        return;\n      }\n      setVerified(true);\n      setStatus(\"Code verified\");\n      later(() => complete(account, \"Email code\"), 600);\n    }, 700);\n  }\n\n  function changeCode(value: string) {\n    if (busy || verified) return;\n    setCode(value);\n    if (codeError && value) setCodeError(\"\");\n    if (value.length === 6) verify(value);\n  }\n\n  function resend() {\n    if (busy || verified || resendIn > 0) return;\n    setResendIn(RESEND_SECONDS);\n    setCode(\"\");\n    setCodeError(\"\");\n    setStatus(\"New code sent\");\n    focusCode();\n  }\n\n  function changeEmail() {\n    settle();\n    setAttempted(false);\n    go(\"email\", -1);\n    setStatus(\"Edit your email\");\n  }\n\n  function switchToPasskey() {\n    settle();\n    setMode(\"sign-in\");\n    go(\"passkey\", -1);\n    setStatus(\"Sign in with your passkey\");\n  }\n\n  function switchMode() {\n    settle();\n    setAttempted(false);\n    setCode(\"\");\n    if (signingUp) {\n      setMode(\"sign-in\");\n      go(\"passkey\", -1);\n      setStatus(\"Sign in to your account\");\n      return;\n    }\n    setMode(\"sign-up\");\n    go(\"email\", 1);\n    setStatus(\"Create an account with your email\");\n  }\n\n  function signOut() {\n    settle();\n    setEmail(\"\");\n    setTouched(false);\n    setAttempted(false);\n    setCode(\"\");\n    setMode(\"sign-in\");\n    go(\"passkey\", -1);\n    setStatus(\"Signed out\");\n  }\n\n  function openLegal(href: string, label: string) {\n    return (event: { preventDefault: () => void }) => {\n      if (!href.startsWith(\"#\")) return;\n      event.preventDefault();\n      setStatus(`${label} opens here in your app (demo)`);\n    };\n  }\n\n  const passkeyLabel =\n    passkey === \"waiting\" ? (\n      <>\n        <PasskeyPulse reduce={reduce} />\n        Waiting for your device\n      </>\n    ) : passkey === \"verified\" ? (\n      <>\n        <Check size={16} strokeWidth={2} aria-hidden=\"true\" />\n        Passkey verified\n      </>\n    ) : (\n      <>\n        <FingerprintPattern size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n        Sign in with passkey\n      </>\n    );\n\n  return (\n    <section\n      className={cn(\n        \"relative isolate grid w-full min-w-0 grid-rows-[var(--bar)_minmax(0,1fr)_var(--bar)] self-stretch justify-self-stretch overflow-hidden bg-page font-body tracking-body text-ink [--bar:64px] @container/login-centered\",\n        fullScreen ? \"min-h-dvh rounded-[0] border-0\" : \"min-h-[640px] rounded-panel border border-line\",\n        className,\n      )}\n      aria-label={signingUp ? \"Create an account\" : \"Sign in\"}\n      onPointerMove={drift.onPointerMove}\n      onPointerLeave={drift.onPointerLeave}\n    >\n      <motion.div\n        className=\"pointer-events-none absolute inset-0 -z-1 [mask-image:radial-gradient(ellipse_72%_78%_at_50%_50%,#000_38%,transparent_100%)]\"\n        style={drift.style}\n        aria-hidden=\"true\"\n      >\n        <svg className=\"absolute inset-0 size-full overflow-visible\">\n          <svg x=\"50%\" y=\"50%\" overflow=\"visible\">\n            <line className={axisClass} x1=\"-2400\" x2=\"2400\" y1=\"0\" y2=\"0\" />\n            <line className={axisClass} x1=\"0\" x2=\"0\" y1=\"-2400\" y2=\"2400\" />\n            {rings.map((ring) => (\n              <circle\n                key={ring.r}\n                className={cn(ringClass, ring.major ? \"stroke-line\" : \"stroke-line-soft\")}\n                r={ring.r}\n                cx=\"0\"\n                cy=\"0\"\n                style={{ \"--k\": ring.k } as CSSProperties}\n              />\n            ))}\n          </svg>\n        </svg>\n      </motion.div>\n\n      <header className={barClass}>\n        <span className=\"inline-flex items-center gap-2 text-base/none font-medium [&_svg]:text-accent\">Polaris</span>\n        <p className=\"m-0 inline-flex min-w-0 items-center gap-1 text-sm leading-body whitespace-nowrap text-ink-2\">\n          <span className=\"relative inline-grid justify-items-end *:[grid-area:1/1]\">\n            {([\"sign-in\", \"sign-up\"] as const).map((option) => (\n              <motion.span\n                key={option}\n                aria-hidden={option !== mode || undefined}\n                initial={false}\n                animate={labelState(option === mode, reduce)}\n                transition={labelTransition(reduce)}\n              >\n                {option === \"sign-up\" ? \"Have an account?\" : \"No account?\"}\n              </motion.span>\n            ))}\n          </span>\n          <button\n            type=\"button\"\n            className=\"relative inline-grid min-h-control-sm cursor-pointer items-center justify-items-center rounded-button px-3 font-medium text-ink [-webkit-tap-highlight-color:transparent] transition-[background-color] duration-fast ease-standard *:[grid-area:1/1] hover:bg-hover active:bg-hover motion-reduce:transition-none @max-[400px]/login-centered:px-2\"\n            aria-label={signingUp ? \"Sign in\" : \"Sign up\"}\n            onClick={switchMode}\n          >\n            {([\"sign-in\", \"sign-up\"] as const).map((option) => (\n              <motion.span\n                key={option}\n                aria-hidden=\"true\"\n                initial={false}\n                animate={labelState(option === mode, reduce)}\n                transition={labelTransition(reduce)}\n              >\n                {option === \"sign-up\" ? \"Sign in\" : \"Sign up\"}\n              </motion.span>\n            ))}\n          </button>\n        </p>\n      </header>\n\n      <div className=\"grid min-w-0 place-items-center px-4 py-6 @max-[520px]/login-centered:p-4 @max-[400px]/login-centered:px-3\">\n        <div className=\"w-[min(100%,420px)] min-w-0 overflow-hidden rounded-surface border border-line bg-surface shadow-resting @max-[520px]/login-centered:rounded-panel\">\n          <motion.div className=\"overflow-hidden\" style={{ height }}>\n            <div ref={track} className=\"relative\">\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={custom}>\n                {step === \"passkey\" && (\n                  <motion.div\n                    key=\"passkey\"\n                    className={stepClass}\n                    custom={custom}\n                    variants={stepMotion}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"exit\"\n                  >\n                    <div className={headingClass}>\n                      <h2>Sign in to Polaris</h2>\n                      <p>Use the passkey saved on this device to open Launch desk. No password needed.</p>\n                    </div>\n                    <div className=\"grid gap-3\">\n                      <Button\n                        ref={passkeyRef}\n                        type=\"button\"\n                        className=\"w-full\"\n                        aria-busy={passkey === \"waiting\" || undefined}\n                        aria-disabled={passkey !== \"idle\" || !!busy || undefined}\n                        onClick={startPasskey}\n                      >\n                        {passkeyLabel}\n                      </Button>\n                      <Button\n                        type=\"button\"\n                        variant=\"secondary\"\n                        className=\"w-full\"\n                        aria-disabled={passkey === \"verified\" || !!busy || undefined}\n                        onClick={passkey === \"waiting\" ? cancelPasskey : switchToEmail}\n                      >\n                        {passkey === \"waiting\" ? (\n                          \"Cancel\"\n                        ) : (\n                          <>\n                            <Mail size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                            Use email instead\n                          </>\n                        )}\n                      </Button>\n                    </div>\n                    <div className=\"mx-0 my-5 flex items-center gap-3 text-xs/none text-ink-3 before:h-px before:flex-1 before:bg-line before:content-[''] after:h-px after:flex-1 after:bg-line after:content-['']\">\n                      or\n                    </div>\n                    <fieldset className=\"grid min-w-0 grid-cols-3 gap-2\" aria-label=\"Single sign-on\">\n                      {providers.map((provider) => (\n                        <Button\n                          key={provider}\n                          type=\"button\"\n                          variant=\"secondary\"\n                          className=\"min-w-0 px-2 @max-[400px]/login-centered:gap-1.5 @max-[400px]/login-centered:px-1 @max-[400px]/login-centered:text-xs\"\n                          aria-label={`Continue with ${provider}`}\n                          aria-disabled={passkey !== \"idle\" || (!!busy && busy !== provider) || undefined}\n                          loading={busy === provider}\n                          onClick={() => signInWith(provider)}\n                        >\n                          {provider === \"Google\" ? (\n                            <GoogleLogo />\n                          ) : (\n                            <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n                              <path d={providerMarks[provider]} />\n                            </svg>\n                          )}\n                          {provider}\n                        </Button>\n                      ))}\n                    </fieldset>\n                  </motion.div>\n                )}\n\n                {step === \"email\" && (\n                  <motion.div\n                    key={`email-${mode}`}\n                    className={stepClass}\n                    custom={custom}\n                    variants={stepMotion}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"exit\"\n                  >\n                    <div className={headingClass}>\n                      {signingUp ? (\n                        <>\n                          <h2>Create your account</h2>\n                          <p>Enter your work email. We will send a code to confirm it.</p>\n                        </>\n                      ) : (\n                        <>\n                          <h2>Sign in with email</h2>\n                          <p>We will send a 6 digit code to your inbox.</p>\n                        </>\n                      )}\n                    </div>\n                    <form className=\"grid gap-4\" onSubmit={submitEmail} noValidate>\n                      <Input\n                        ref={emailRef}\n                        label={signingUp ? \"Work email\" : \"Email\"}\n                        type=\"email\"\n                        name=\"email\"\n                        inputMode=\"email\"\n                        autoComplete=\"email\"\n                        autoCapitalize=\"none\"\n                        spellCheck={false}\n                        placeholder=\"name@example.com\"\n                        value={email}\n                        readOnly={busy === \"email\"}\n                        error={emailError}\n                        onChange={(event) => setEmail(event.target.value)}\n                        onBlur={() => {\n                          if (email.trim()) setTouched(true);\n                        }}\n                      />\n                      <Button type=\"submit\" className=\"w-full\" loading={busy === \"email\"}>\n                        {signingUp ? \"Create account\" : \"Send code\"}\n                      </Button>\n                    </form>\n                    {!signingUp && (\n                      <button type=\"button\" className={textButtonClass} onClick={switchToPasskey}>\n                        <FingerprintPattern size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                        Use a passkey instead\n                      </button>\n                    )}\n                  </motion.div>\n                )}\n\n                {step === \"code\" && (\n                  <motion.div\n                    key=\"code\"\n                    className={stepClass}\n                    custom={custom}\n                    variants={stepMotion}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"exit\"\n                  >\n                    <div className={headingClass}>\n                      <h2>Check your email</h2>\n                      <p>\n                        Enter the code sent to <span className={addressClass}>{account}</span>.{\" \"}\n                        <button\n                          type=\"button\"\n                          className=\"cursor-pointer rounded-[4px] p-0 font-medium text-ink underline decoration-line-strong underline-offset-3 transition-[text-decoration-color] duration-fast ease-standard hover:decoration-current motion-reduce:transition-none\"\n                          onClick={changeEmail}\n                        >\n                          Change\n                        </button>\n                      </p>\n                    </div>\n                    <form\n                      className=\"grid gap-4\"\n                      onSubmit={(event) => {\n                        event.preventDefault();\n                        verify(code);\n                      }}\n                      noValidate\n                    >\n                      <div ref={codeRef}>\n                        <OtpInput\n                          label=\"Verification code\"\n                          description={`Demo code: ${demoCode}`}\n                          value={code}\n                          onChange={changeCode}\n                          error={codeError}\n                        />\n                      </div>\n                      <Button\n                        type=\"submit\"\n                        className=\"w-full\"\n                        loading={busy === \"code\"}\n                        aria-disabled={verified || undefined}\n                      >\n                        {verified ? (\n                          <>\n                            <Check size={16} strokeWidth={2} aria-hidden=\"true\" />\n                            Verified\n                          </>\n                        ) : signingUp ? (\n                          \"Verify and create account\"\n                        ) : (\n                          \"Verify\"\n                        )}\n                      </Button>\n                    </form>\n                    <button\n                      type=\"button\"\n                      className={textButtonClass}\n                      aria-disabled={resendIn > 0 || verified || undefined}\n                      aria-label={resendIn > 0 ? `Resend code, available in ${resendIn} seconds` : \"Resend code\"}\n                      onClick={resend}\n                    >\n                      <AnimatePresence mode=\"popLayout\" initial={false}>\n                        <motion.span\n                          key={resendIn > 0 ? \"wait\" : \"ready\"}\n                          className=\"inline-flex items-baseline gap-[.3em] whitespace-nowrap\"\n                          {...swap(reduce)}\n                        >\n                          {resendIn > 0 ? (\n                            <>\n                              Resend code in <RollingTime seconds={resendIn} reduce={reduce} />\n                            </>\n                          ) : (\n                            \"Resend code\"\n                          )}\n                        </motion.span>\n                      </AnimatePresence>\n                    </button>\n                  </motion.div>\n                )}\n\n                {step === \"done\" && (\n                  <motion.div\n                    key=\"done\"\n                    className={stepClass}\n                    custom={custom}\n                    variants={stepMotion}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"exit\"\n                  >\n                    <svg\n                      className=\"mb-6 block size-12 overflow-visible text-green\"\n                      viewBox=\"0 0 48 48\"\n                      aria-hidden=\"true\"\n                    >\n                      <g transform=\"rotate(-90 24 24)\">\n                        <motion.circle\n                          cx=\"24\"\n                          cy=\"24\"\n                          r=\"22.25\"\n                          fill=\"none\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.5\"\n                          initial={reduce ? false : { pathLength: 0, opacity: 0 }}\n                          animate={{ pathLength: 1, opacity: 1 }}\n                          transition={{\n                            pathLength: {\n                              duration: motionTokens.duration.considered,\n                              ease: [...motionTokens.ease.inOut],\n                              delay: 0.16,\n                            },\n                            opacity: { duration: motionTokens.duration.instant, delay: 0.16 },\n                          }}\n                        />\n                      </g>\n                      <motion.path\n                        d=\"M15.5 24.5l5.5 5.5 11.5-12\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"2\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        initial={reduce ? false : { pathLength: 0, opacity: 0 }}\n                        animate={{ pathLength: 1, opacity: 1 }}\n                        transition={{\n                          pathLength: {\n                            duration: motionTokens.duration.standard + 0.08,\n                            ease: [...motionTokens.ease.enter],\n                            delay: 0.52,\n                          },\n                          opacity: { duration: motionTokens.duration.instant, delay: 0.52 },\n                        }}\n                      />\n                    </svg>\n                    <div className={headingClass}>\n                      <h2 ref={doneRef} tabIndex={-1}>\n                        {signingUp ? \"Welcome to Polaris\" : \"Welcome back\"}\n                      </h2>\n                      <p>\n                        {signingUp ? \"Your account is ready. \" : \"You are signed in to Launch desk as \"}\n                        <span className={addressClass}>{account}</span>\n                        {signingUp ? \"\" : \".\"}\n                      </p>\n                    </div>\n                    <dl className=\"mx-0 -mt-2 mb-6 grid border-t border-line p-0 [&_dd]:m-0 [&_dd]:text-right [&_div]:flex [&_div]:justify-between [&_div]:gap-4 [&_div]:border-b [&_div]:border-line [&_div]:py-3 [&_div]:text-sm [&_div]:leading-body [&_dt]:text-ink-2\">\n                      <div>\n                        <dt>Method</dt>\n                        <dd>{method}</dd>\n                      </div>\n                      <div>\n                        <dt>This device</dt>\n                        <dd>Remembered for 30 days</dd>\n                      </div>\n                    </dl>\n                    <Button type=\"button\" variant=\"secondary\" className=\"w-full\" onClick={signOut}>\n                      Sign out\n                    </Button>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        </div>\n      </div>\n\n      <footer className={cn(barClass, \"text-xs leading-body text-ink-3\")}>\n        <output className=\"sr-only\" aria-live=\"polite\">\n          {status}\n        </output>\n        <span\n          className=\"relative block min-w-0 flex-1 *:block *:overflow-hidden *:text-ellipsis *:whitespace-nowrap\"\n          aria-hidden=\"true\"\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span key={status} {...swap(reduce)}>\n              {status}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <nav\n          className=\"flex flex-none gap-1 [&_a]:inline-flex [&_a]:min-h-8 [&_a]:items-center [&_a]:rounded-button [&_a]:px-2 [&_a]:text-ink-2 [&_a]:no-underline [&_a]:transition-[color,background-color] [&_a]:duration-fast [&_a]:ease-standard [&_a]:hover:bg-hover [&_a]:hover:text-ink [&_a]:active:text-ink motion-reduce:[&_a]:transition-none\"\n          aria-label=\"Legal\"\n        >\n          <a href={termsHref} onClick={openLegal(termsHref, \"Terms\")}>\n            Terms\n          </a>\n          <a href={privacyHref} onClick={openLegal(privacyHref, \"Privacy policy\")}>\n            Privacy\n          </a>\n        </nav>\n      </footer>\n    </section>\n  );\n}\n\nexport default LoginCentered;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/login-centered/login-centered.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/login-centered",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "LoginCentered",
    "docs": "https://irisui.dev/components/login-centered"
  }
}
