{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"domain-connect","type":"registry:component","title":"Domain connect","description":"A custom domain from typed to live: copyable records that tick green as DNS finds them, checks that wait longer each time, and a rail that runs to Live.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/domain-connect.tsx","type":"registry:component","content":"// Hairline UI · domain-connect · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUpRight, Clock, Copy, Globe, Trash2 } from \"lucide-react\";\nimport { ConfirmAction } from \"./confirm-action\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * DOMAIN CONNECT: a custom domain, from typed to live\n *\n *   add       type or paste an address (a pasted URL is cut\n *             down to its host); Connect, and the address lifts\n *             out of the field into the domain's own heading as\n *             the records it needs open beneath it\n *   records   every record has its own copy buttons; each one\n *             ticks green the moment your server sees it, and\n *             the rail above fills record by record\n *   checks    the card checks again by itself, waiting longer\n *             each time (10s, 20s, 30s, 1 min, 2 min, 5 min):\n *             \"Checking again in 0:42\" rolls down; Check now\n *             asks at once. It never checks while the tab is\n *             hidden\n *   live      the rail runs to its end, the status morphs to\n *             Live with a drawn check, the records fold away\n *             behind \"Show records\" and the address becomes a\n *             link\n *\n * Your server owns the truth: onAdd registers the domain and\n * check() reports what it sees. Works with Cloudflare for SaaS,\n * Vercel's domains API, or Resend and Postmark sending domains.\n * ───────────────────────────────────────────────────────── */\n\nexport type DnsRecordType = \"CNAME\" | \"TXT\" | \"A\" | \"AAAA\" | \"MX\" | \"CAA\";\nexport type DnsRecordStatus = \"pending\" | \"verified\" | \"failed\";\nexport type DomainStatus = \"pending\" | \"issuing\" | \"active\" | \"failed\";\n\nexport interface DnsRecord {\n  type: DnsRecordType;\n  /** The host to set it on, e.g. \"app\" or \"_acme-challenge.app\". */\n  name: string;\n  value: string;\n  priority?: number;\n  status?: DnsRecordStatus;\n}\n\nexport interface DomainState {\n  domain: string;\n  /** pending: waiting for DNS; issuing: DNS is right, the certificate is on its way; active: live. */\n  status: DomainStatus;\n  records: DnsRecord[];\n  /** Why it failed, or a hint while it's pending (\"CNAME points at the old host\"). */\n  message?: string | null;\n}\n\nexport interface DomainConnectProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  value?: DomainState | null;\n  defaultValue?: DomainState | null;\n  onValueChange?: (state: DomainState | null) => void;\n  /** Registers the domain and returns the records it needs. Throw to keep the field and say why. */\n  onAdd: (domain: string) => Promise<DomainState>;\n  /** Asks your server what it sees now. */\n  check: (domain: string, options: { signal: AbortSignal }) => Promise<DomainState>;\n  onRemove?: (domain: string) => Promise<unknown> | unknown;\n  /** Seconds to wait before each automatic check; the last repeats. */\n  schedule?: number[];\n  title?: string;\n  description?: string;\n  placeholder?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst SCHEDULE = [10, 20, 30, 60, 120, 300];\nconst HOST = /^(?=.{1,253}$)(?:(?!-)[a-z0-9-]{1,63}(?<!-)\\.)+[a-z]{2,63}$/;\nconst SHAKE = [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** \"https://App.Fernhill.co/pricing\" → \"app.fernhill.co\". */\nexport const toHost = (input: string) =>\n  input\n    .trim()\n    .toLowerCase()\n    .replace(/^[a-z]+:\\/\\//, \"\")\n    .replace(/[/?#].*$/, \"\")\n    .replace(/:\\d+$/, \"\")\n    .replace(/\\.$/, \"\");\n\nconst message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);\n\nfunction Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {\n  const kept = React.useRef(children);\n  if (open) kept.current = children;\n  return (\n    <div className=\"grid\" inert={!open} aria-hidden={!open || undefined} style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 480ms ${EASE}, opacity ${open ? 320 : 180}ms ${EASE}` }}>\n      <div className=\"min-h-0 min-w-0 overflow-clip [overflow-clip-margin:6px]\">{open ? children : kept.current}</div>\n    </div>\n  );\n}\n\nfunction Check({ drawn, reduced, className = \"size-3.5\" }: { drawn: boolean; reduced: boolean; className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.9\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${EASE} 120ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\n/** Layers that swap through a blur: one visible at a time. */\nfunction Swap({ show, reduced, layers, className = \"size-3.5\" }: { show: string; reduced: boolean; layers: Record<string, React.ReactNode>; className?: string }) {\n  return (\n    <span aria-hidden=\"true\" className={`relative grid shrink-0 place-items-center ${className}`}>\n      {Object.entries(layers).map(([k, node]) => (\n        <span\n          key={k}\n          className=\"absolute inset-0 grid place-items-center\"\n          style={{\n            opacity: k === show ? 1 : 0,\n            transform: k === show ? \"none\" : \"scale(0.6)\",\n            filter: k === show || reduced ? \"none\" : \"blur(3px)\",\n            transition: reduced ? \"none\" : `opacity 240ms ${EASE}, transform 360ms ${EASE}, filter 240ms ${EASE}`,\n          }}\n        >\n          {node}\n        </span>\n      ))}\n    </span>\n  );\n}\n\nconst Spinner = ({ on }: { on: boolean }) => <span className={`size-3 rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none ${on ? \"animate-spin\" : \"\"}`} />;\n\n/** A copy button whose icon turns into a drawn check. */\nfunction CopyValue({ text, label, reduced }: { text: string; label: string; reduced: boolean }) {\n  const [done, setDone] = React.useState(false);\n  React.useEffect(() => {\n    if (!done) return;\n    const timer = window.setTimeout(() => setDone(false), 1400);\n    return () => window.clearTimeout(timer);\n  }, [done]);\n  return (\n    <button\n      type=\"button\"\n      aria-label={done ? `${label} copied` : `Copy ${label}`}\n      onClick={async () => {\n        try {\n          await navigator.clipboard.writeText(text);\n          setDone(true);\n        } catch {\n          /* The value is on screen to select by hand. */\n        }\n      }}\n      className={`grid size-6 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS} ${done ? \"text-emerald-600 dark:text-emerald-400\" : \"\"}`}\n    >\n      <Swap show={done ? \"done\" : \"copy\"} reduced={reduced} layers={{ copy: <Copy className=\"size-3.5\" strokeWidth={1.75} />, done: <Check drawn={done} reduced={reduced} /> }} />\n    </button>\n  );\n}\n\nfunction RecordRow({ r, index, reduced }: { r: DnsRecord; index: number; reduced: boolean }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const status = r.status ?? \"pending\";\n  const was = React.useRef(status);\n  // A record that just verified lights up for a moment.\n  React.useEffect(() => {\n    if (was.current === status) return;\n    was.current = status;\n    if (status === \"verified\" && !reduced)\n      ref.current?.animate([{ backgroundColor: \"color-mix(in oklab, var(--color-emerald-500) 9%, transparent)\" }, { backgroundColor: \"transparent\" }], { duration: 1400, easing: EASE });\n  }, [status, reduced]);\n  React.useLayoutEffect(() => {\n    if (!reduced) ref.current?.animate([{ opacity: 0, transform: \"translateY(6px)\", filter: \"blur(3px)\" }, { opacity: 1, transform: \"none\", filter: \"blur(0px)\" }], { duration: 420, delay: 180 + index * 70, easing: EASE, fill: \"backwards\" });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  const said = status === \"verified\" ? \"Found\" : status === \"failed\" ? \"Wrong value\" : \"Waiting\";\n  return (\n    <div ref={ref} role=\"row\" className=\"grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1 rounded-[10px] px-2 py-2 @lg:grid-cols-[3.25rem_minmax(0,0.8fr)_minmax(0,1.4fr)_auto] @lg:items-center\">\n      <span role=\"cell\" className=\"pt-0.5 font-mono text-[11px] font-medium text-muted-foreground @lg:pt-0\">\n        {r.type}\n        {r.priority !== undefined && <span className=\"ml-1 font-normal\">{r.priority}</span>}\n      </span>\n      <span role=\"cell\" className=\"flex min-w-0 items-center gap-1\">\n        <code title={r.name} className=\"min-w-0 truncate font-mono text-[12px]\">{r.name}</code>\n        <CopyValue text={r.name} label={`${r.type} name`} reduced={reduced} />\n      </span>\n      <span role=\"cell\" className={`col-start-3 row-start-1 flex items-center gap-1.5 pt-0.5 text-[11.5px] @lg:col-start-4 @lg:pt-0 ${status === \"verified\" ? \"text-emerald-600 dark:text-emerald-400\" : status === \"failed\" ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n        <Swap\n          show={status}\n          reduced={reduced}\n          layers={{ pending: <Spinner on={status === \"pending\"} />, verified: <Check drawn={status === \"verified\"} reduced={reduced} />, failed: <span className=\"size-1.5 rounded-full bg-current\" /> }}\n        />\n        <span className=\"hidden @sm:inline\">\n          <TextMorph>{said}</TextMorph>\n        </span>\n        <span className=\"sr-only @sm:hidden\">{said}</span>\n      </span>\n      <span role=\"cell\" className=\"col-span-2 col-start-2 flex min-w-0 items-center gap-1 @lg:col-span-1 @lg:col-start-3 @lg:row-start-1\">\n        <code title={r.value} className=\"min-w-0 truncate font-mono text-[12px] text-muted-foreground\">{r.value}</code>\n        <CopyValue text={r.value} label={`${r.type} value`} reduced={reduced} />\n      </span>\n    </div>\n  );\n}\n\n/** \"Checking again in 0:42\", rolling down; \"Checking\" while it asks. */\nfunction Countdown({ at, checking, reduced }: { at: number | null; checking: boolean; reduced: boolean }) {\n  const [now, setNow] = React.useState(() => Date.now());\n  const left = at === null ? 0 : Math.max(0, Math.ceil((at - now) / 1000));\n  React.useEffect(() => {\n    if (at === null || checking) return;\n    setNow(Date.now());\n  }, [at, checking]);\n  React.useEffect(() => {\n    if (at === null || checking || left <= 0) return;\n    const timer = window.setTimeout(() => setNow(Date.now()), 1000 - (Date.now() % 1000) + 10);\n    return () => window.clearTimeout(timer);\n  }, [at, checking, left, now]);\n  const counting = !checking && at !== null && left > 0;\n  return (\n    <span className=\"inline-flex items-center gap-1.5 whitespace-nowrap tabular-nums\">\n      <Swap show={checking ? \"checking\" : \"waiting\"} reduced={reduced} className=\"size-3\" layers={{ waiting: <Clock className=\"size-3\" strokeWidth={2} />, checking: <Spinner on={checking} /> }} />\n      <TextMorph>{checking ? \"Checking DNS\" : \"Checking again in\"}</TextMorph>\n      <span\n        aria-hidden=\"true\"\n        className=\"-ml-1.5 inline-grid\"\n        style={{ gridTemplateColumns: counting ? \"1fr\" : \"0fr\", opacity: counting ? 1 : 0, transition: reduced ? \"none\" : `grid-template-columns 380ms ${EASE}, opacity 240ms ${EASE}` }}\n      >\n        <span className=\"min-w-0 [clip-path:inset(-4px_0)]\">\n          <span className=\"inline-flex items-baseline pl-1.5\">\n            <NumberRoll value={Math.floor(left / 60)} direction=\"down\" />:\n            <NumberRoll value={left % 60} format={{ minimumIntegerDigits: 2 }} direction=\"down\" />\n          </span>\n        </span>\n      </span>\n    </span>\n  );\n}\n\nconst STEPS = [\"DNS records\", \"Certificate\", \"Live\"];\n\nfunction Rail({ state, reduced }: { state: DomainState; reduced: boolean }) {\n  const total = state.records.length || 1;\n  const found = state.records.filter((r) => r.status === \"verified\").length;\n  const fill = state.status === \"active\" ? 1 : state.status === \"issuing\" ? 0.5 : (found / total) * 0.5;\n  const reached = (i: number) => (i === 0 ? found === total || state.status === \"issuing\" || state.status === \"active\" : state.status === \"active\");\n  const current = state.status === \"failed\" ? -1 : state.status === \"active\" ? -1 : state.status === \"issuing\" ? 1 : 0;\n  return (\n    <div className=\"px-1 pt-1\" aria-hidden=\"true\">\n      <div className=\"relative mx-[9px] h-px bg-border\">\n        <span\n          className={`absolute inset-0 origin-left ${state.status === \"failed\" ? \"bg-red-500\" : \"bg-emerald-500\"}`}\n          style={{ transform: `scaleX(${fill})`, transition: reduced ? \"none\" : `transform 760ms ${EASE}` }}\n        />\n        {STEPS.map((_, i) => {\n          const done = reached(i);\n          return (\n            <span\n              key={i}\n              className={`absolute top-1/2 grid size-[18px] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-background shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-500 ${done ? \"text-emerald-600 dark:text-emerald-400\" : \"text-muted-foreground\"}`}\n              style={{ left: `${i * 50}%` }}\n            >\n              {current === i && !done ? <Spinner on /> : <Check drawn={done} reduced={reduced} className=\"size-3\" />}\n            </span>\n          );\n        })}\n      </div>\n      <div className=\"mt-3.5 grid grid-cols-3 text-[11px] text-muted-foreground\">\n        {STEPS.map((s, i) => (\n          <span key={s} className={`${i === 0 ? \"text-left\" : i === 1 ? \"text-center\" : \"text-right\"} ${reached(i) ? \"text-foreground\" : \"\"} transition-colors duration-500`}>\n            {s}\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport function DomainConnect({\n  value,\n  defaultValue = null,\n  onValueChange,\n  onAdd,\n  check,\n  onRemove,\n  schedule = SCHEDULE,\n  title = \"Custom domain\",\n  description = \"Serve your workspace from an address you own.\",\n  placeholder = \"app.yourcompany.com\",\n  className = \"\",\n  ...props\n}: DomainConnectProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [inner, setInner] = React.useState<DomainState | null>(defaultValue);\n  const state = value !== undefined ? value : inner;\n  const [typed, setTyped] = React.useState(\"\");\n  const [adding, setAdding] = React.useState<ActionStatus>(\"idle\");\n  const [addError, setAddError] = React.useState<string | null>(null);\n  const [checking, setChecking] = React.useState(false);\n  const [nextAt, setNextAt] = React.useState<number | null>(null);\n  const [showRecords, setShowRecords] = React.useState(false);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const fieldRef = React.useRef<HTMLDivElement>(null);\n  const headRef = React.useRef<HTMLParagraphElement>(null);\n  const flight = React.useRef<DOMRect | null>(null);\n  const attempt = React.useRef(0);\n  const ctrl = React.useRef<AbortController | null>(null);\n  const latest = React.useRef(state);\n  latest.current = state;\n\n  const commit = (next: DomainState | null) => {\n    if (value === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n\n  const waiting = Boolean(state && (state.status === \"pending\" || state.status === \"issuing\"));\n\n  const runCheck = React.useCallback(async () => {\n    const cur = latest.current;\n    if (!cur) return;\n    ctrl.current?.abort();\n    const c = new AbortController();\n    ctrl.current = c;\n    setChecking(true);\n    setNextAt(null);\n    try {\n      const next = await check(cur.domain, { signal: c.signal });\n      if (c.signal.aborted || latest.current?.domain !== cur.domain) return;\n      const before = cur.records.filter((r) => r.status === \"verified\").length;\n      const after = next.records.filter((r) => r.status === \"verified\").length;\n      if (next.status !== cur.status) setAnnounce(next.status === \"active\" ? `${next.domain} is live.` : next.status === \"issuing\" ? \"DNS records found. Issuing a certificate.\" : next.status === \"failed\" ? next.message ?? \"The domain couldn't be connected.\" : \"\");\n      else if (after > before) setAnnounce(`${after} of ${next.records.length} records found.`);\n      commit(next);\n      attempt.current++;\n    } catch {\n      if (c.signal.aborted) return;\n      attempt.current++;\n    } finally {\n      if (!c.signal.aborted) setChecking(false);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [check, onValueChange, value]);\n\n  // The next automatic check: further off each time, never while the tab is hidden.\n  React.useEffect(() => {\n    if (!waiting || checking) return;\n    const wait = schedule[Math.min(attempt.current, schedule.length - 1)] * 1000;\n    const at = Date.now() + wait;\n    setNextAt(at);\n    let onShow: (() => void) | null = null;\n    const timer = window.setTimeout(() => {\n      if (!document.hidden) return runCheck();\n      // Hidden tab: wait until it's looked at again.\n      onShow = () => {\n        document.removeEventListener(\"visibilitychange\", onShow!);\n        onShow = null;\n        runCheck();\n      };\n      document.addEventListener(\"visibilitychange\", onShow);\n    }, wait);\n    return () => {\n      window.clearTimeout(timer);\n      if (onShow) document.removeEventListener(\"visibilitychange\", onShow);\n    };\n  }, [waiting, checking, schedule, runCheck, state?.domain, state?.status]);\n\n  React.useEffect(() => () => ctrl.current?.abort(), []);\n\n  // The address lifts out of the field into the heading.\n  React.useLayoutEffect(() => {\n    const from = flight.current;\n    const head = headRef.current;\n    if (!from || !head || !state || reduced) return;\n    flight.current = null;\n    const to = head.getBoundingClientRect();\n    head.animate(\n      [{ transform: `translate(${from.left - to.left}px, ${from.top + from.height / 2 - (to.top + to.height / 2)}px)` }, { transform: \"none\" }],\n      { duration: 620, easing: EASE },\n    );\n  }, [state, reduced]);\n\n  const add = async () => {\n    if (adding === \"pending\") return;\n    const host = toHost(typed);\n    if (!HOST.test(host)) {\n      setAddError(host ? `“${host}” isn't a domain. Try something like ${placeholder}.` : \"Enter the domain you want to use.\");\n      if (!reduced) fieldRef.current?.animate(SHAKE, { duration: 380, easing: \"ease-out\" });\n      inputRef.current?.focus();\n      return;\n    }\n    if (host !== typed) setTyped(host);\n    setAdding(\"pending\");\n    setAddError(null);\n    try {\n      const next = await onAdd(host);\n      const input = inputRef.current;\n      if (input) {\n        const r = input.getBoundingClientRect();\n        const pad = parseFloat(getComputedStyle(input).paddingLeft) || 0;\n        flight.current = new DOMRect(r.left + pad, r.top, r.width, r.height);\n      }\n      attempt.current = 0;\n      setShowRecords(false);\n      commit(next);\n      setAdding(\"idle\");\n      setTyped(\"\");\n      setAnnounce(`${next.domain} added. Add the records below at your DNS provider.`);\n    } catch (e) {\n      setAdding(\"error\");\n      setAddError(message(e, \"That domain couldn't be added. Try again.\"));\n    }\n  };\n\n  const remove = async () => {\n    if (!state) return;\n    await onRemove?.(state.domain);\n    ctrl.current?.abort();\n    setChecking(false);\n    setNextAt(null);\n    setAnnounce(`${state.domain} removed.`);\n    window.setTimeout(() => {\n      commit(null);\n      requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));\n    }, 500);\n  };\n\n  const live = state?.status === \"active\";\n  const failed = state?.status === \"failed\";\n  const found = state ? state.records.filter((r) => r.status === \"verified\").length : 0;\n  const pill = !state\n    ? \"\"\n    : live\n      ? \"Live\"\n      : failed\n        ? \"Not connected\"\n        : state.status === \"issuing\"\n          ? \"Issuing certificate\"\n          : found\n            ? `${found} of ${state.records.length} records found`\n            : \"Waiting for DNS\";\n\n  return (\n    <div className={`@container w-full rounded-[22px] border border-border bg-background text-foreground ${className}`} {...props}>\n      <div className=\"px-4 pt-4 @md:px-5 @md:pt-5\">\n        <p className=\"text-[14px] font-medium tracking-[-0.01em]\">{title}</p>\n        <p className=\"mt-0.5 text-[12px] text-muted-foreground\">{description}</p>\n      </div>\n\n      {/* The field: only while there's no domain. */}\n      <Fold open={!state} reduced={reduced}>\n        <form\n          noValidate\n          className=\"px-4 pt-3.5 pb-4 @md:px-5 @md:pb-5\"\n          onSubmit={(e) => {\n            e.preventDefault();\n            add();\n          }}\n        >\n          <label htmlFor={`${id}-domain`} className=\"sr-only\">\n            Domain\n          </label>\n          <div className=\"flex flex-col gap-2 @sm:flex-row\">\n            <div\n              ref={fieldRef}\n              // Grows sideways only beside the button; stacked on a narrow card, flex-1 would override its height.\n              className={`flex h-9 min-w-0 shrink-0 items-center rounded-full px-3.5 transition-shadow @sm:flex-1 duration-200 ${addError ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_45%,transparent)]\" : \"shadow-[inset_0_0_0_1px_var(--border)] focus-within:shadow-[inset_0_0_0_1px_var(--ring)]\"}`}\n            >\n              <Globe className=\"mr-2 size-3.5 shrink-0 text-muted-foreground\" strokeWidth={1.75} aria-hidden=\"true\" />\n              <input\n                ref={inputRef}\n                id={`${id}-domain`}\n                value={typed}\n                onChange={(e) => {\n                  setTyped(e.target.value);\n                  setAddError(null);\n                  if (adding === \"error\") setAdding(\"idle\");\n                }}\n                onBlur={() => typed && setTyped(toHost(typed))}\n                onPaste={(e) => {\n                  const text = e.clipboardData.getData(\"text\");\n                  if (!/[:/]/.test(text)) return;\n                  e.preventDefault();\n                  setTyped(toHost(text));\n                }}\n                readOnly={adding === \"pending\"}\n                placeholder={placeholder}\n                inputMode=\"url\"\n                autoComplete=\"off\"\n                autoCapitalize=\"off\"\n                spellCheck={false}\n                aria-invalid={Boolean(addError) || undefined}\n                aria-describedby={addError ? `${id}-error` : undefined}\n                className=\"h-full min-w-0 flex-1 bg-transparent text-[16px] text-foreground outline-none placeholder:text-muted-foreground/70 [@media(pointer:fine)]:text-[13.5px]\"\n              />\n            </div>\n            <StatusButton type=\"submit\" status={adding} labels={{ idle: \"Connect\", pending: \"Connecting\", error: \"Try again\" }} />\n          </div>\n          <Fold open={Boolean(addError)} reduced={reduced}>\n            <p id={`${id}-error`} className=\"px-1 pt-2 text-[12px] text-red-600 dark:text-red-400\">\n              {addError}\n            </p>\n          </Fold>\n        </form>\n      </Fold>\n\n      {/* The domain: heading, rail, records, and the next check. */}\n      <Fold open={Boolean(state)} reduced={reduced}>\n        {state && (\n          <div className=\"px-4 pt-3.5 pb-4 @md:px-5 @md:pb-5\">\n            <div className=\"flex items-center gap-3\">\n              <div className=\"min-w-0 flex-1\">\n                <p ref={headRef} className=\"truncate text-[15px] font-medium tracking-[-0.01em]\">\n                  {live ? (\n                    <a href={`https://${state.domain}`} target=\"_blank\" rel=\"noopener noreferrer\" className={`inline-flex items-center gap-1 underline-offset-4 hover:underline ${FOCUS}`}>\n                      {state.domain}\n                      <ArrowUpRight className=\"size-3.5 text-muted-foreground\" strokeWidth={2} aria-hidden=\"true\" />\n                    </a>\n                  ) : (\n                    state.domain\n                  )}\n                </p>\n                <p className={`mt-0.5 flex items-center gap-1.5 text-[12px] transition-colors duration-500 ${live ? \"text-emerald-600 dark:text-emerald-400\" : failed ? \"text-red-600 dark:text-red-400\" : \"text-amber-700 dark:text-amber-400\"}`}>\n                  <span className=\"relative grid size-2 place-items-center\" aria-hidden=\"true\">\n                    <span className=\"size-1.5 rounded-full bg-current\" />\n                    {waiting && !reduced && <span className=\"absolute inset-0 rounded-full bg-current opacity-40 motion-safe:animate-[ui-ping_1.6s_ease-out_infinite]\" />}\n                  </span>\n                  <TextMorph>{pill}</TextMorph>\n                </p>\n              </div>\n              {onRemove && (\n                <ConfirmAction icon={<Trash2 strokeWidth={1.75} />} label={`Remove ${state.domain}`} labels={{ idle: \"Remove\", pending: \"Removing\", success: \"Removed\", error: \"Try again\" }} onConfirm={remove} />\n              )}\n            </div>\n\n            <div className=\"mt-4\">\n              <Rail state={state} reduced={reduced} />\n            </div>\n\n            <Fold open={!live || showRecords} reduced={reduced}>\n              <div className=\"pt-4\">\n                <p className=\"text-[12px] text-muted-foreground\">{live ? \"These records keep it connected.\" : \"Add these at your DNS provider. Changes can take a few minutes to show.\"}</p>\n                <div role=\"table\" aria-label={`DNS records for ${state.domain}`} className=\"mt-2 rounded-[14px] p-1 shadow-[inset_0_0_0_1px_var(--border)]\">\n                  {state.records.map((r, i) => (\n                    <RecordRow key={`${r.type}-${r.name}-${r.value}`} r={r} index={i} reduced={reduced} />\n                  ))}\n                </div>\n              </div>\n            </Fold>\n\n            <Fold open={Boolean(state.message)} reduced={reduced}>\n              <p className={`pt-3 text-[12px] ${failed ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>{state.message}</p>\n            </Fold>\n\n            <div className=\"mt-3.5 flex min-h-7 items-center justify-between gap-3 text-[12px] text-muted-foreground\">\n              {live ? (\n                <button\n                  type=\"button\"\n                  aria-expanded={showRecords}\n                  onClick={() => setShowRecords((s) => !s)}\n                  className={`-ml-2 h-7 cursor-pointer rounded-full px-2 font-medium transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n                >\n                  <TextMorph>{showRecords ? \"Hide records\" : \"Show records\"}</TextMorph>\n                </button>\n              ) : failed ? (\n                <span className=\"text-red-600 dark:text-red-400\">Fix the records, then check again.</span>\n              ) : (\n                <Countdown at={nextAt} checking={checking} reduced={reduced} />\n              )}\n              {!live && (\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    attempt.current = 0;\n                    runCheck();\n                  }}\n                  aria-disabled={checking || undefined}\n                  className={`h-7 shrink-0 cursor-pointer rounded-full px-3 font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-[background-color,opacity] hover:bg-accent aria-disabled:cursor-default aria-disabled:opacity-50 ${FOCUS}`}\n                >\n                  Check now\n                </button>\n              )}\n            </div>\n          </div>\n        )}\n      </Fold>\n\n      <span className=\"sr-only\" role=\"status\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/confirm-action.tsx","type":"registry:component","content":"// Hairline UI · domain-connect · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\n\n/* ─────────────────────────────────────────────────────────\n * CONFIRM ACTION: an icon that asks before it acts\n *\n *   ask       a click and the icon's own circle stretches into\n *             a pill: the confirm button opens out to its left\n *             and the icon turns into a cross that takes it back\n *   act       the confirm button is a status button, so it says\n *             Revoking, Revoked or Try again in place\n *   leave     Escape, the cross, or a click or focus anywhere\n *             else folds it back into the icon\n *\n * Nothing opens over the row it belongs to; the question sits\n * where the icon was.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ConfirmActionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onClick\"> {\n  icon: React.ReactNode;\n  /** The icon button's accessible name, e.g. \"Revoke Production key\". */\n  label: string;\n  /** The confirm button's words per state, e.g. { idle: \"Revoke\", pending: \"Revoking\", success: \"Revoked\" }. */\n  labels?: Partial<Record<ActionStatus, string>>;\n  /** \"danger\" makes the confirm red. */\n  tone?: \"danger\" | \"default\";\n  /** Runs once on confirm. Throw (or reject) to show Try again. */\n  onConfirm: () => Promise<unknown> | unknown;\n  /** Back to the icon this long after success (ms); 0 keeps \"done\" until the row goes. */\n  resetAfter?: number;\n  disabled?: boolean;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction Cross() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n      <path d=\"M4.5 4.5l7 7M11.5 4.5l-7 7\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nexport function ConfirmAction({\n  icon,\n  label,\n  labels,\n  tone = \"danger\",\n  onConfirm,\n  resetAfter = 0,\n  disabled = false,\n  className = \"\",\n  ...props\n}: ConfirmActionProps) {\n  const reduced = useReducedMotion();\n  const [asking, setAsking] = React.useState(false);\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const iconRef = React.useRef<HTMLButtonElement>(null);\n  const run = React.useRef(0);\n  const busy = status === \"pending\" || status === \"success\";\n\n  const cancel = React.useCallback(\n    (refocus: boolean) => {\n      if (status === \"pending\") return;\n      run.current++;\n      setAsking(false);\n      setStatus(\"idle\");\n      if (refocus) iconRef.current?.focus({ preventScroll: true });\n    },\n    [status],\n  );\n\n  // The question is open: its button takes focus once it's there to take it.\n  React.useEffect(() => {\n    if (asking && status === \"idle\") wrapRef.current?.querySelector<HTMLButtonElement>(\"[data-confirm]\")?.focus({ preventScroll: true });\n  }, [asking, status]);\n\n  React.useEffect(() => {\n    if (!asking) return;\n    const onDown = (e: PointerEvent) => {\n      if (!wrapRef.current?.contains(e.target as Node)) cancel(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [asking, cancel]);\n\n  const confirm = async () => {\n    if (busy) return;\n    const mine = ++run.current;\n    setStatus(\"pending\");\n    try {\n      await onConfirm();\n      if (run.current === mine) setStatus(\"success\");\n    } catch {\n      if (run.current === mine) setStatus(\"error\");\n    }\n  };\n\n  const t = `${reduced ? 0 : 420}ms ${EASE}`;\n  const red = tone === \"danger\" ? \"text-red-600! hover:bg-red-500/10! dark:text-red-400!\" : \"\";\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative inline-flex h-7 shrink-0 items-center rounded-full ${className}`}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && asking) {\n          e.preventDefault();\n          e.stopPropagation();\n          cancel(true);\n        }\n      }}\n      onBlur={(e) => {\n        if (asking && !busy && document.hasFocus() && !wrapRef.current?.contains(e.relatedTarget as Node)) cancel(false);\n      }}\n      {...props}\n    >\n      {/* The icon's circle, stretched into the pill round the question. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 rounded-full\"\n        style={{ boxShadow: `inset 0 0 0 1px ${asking ? \"var(--border)\" : \"transparent\"}`, transition: `box-shadow ${t}` }}\n      />\n      <span\n        className=\"grid\"\n        inert={!asking}\n        style={{\n          gridTemplateColumns: asking ? \"1fr\" : \"0fr\",\n          opacity: asking ? 1 : 0,\n          filter: asking || reduced ? \"none\" : \"blur(3px)\",\n          transition: `grid-template-columns ${t}, opacity ${reduced ? 0 : asking ? 320 : 160}ms ${EASE}, filter ${t}`,\n        }}\n      >\n        <span className=\"min-w-0 [clip-path:inset(-2px_0_-2px_0)]\">\n          <span className=\"flex w-max items-center pl-0.5\">\n            <StatusButton\n              data-confirm=\"\"\n              size=\"sm\"\n              variant=\"ghost\"\n              status={status}\n              labels={{ idle: \"Confirm\", pending: \"Working\", success: \"Done\", error: \"Try again\", ...labels }}\n              onClick={confirm}\n              onReset={resetAfter ? () => cancel(false) : undefined}\n              resetAfter={resetAfter || undefined}\n              className={`h-[26px]! ${status === \"error\" ? \"\" : red}`}\n            />\n          </span>\n        </span>\n      </span>\n      <button\n        ref={iconRef}\n        type=\"button\"\n        aria-label={asking ? \"Cancel\" : label}\n        aria-expanded={asking}\n        disabled={disabled}\n        onClick={() => (asking ? cancel(true) : setAsking(true))}\n        className={`relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40 aria-disabled:cursor-default ${FOCUS}`}\n        aria-disabled={busy || undefined}\n      >\n        {[icon, <Cross key=\"x\" />].map((glyph, i) => {\n          const on = (i === 1) === asking;\n          return (\n            <span\n              key={i}\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 grid place-items-center [&_svg]:size-3.5\"\n              style={{\n                opacity: on ? 1 : 0,\n                transform: on ? \"none\" : \"scale(0.6)\",\n                filter: on || reduced ? \"none\" : \"blur(3px)\",\n                transition: reduced ? \"none\" : `opacity 260ms ${EASE}, transform 380ms ${EASE}, filter 260ms ${EASE}`,\n              }}\n            >\n              {glyph}\n            </span>\n          );\n        })}\n      </button>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-ping":{"from":{"transform":"scale(1)","opacity":"0.55"},"to":{"transform":"scale(2.6)","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/domain-connect. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}