{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "magic-link",
  "type": "registry:ui",
  "title": "Magic Link",
  "description": "Check-your-email screen with inbox shortcuts, a backing-off resend and a folding envelope.",
  "categories": [
    "onboarding"
  ],
  "dependencies": [
    "@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"
  ],
  "files": [
    {
      "path": "components/ui/magic-link.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, ArrowLeft, ArrowUpRight } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type MailApp = \"gmail\" | \"outlook\" | \"outlook-work\" | \"yahoo\" | \"icloud\" | \"proton\";\n\nconst mailApps: Record<MailApp, { name: string; href: (sender?: string) => string }> = {\n  // With a sender, Gmail opens straight onto a search for the email instead of the inbox.\n  gmail: {\n    name: \"Gmail\",\n    href: (sender) =>\n      sender\n        ? `https://mail.google.com/mail/u/0/#search/${encodeURIComponent(`from:${sender} in:anywhere newer_than:1h`)}`\n        : \"https://mail.google.com/mail/u/0/#inbox\",\n  },\n  outlook: { name: \"Outlook\", href: () => \"https://outlook.live.com/mail/0/\" },\n  \"outlook-work\": { name: \"Outlook\", href: () => \"https://outlook.office.com/mail/\" },\n  yahoo: { name: \"Yahoo Mail\", href: () => \"https://mail.yahoo.com/\" },\n  icloud: { name: \"iCloud Mail\", href: () => \"https://www.icloud.com/mail/\" },\n  proton: { name: \"Proton Mail\", href: () => \"https://mail.proton.me/u/0/inbox\" },\n};\n\n/** Guess the inbox from the address. Company domains usually live on one of the two big suites, so offer both. */\nexport function mailAppsFor(email: string): MailApp[] {\n  const domain = email.split(\"@\")[1]?.toLowerCase() ?? \"\";\n  if (/^(gmail|googlemail)\\.com$/.test(domain)) return [\"gmail\"];\n  if (/^(outlook|hotmail|live|msn)\\.[a-z.]+$/.test(domain)) return [\"outlook\"];\n  if (/^(yahoo|ymail)\\.[a-z.]+$/.test(domain)) return [\"yahoo\"];\n  if (/^(icloud|me|mac)\\.com$/.test(domain)) return [\"icloud\"];\n  if (/^(proton\\.me|protonmail\\.com|pm\\.me)$/.test(domain)) return [\"proton\"];\n  return [\"gmail\", \"outlook-work\"];\n}\n\n/**\n * A resend cooldown measured against the clock, so a background tab still lands\n * on the right second. Pass a list to back off: 30s, then 60s, then 120s.\n */\nexport function useResendCooldown(cooldown: number | number[] = 30, startOnMount = true) {\n  const steps = Array.isArray(cooldown) ? cooldown : [cooldown];\n  const [count, setCount] = useState(0);\n  const [clock, setClock] = useState({ until: 0, now: 0 });\n  const started = useRef(false);\n\n  const start = () => {\n    const now = Date.now();\n    const seconds = steps[Math.min(count, steps.length - 1)] ?? 30;\n    setCount((c) => c + 1);\n    setClock({ until: now + seconds * 1000, now });\n  };\n\n  // The first cooldown starts when the screen appears: an email was just sent.\n  const startRef = useRef(start);\n  useEffect(() => {\n    startRef.current = start;\n  });\n  useEffect(() => {\n    if (!startOnMount || started.current) return;\n    const id = window.setTimeout(() => {\n      started.current = true;\n      startRef.current();\n    }, 0);\n    return () => window.clearTimeout(id);\n  }, [startOnMount]);\n\n  useEffect(() => {\n    if (!clock.until) return;\n    const id = window.setInterval(() => {\n      const now = Date.now();\n      setClock((c) => ({ ...c, now }));\n      if (now >= clock.until) window.clearInterval(id);\n    }, 250);\n    return () => window.clearInterval(id);\n  }, [clock.until]);\n\n  // Before the first tick (and on the server) show the full first wait, so the\n  // label never flashes \"Resend\" before the countdown has started.\n  const remaining = clock.until ? Math.max(0, Math.ceil((clock.until - clock.now) / 1000)) : startOnMount ? (steps[0] ?? 0) : 0;\n  return { remaining, ready: remaining === 0 && (clock.until > 0 || !startOnMount), start };\n}\n\nexport type MagicLinkProps = Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> & {\n  /** Where the link went. */\n  email: string;\n  /** The address the email comes from. Lets the Gmail shortcut open straight onto it. */\n  sender?: string;\n  /** Minutes until the link stops working. */\n  expiresIn?: number;\n  /** Send another link. Throw with a message to show it in place. */\n  onResend?: () => Promise<void> | void;\n  /** Seconds before another link can be sent; a list backs off with each send. */\n  cooldown?: number | number[];\n  onChangeEmail?: () => void;\n  /** Inbox shortcuts. Guessed from the address by default; an empty list hides them. */\n  apps?: MailApp[];\n  /** The link was opened (usually in another tab). The envelope opens and the copy changes. */\n  verified?: boolean;\n  title?: React.ReactNode;\n  verifiedTitle?: React.ReactNode;\n  verifiedDescription?: React.ReactNode;\n};\n\nexport function MagicLink({\n  email,\n  sender,\n  expiresIn = 15,\n  onResend,\n  cooldown = [30, 60, 120],\n  onChangeEmail,\n  apps,\n  verified = false,\n  title = \"Check your email\",\n  verifiedTitle = \"You’re signed in\",\n  verifiedDescription = \"Carry on in this tab. You can close the other one.\",\n  className,\n  ...rest\n}: MagicLinkProps) {\n  const reduce = useReducedMotion();\n  const { remaining, ready, start } = useResendCooldown(cooldown);\n  const [state, setState] = useState<\"idle\" | \"sending\" | \"sent\">(\"idle\");\n  const [error, setError] = useState<string | null>(null);\n  // Bumping this replays the letter going into the envelope.\n  const [sends, setSends] = useState(0);\n  const [announce, setAnnounce] = useState(\"\");\n  const timer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n  const shortcuts = apps ?? mailAppsFor(email);\n\n  async function resend() {\n    if (state !== \"idle\" || !ready || verified) return;\n    setState(\"sending\");\n    setError(null);\n    try {\n      await onResend?.();\n      setState(\"sent\");\n      setSends((n) => n + 1);\n      setAnnounce(`We sent another link to ${email}`);\n      start();\n      timer.current = window.setTimeout(() => setState(\"idle\"), 1800);\n    } catch (err) {\n      setState(\"idle\");\n      setError(err instanceof Error && err.message ? err.message : \"Couldn’t send another link. Try again in a moment.\");\n    }\n  }\n\n  const label = state === \"sending\" ? \"sending\" : state === \"sent\" ? \"sent\" : remaining > 0 ? \"wait\" : \"ready\";\n\n  // When the link is opened the card sheds its actions; its height glides down\n  // instead of snapping. At rest it is auto, so hints and errors grow it freely.\n  const card = useRef<HTMLDivElement>(null);\n  const settled = useRef(0);\n  const moving = useRef<AnimationPlaybackControls | null>(null);\n  const lastVerified = useRef(verified);\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 (lastVerified.current === verified) return;\n    lastVerified.current = verified;\n    const el = card.current;\n    if (!el || reduce) return;\n    const from = moving.current ? el.offsetHeight : settled.current;\n    moving.current?.stop();\n    el.style.height = \"\";\n    const to = el.offsetHeight;\n    if (!from || Math.abs(from - to) < 1) return;\n    el.dataset.moving = \"\";\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      delete el.dataset.moving;\n      settled.current = el.offsetHeight;\n    });\n  }, [verified, reduce]);\n\n  return (\n    <div\n      ref={card}\n      data-state={verified ? \"verified\" : \"waiting\"}\n      className={cn(\n        \"flex w-full max-w-[360px] flex-col items-center gap-5 rounded-xl border border-line bg-raised px-5 pb-5 pt-4 text-center shadow-[var(--shadow)] data-moving:overflow-hidden sm:px-6 sm:pb-6\",\n        className,\n      )}\n      {...rest}\n    >\n      <Envelope key={sends} open={verified} reduce={!!reduce} />\n\n      <div className=\"grid w-full\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.div\n            key={verified ? \"verified\" : \"waiting\"}\n            className=\"flex min-w-0 flex-col items-center gap-1.5\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n            transition={{ duration: reduce ? 0.15 : 0.3, ease: ease.out, delay: reduce ? 0 : 0.05 }}\n          >\n            <h2 className=\"text-balance text-[15px] font-medium leading-5 tracking-[-0.015em] text-fg\">{verified ? verifiedTitle : title}</h2>\n            {verified ? (\n              <p className=\"text-pretty text-[12.5px] leading-[18px] text-fg-2\">{verifiedDescription}</p>\n            ) : (\n              <>\n                <p className=\"text-pretty text-[12.5px] leading-[18px] text-fg-2\">We sent a sign-in link to</p>\n                <span className=\"inline-flex h-7 min-w-0 max-w-full items-center rounded-full border border-line bg-frame px-3\" title={email}>\n                  <span className=\"min-w-0 truncate text-[12.5px] font-medium text-fg\">{email}</span>\n                </span>\n                {expiresIn > 0 && (\n                  <p className=\"pt-0.5 text-[12px] text-fg-3\">\n                    The link works for {expiresIn} {expiresIn === 1 ? \"minute\" : \"minutes\"}.\n                  </p>\n                )}\n              </>\n            )}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n\n      {!verified && shortcuts.length > 0 && (\n        // Two inboxes sit side by side, and stack when the card is too narrow for both names.\n        <div className=\"@container/apps w-full\">\n          <div className={cn(\"grid w-full gap-2\", shortcuts.length > 1 && \"@[18rem]/apps:grid-cols-2\")}>\n          {shortcuts.map((app) => (\n            <a\n              key={app}\n              href={mailApps[app].href(sender)}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className={cn(\n                \"group/app inline-flex h-9 min-w-0 select-none items-center justify-center gap-1.5 rounded-lg border border-line-2 bg-raised px-3 text-[13px] font-medium text-fg shadow-[var(--shadow)] outline-none pointer-coarse:h-11\",\n                \"transition-[background-color,border-color,scale] duration-150 ease-out hover:border-fg-4 hover:bg-hover 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              )}\n            >\n              <span className=\"min-w-0 truncate\">Open {mailApps[app].name}</span>\n              <ArrowUpRight\n                size={14}\n                className=\"shrink-0 text-fg-3 transition-[translate,color] duration-200 ease-out group-hover/app:translate-x-px group-hover/app:-translate-y-px group-hover/app:text-fg\"\n              />\n              <span className=\"sr-only\">(opens in a new tab)</span>\n            </a>\n          ))}\n          </div>\n        </div>\n      )}\n\n      {!verified && (\n        <div className=\"flex w-full flex-col gap-2\">\n          <AnimatePresence initial={false}>\n            {(error || (ready && state === \"idle\")) && (\n              <motion.div\n                key={error ? \"error\" : \"hint\"}\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                {error ? (\n                  <p role=\"alert\" className=\"flex items-start justify-center gap-1.5 text-[12px] leading-4 text-danger\">\n                    <Alert size={14} className=\"mt-px shrink-0\" />\n                    <span className=\"text-pretty\">{error}</span>\n                  </p>\n                ) : (\n                  <p className=\"text-[12px] leading-4 text-fg-3\">Not in your inbox? Check spam before sending another.</p>\n                )}\n              </motion.div>\n            )}\n          </AnimatePresence>\n          <div className=\"flex min-h-7 w-full items-center justify-between gap-3\">\n            {onChangeEmail ? (\n              <TextButton onClick={onChangeEmail} className=\"group/back\">\n                <ArrowLeft size={14} className=\"shrink-0 transition-transform duration-200 ease-out group-hover/back:-translate-x-0.5\" />\n                Use a different email\n              </TextButton>\n            ) : (\n              <span />\n            )}\n            {onResend && (\n              <TextButton\n                onClick={resend}\n                disabled={label === \"wait\" || label === \"sent\"}\n                aria-busy={label === \"sending\" || undefined}\n                aria-label={label === \"wait\" ? `Resend email, available in ${remaining} seconds` : undefined}\n              >\n                <span className=\"grid justify-items-end\">\n                  {[\"Resend in 0:00\", \"Resend email\", \"Sending…\", \"Sent\"].map((l) => (\n                    <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1 tabular\">\n                      {l}\n                    </span>\n                  ))}\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    <motion.span\n                      key={label}\n                      className=\"col-start-1 row-start-1 inline-flex items-center gap-1 tabular\"\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={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n                    >\n                      {label === \"wait\" && (\n                        <span>\n                          Resend in {Math.floor(remaining / 60)}:\n                          <NumberFlow value={remaining % 60} format={{ minimumIntegerDigits: 2 }} trend={-1} animated={!reduce} />\n                        </span>\n                      )}\n                      {label === \"ready\" && \"Resend email\"}\n                      {label === \"sending\" && \"Sending…\"}\n                      {label === \"sent\" && (\n                        <>\n                          <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"text-success\">\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.3, ease: ease.out, delay: 0.05 }} />\n                          </svg>\n                          Sent\n                        </>\n                      )}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n              </TextButton>\n            )}\n          </div>\n        </div>\n      )}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {verified ? \"Signed in\" : announce}\n      </span>\n    </div>\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        \"disabled:pointer-events-none disabled:text-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </button>\n  );\n}\n\n// The letter drops into the envelope and the flap folds shut, once per email sent.\n// When the link is opened the flap lifts and the letter rises with a check. The\n// flap is two shapes: the open half behind the letter, the shut half in front of\n// it, each folding flat at the hinge, so it reads as one flap turning over.\n// The server and client render the same starting frame; reduced motion only\n// zeroes the durations, so hydration never disagrees.\nfunction Envelope({ open, reduce }: { open: boolean; reduce: boolean }) {\n  const t = (closing: object, opening: object) => (reduce ? { duration: 0 } : open ? opening : closing);\n  return (\n    <svg width=\"64\" height=\"60\" viewBox=\"0 0 64 60\" fill=\"none\" aria-hidden className=\"shrink-0 overflow-visible text-fg-3\">\n      <rect x=\"8\" y=\"24\" width=\"48\" height=\"31\" rx=\"4\" className=\"fill-hover\" stroke=\"currentColor\" strokeWidth=\"1.25\" />\n      {/* Flap, open: behind the letter */}\n      <motion.path\n        d=\"M8.6 24.4 32 8.5l23.4 15.9\"\n        className=\"fill-hover\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.25\"\n        strokeLinejoin=\"round\"\n        style={{ originY: 1 }}\n        initial={{ scaleY: 1 }}\n        animate={{ scaleY: open ? 1 : 0 }}\n        transition={t({ duration: 0.16, ease: ease.in, delay: 0.52 }, { duration: 0.16, ease: ease.out, delay: 0.16 })}\n      />\n      <motion.g\n        initial={{ y: -22, opacity: 0 }}\n        animate={{ y: open ? -12 : 10, opacity: 1 }}\n        transition={reduce ? { duration: 0 } : open ? { ...spring.soft, delay: 0.26 } : { duration: 0.55, ease: ease.out, delay: 0.08, opacity: { duration: 0.2, delay: 0.08 } }}\n      >\n        <rect x=\"15\" y=\"16\" width=\"34\" height=\"26\" rx=\"2.5\" className=\"fill-raised\" stroke=\"currentColor\" strokeWidth=\"1.25\" />\n        <AnimatePresence initial={false}>\n          {open ? (\n            <motion.path\n              key=\"check\"\n              d=\"M26.5 28.5 30.5 32.5 38 24\"\n              className=\"text-success\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.75\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? false : { pathLength: 0, opacity: 0 }}\n              animate={{ pathLength: 1, opacity: 1 }}\n              transition={{ duration: 0.4, ease: ease.out, delay: 0.5, opacity: { duration: 0.01, delay: 0.5 } }}\n            />\n          ) : (\n            <motion.path\n              key=\"lines\"\n              d=\"M21 23.5h22M21 28.5h22M21 33.5h13\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.25\"\n              strokeLinecap=\"round\"\n              opacity=\"0.6\"\n              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n            />\n          )}\n        </AnimatePresence>\n      </motion.g>\n      {/* Front pocket, which hides the lower part of the letter */}\n      <path d=\"M8 27.5 32 43.5 56 27.5V51a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4z\" className=\"fill-raised\" />\n      <path d=\"M8 27V51a4 4 0 0 0 4 4h40a4 4 0 0 0 4-4V27\" stroke=\"currentColor\" strokeWidth=\"1.25\" />\n      {/* The pocket's edge shows only while the flap is up; shut, the flap's own edge takes its place. */}\n      <motion.path\n        d=\"M8.6 27.9 32 43.5l23.4-15.6\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.25\"\n        strokeLinejoin=\"round\"\n        initial={{ opacity: 1 }}\n        animate={{ opacity: open ? 1 : 0 }}\n        transition={t({ duration: 0.08, delay: 0.72 }, { duration: 0.08 })}\n      />\n      <path d=\"M9.5 53.5 26 39.8M54.5 53.5 38 39.8\" stroke=\"currentColor\" strokeWidth=\"1.25\" strokeLinecap=\"round\" opacity=\"0.45\" />\n      {/* Flap, shut: in front of the letter */}\n      <motion.path\n        d=\"M8.6 25.4 32 43.5l23.4-18.1\"\n        className=\"fill-raised\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.25\"\n        strokeLinejoin=\"round\"\n        style={{ originY: 0 }}\n        initial={{ scaleY: 0 }}\n        animate={{ scaleY: open ? 0 : 1 }}\n        transition={t({ duration: 0.2, ease: ease.out, delay: 0.68 }, { duration: 0.16, ease: ease.in })}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/magic-link"
}
