Components

Getting started

Components109

More

Domain connectNew

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.

Used forCustom domains for SaaSSite and page buildersEmail sending domainsHelp centres and status pages

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/domain-connect.json

Where it’s used

  • Custom domains for SaaS

    Let customers serve their app or site from their own domain.

  • Site and page builders

    Connect a domain to a published site and show when it is live.

  • Email sending domains

    Verify a domain before a product sends email from it.

  • Help centres and status pages

    Customer-branded pages that live on the customer’s own address.

What it handles for you

Custom domain screens usually leave people refreshing a table of DNS records and wondering whether anything is happening. This takes one domain from typed to live, and shows each step on the way.

Instead of wiring upa DNS records tablecopy buttonsa polling loopa progress railverification messages
  1. Paste a URL, get a domain

    A pasted address is cut down to its host. Anything that is not a domain shakes the field and says so.

  2. Records you can copy

    Each record opens with its own copy buttons, named after the record, such as “Copy CNAME value”.

  3. It checks again by itself

    Checks wait longer each time and never run while the tab is hidden. “Checking again in 0:42” counts down, and Check now asks at once.

  4. Records tick green

    Each record turns green the moment your server sees it, and the rail above fills record by record.

  5. From certificate to Live

    It shows “Issuing certificate”, then Live. The records fold behind “Show records” and the address becomes a link.

  6. Failures in plain words

    Your server’s message says what is wrong, the wrong record is marked and automatic checks stop.

Overview

Custom domain screens usually leave people refreshing a table of records and wondering whether anything is happening. Here the address you type (a pasted URL is cut down to its host) lifts out of the field into the domain's own heading as the records it needs open beneath it, each value with its own copy button. The card checks again by itself, waiting longer each time and never while the tab is hidden; "Checking again in 0:42" rolls down, and Check now asks at once. Each record ticks green the moment your server sees it, the rail above fills record by record, then waits on the certificate and runs to Live, where the status morphs, a check draws, the records fold behind "Show records" and the address becomes a link. If something is wrong, your server's message says what, in plain words.

States

add
A pasted URL becomes its host on paste ("https://App.Fernhill.co/pricing" → "app.fernhill.co"). Anything that isn't a domain shakes the field and says so. Connect turns to Connecting while onAdd runs.
added
The field folds away in 480ms while the domain opens; its heading travels 620ms from where the address sat in the field. The records rise in one after another, 70ms apart.
waiting
A pinging amber dot says "Waiting for DNS", then "1 of 2 records found". "Checking again in 0:42" rolls down a second at a time; while check() runs it reads "Checking DNS" with a spinner.
found
A record that verifies swaps its spinner for a drawn check, says Found and glows green for 1.4s; the rail fills to that record's share of the first step in 760ms.
certificate
With every record found the status morphs to "Issuing certificate" and the middle step spins until your server reports the domain active.
live
The rail runs to its end, each step draws its check, "Live" turns green, the records fold behind "Show records" and the heading becomes a link to the domain.
failed
Your server's message opens under the records in red and automatic checks stop; the wrong record is marked. Check now starts again.

Usage

usage.tsx
import { DomainConnect } from "@/components/domain-connect";<DomainConnect  onAdd={(domain) => api.addDomain(domain)}       // → { domain, status, records }  check={(domain, { signal }) => api.getDomain(domain, { signal })}  onRemove={(domain) => api.removeDomain(domain)}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

Custom hostnames on Cloudflare for SaaS

Your route creates the custom hostname and maps what Cloudflare reports to the card's state: the CNAME to your fallback origin, the ownership TXT record, and the certificate's validation records. The card polls the GET route on its own schedule. Vercel's domains API and Resend's sending domains map the same way.

example.tsx
// app/api/domains/route.tsimport type { DomainState } from "@/components/domain-connect";const CF = `https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/custom_hostnames`;const headers = { Authorization: `Bearer ${process.env.CF_API_TOKEN}`, "Content-Type": "application/json" };function toState(h: any): DomainState {  const live = h.status === "active";  const ssl = h.ssl ?? {};  const records = [    { type: "CNAME", name: h.hostname, value: "customers.relay.app", status: live ? "verified" : "pending" },    ...(h.ownership_verification ? [{ type: "TXT", name: h.ownership_verification.name, value: h.ownership_verification.value, status: live ? "verified" : "pending" }] : []),    ...(ssl.validation_records ?? []).filter((r: any) => r.txt_name).map((r: any) => ({ type: "TXT", name: r.txt_name, value: r.txt_value, status: ssl.status === "active" ? "verified" : "pending" })),  ] as DomainState["records"];  return { domain: h.hostname, status: live && ssl.status === "active" ? "active" : live ? "issuing" : "pending", records, message: h.verification_errors?.[0] ?? null };}export async function POST(req: Request) {  const { domain } = await req.json();  const res = await fetch(CF, { method: "POST", headers, body: JSON.stringify({ hostname: domain, ssl: { method: "txt", type: "dv" } }) });  const body = await res.json();  if (!body.success) return Response.json({ error: body.errors?.[0]?.message ?? "Couldn't add it." }, { status: 400 });  return Response.json(toState(body.result));}export async function GET(req: Request) {  const domain = new URL(req.url).searchParams.get("domain")!;  const body = await (await fetch(`${CF}?hostname=${encodeURIComponent(domain)}`, { headers })).json();  return Response.json(toState(body.result[0]));}// app/settings/domain.tsx"use client";<DomainConnect  onAdd={async (domain) => {    const res = await fetch("/api/domains", { method: "POST", body: JSON.stringify({ domain }) });    if (!res.ok) throw new Error((await res.json()).error);    return res.json();  }}  check={async (domain, { signal }) => (await fetch(`/api/domains?domain=${domain}`, { signal })).json()}/>

Props

PropTypeDescription
onAdd(domain) => Promise<DomainState>Registers the domain (already cut down to its host) and returns { domain, status, records, message? }. Throw to keep the field and show the message.
check(domain, { signal }) => Promise<DomainState>Reports what your server sees now. Called on the schedule and by Check now; the signal aborts it when the domain is removed or a newer check starts.
onRemove(domain) => Promise<unknown>Removes the domain; the card goes back to the field. Without it there is no Remove.
valueDomainState | nullControlled state. status is "pending", "issuing", "active" or "failed"; each record is { type, name, value, priority?, status? }.
defaultValueDomainState | nullA domain that was already added, when uncontrolled.
onValueChange(state | null) => voidEvery add, check and removal.
schedulenumber[]default [10, 20, 30, 60, 120, 300]Seconds before each automatic check; the last one repeats. Checks wait while the tab is hidden.
titlestringdefault "Custom domain"The card's heading.
descriptionstringThe line under the heading.
placeholderstringdefault "app.yourcompany.com"The field's placeholder and the example in its error.

Accessibility and motion

  • The records are a table with a row per record; each name and value has its own copy button, named after the record ("Copy CNAME value"), whose icon turns into a drawn check.
  • Changes in status ("2 of 2 records found", "app.fernhill.co is live") are announced in a status region; the countdown is hidden from screen readers.
  • Automatic checks stop when the domain is live, has failed or has been removed, and wait while the page is hidden. A newer check aborts an older one.
  • The rail is drawn for the eye only; the status line under the heading says the same in words.
  • Reduced motion swaps every state in place: no flight, rises, glows or pings.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Text morph, Number roll, Status button.

Source

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

Registry item: /r/domain-connect.json