Source: https://monarchui.dev/components/custom-domain-connect/

Patterns · Technical infrastructure

# CustomDomainConnect

Connect a custom domain: enter it, copy the DNS records, verify, and see pending, verifying, active or failed.

@rookcom/monarch-react/patterns

Pattern

## Full flow (simulated checks)

## Usage examples

### Pending, with records and provider hints

### Failed with a reason

### Active

## Purpose

One flow for every product that serves a page on the customer’s own domain (support pages, booking pages, status pages, careers pages). It handles the steps, syntax checking, copyable records and status; the product runs the DNS checks. Import from `@rookcom/monarch-react/patterns`.

### Use when

- A customer points their own domain or subdomain at a page the product hosts.
- The product gives DNS records to add and can check them on request or on a timer.

### Avoid when

- Showing a single value to copy, such as an API key or webhook URL: use CopyField or SecretField.
- Picking a sub-path or slug on the product’s own domain: use an Input with a prefix.

## Design principles

- Four plain steps (Domain, DNS records, Verify, Live) shown with Stepper, so people know how far along they are.
- Mistakes are caught before the network: what people paste (https://, paths, ports, capitals, a trailing dot) is cleaned up, and anything still not a host name is explained.
- Every record value is a CopyField, so nothing is retyped by hand.
- No provider copy is built in. Menus at DNS providers change, so the product passes provider hints it maintains.
- Status is text and a badge, never colour alone; the failure reason is the product’s own words.

## API reference

```tsx
import { CustomDomainConnect, domainSyntaxError, normalizeDomain } from '@rookcom/monarch-react/patterns';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `CustomDomainConnect` | component | client | `.mn-custom-domain-connect` |
| `domainSyntaxError` | utility | client | – |
| `normalizeDomain` | utility | client | – |

Renders a `<section>` (ref) with `data-step` set to `enter`, `pending`, `verifying`, `active` or `failed`.

`onConnect(domain)` receives the normalised domain; reject with an Error to show its message under the field. `onVerify()` and `onRemove()` may return promises.

`domain` / `defaultDomain` / `onDomainChange` hold the connected domain (`null` before connecting and after removal). `status` comes from the product.

`normalizeDomain(input)` and `domainSyntaxError(input, { requireSubdomain })` are exported for server-side reuse.

Generated from the TypeScript declarations of `packages/react/src/patterns/technical/custom-domain-connect`.

## Properties

### `CustomDomainConnect`

Connects a custom domain in four steps: enter the domain (syntax checked in the browser), copy the DNS records, verify, and see the result: pending, verifying, active, or failed with a reason. The product runs every DNS check through `onConnect`, `onVerify` and `onRemove`, and supplies any provider-specific instructions.

| Prop | Type | Description |
| --- | --- | --- |
| `onConnect` required | `(domain: string) => unknown` | Registers the domain with the product (normalised and syntax-checked first). Return a promise to show a pending button; reject with an Error to show its message under the field. |
| `children` | `ReactNode` | Product content after the records, such as a replacement or redirect option. |
| `className` | `string` |  |
| `defaultDomain` | `string \| null` | Initial connected domain (uncontrolled). Default `null`. |
| `disabled` | `boolean` | Disables every action, for people who may only view the setup. |
| `domain` | `string \| null` | The connected domain (controlled), or `null` while none is connected. |
| `failureReason` | `ReactNode` | Why verification failed, in the product's words. Shown with `status="failed"`. |
| `headingLevel` | `3 \| 4 \| 6 \| 2 \| 5` | Level of the domain and records headings. Default 3. |
| `id` | `string` |  |
| `inputDescription` | `ReactNode` | Help under the domain field ("Use the address your visitors will type"). |
| `lastChecked` | `ReactNode` | When the domain was last checked, already formatted ("Checked 2 minutes ago"). Not announced. |
| `messages` | `CustomDomainMessagesInput` | Override visible and announced strings; nested maps may be partial. |
| `onDomainChange` | `((domain: string \| null) => void)` | Called with the domain after `onConnect` succeeds, and with `null` after removal. |
| `onRemove` | `(() => unknown)` | Disconnects the domain, after a confirmation dialog. Omit to hide Remove. |
| `onVerify` | `(() => unknown)` | Runs the product's DNS check. While its promise is pending the status shows "Verifying"; the product then sets `status`. Reject to show an error. |
| `providerHints` | `CustomDomainProviderHint[]` | Provider instructions written by the product; adds a "Where is your DNS managed?" picker. |
| `records` | `CustomDomainRecord[]` | The records to add, from the product. |
| `requireSubdomain` | `boolean` | Ask for a subdomain rather than an apex domain (heuristic: at least three labels). |
| `showRecordsWhenActive` | `boolean` | Keep showing the records once the domain is active. Default false. |
| `status` | `CustomDomainStatus` | Status of the connected domain, from the product's checks. Default `pending`. |
| `validate` | `((domain: string) => ReactNode \| null \| undefined)` | Extra product check on the normalised domain. Return a message to block connecting. |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `status` | `'pending'``'verifying'``'active'``'failed'` | Set by the product from its DNS checks. While an `onVerify` promise runs the component shows `verifying` on its own. |
| `requireSubdomain` | `'false'``'true'` | `true` asks for at least three labels (help.example.com). It cannot know public suffixes such as co.uk; add `validate` for exact rules. |
| `showRecordsWhenActive` | `'false'``'true'` | Keep the records visible after the domain is active, for reference. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Enter | `data-step="enter"` | No domain yet: the field and Connect button, step 1 current. |
| Invalid | `aria-invalid on the input` | Syntax, `validate` or `onConnect` error under the field; focus returns to it. |
| Connecting | `aria-busy on Connect` | While the `onConnect` promise runs. |
| Pending | `data-step="pending"` | Records to add, a “Waiting for DNS” badge and the Verify button. |
| Verifying | `data-step="verifying"` | Spinner, “Checking your DNS records.”, Verify busy. |
| Active | `data-step="active"` | Success text and badge; the stepper, records and Verify are hidden. |
| Failed | `data-step="failed"` | The Verify step shows an error, the product’s `failureReason` is an alert, and the button reads “Check again”. |
| Record found | `data-found="true\|false" on a record` | “Found” or “Not found yet” per record, from the product’s last check. |
| Disabled | – | Every action disabled, for people who may only view. |

## Interaction behaviour

- Connect normalises the text, checks syntax, runs `validate`, then calls `onConnect`. On success focus moves to the domain heading so screen-reader users hear the next step.
- Verify calls `onVerify`; Remove opens a ConfirmDialog and calls `onRemove`, then focus returns to the empty domain field.
- Choosing a provider shows that provider’s steps, written by the product.
- Polling is the product’s job: re-render with a new `status`, `records[].found` and `lastChecked` as checks complete.

## Keyboard support

| Keys | Action |
| --- | --- |
| Enter | In the domain field, connects (it is a form). |
| Tab | Moves through the field, copy buttons, provider picker and actions. |
| Enter / Space | Activates Connect, a copy button, Verify or Remove. |
| Escape | Closes the remove confirmation without removing. |

## Accessibility

Role

section (a region landmark only when given an aria-label)

Accessible name

None by default, so several on one page do not repeat a landmark; pass

aria-label

to make it a named region.

- The stepper is a nav named “Custom domain setup”, then “Setup of {domain}” once connected, with `aria-current="step"` and hidden “Step n of 4” text.
- The domain field is a required Field: its error is linked with `aria-describedby` and announced politely.
- The status sentence is a polite live region; “last checked” text sits outside it so a ticking time is not announced. Failure reasons are `role="alert"`.
- Each copy button is named after its record (“Copy CNAME record 1 value”); record type is read with a hidden “Type:” prefix.
- Remove is confirmed in an alertdialog whose focus starts on Cancel.
- Verified with axe in jsdom and in Chromium, Firefox and WebKit on /components/custom-domain-connect.

## Responsive behaviour

- The domain field and Connect button wrap; under 30rem Connect takes the full width.
- Record cards put Name and Value side by side, and stack them under 30rem. Long values scroll inside their CopyField instead of widening the page.
- The stepper collapses to “Step n of 4” in narrow containers.

## Theme support

- Monarch tokens only: surfaces from `--mn-color-bg-surface` and `--mn-color-bg-subtle`, found records outlined with `--mn-color-success-border`.
- Status badges use the warning, info, success and danger tones, so they follow light, dark and high-contrast themes.
- Spinners stop under `prefers-reduced-motion: reduce` (Spinner’s own rule).

## Do and don't

### Do

- Pass the reason a check failed in plain words, with what to change.
- Give record purposes (“Proves you own the domain”) so people know why each one is needed.
- Keep checking in the background and re-render with the new status; tell people they can leave the page.

### Don't

- Do not hard-code provider menus in shared code; pass them as `providerHints` from the product, where they can be kept current.
- Do not rely on client-side syntax checks for security; the server must validate again.
- Do not announce a ticking “checked x seconds ago” time; pass it as `lastChecked`, which is not live.

## Related components

- [CopyField](https://monarchui.dev/components/copy-field/): One value with a copy button; used for every record here.
- [Stepper](https://monarchui.dev/components/stepper/): The step indicator this pattern uses.
- [ConfirmDialog](https://monarchui.dev/components/confirm-dialog/): Confirms removal.
- [SecretField](https://monarchui.dev/components/secret-field/): For tokens that must be hidden until revealed.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [CopyField](https://monarchui.dev/components/copy-field/), [Badge](https://monarchui.dev/components/badge/), [Alert](https://monarchui.dev/components/alert/), [Spinner](https://monarchui.dev/components/spinner/), [Field](https://monarchui.dev/components/form-field/), [Input](https://monarchui.dev/components/text-field/), [ConfirmDialog](https://monarchui.dev/components/confirm-dialog/), [Stepper](https://monarchui.dev/components/stepper/) |

## Testing coverage

- `normalizeDomain` strips schemes, paths, ports and trailing dots, lowercases, and converts international names to punycode.
- `domainSyntaxError` rejects empty, single-label, underscore, hyphen-edged, empty-label, numeric-TLD, wildcard, over-long label and over-long names; `requireSubdomain`.
- Step 1 validates before `onConnect`, links the error and focuses the field; typing clears it; axe.
- Connects the normalised domain (uncontrolled), shows a busy button, focuses the domain heading, reports `onDomainChange`; axe.
- Rejected `onConnect` and product `validate` messages under the field.
- Records: type, purpose, TTL, found state, named Name and Value copy fields; axe.
- Verifying while `onVerify` runs, then active: stepper, records and Verify hidden.
- Failed: alert with the reason, error step, “Check again”, rejected verify message, `lastChecked` outside the live region; axe.
- Remove: confirm dialog, Cancel keeps the domain, confirm calls `onRemove` and returns focus to the field.
- Provider hints picker; disabled; translated messages; renders on the server.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/custom-domain-connect; Cross-browser axe: /components/custom-domain-connect |

Tests: `packages/react/src/patterns/technical/custom-domain-connect`

## All example code

### Full flow (simulated checks)

```tsx
import { useState } from 'react';
import { CustomDomainConnect, type CustomDomainRecord, type CustomDomainStatus } from '@rookcom/monarch-react/patterns';

const RECORDS: CustomDomainRecord[] = [
  {
    type: 'CNAME',
    name: 'help',
    value: 'pages.example.net',
    purpose: 'Points your address to the page',
  },
  {
    type: 'TXT',
    name: '_verify.help',
    value: 'verify=4f1c9a2e7b',
    purpose: 'Proves you own the domain',
    ttl: 'Auto',
  },
];

function FlowDemo() {
  const [status, setStatus] = useState<CustomDomainStatus>('pending');
  const [checks, setChecks] = useState(0);
  const wait = () => new Promise((r) => setTimeout(r, 900));
  return (
    <CustomDomainConnect
      requireSubdomain
      inputDescription="The address visitors will type, such as help.example.com."
      status={status}
      records={RECORDS.map((r, i) => (checks > 0 ? { ...r, found: i === 0 || checks > 1 } : r))}
      onConnect={wait}
      onVerify={async () => {
        await wait();
        setChecks((c) => c + 1);
        setStatus(checks > 0 ? 'active' : 'failed');
      }}
      failureReason="The TXT record was not found yet. DNS changes can take a while to appear."
      onRemove={async () => {
        await wait();
        setStatus('pending');
        setChecks(0);
      }}
      onDomainChange={(d) => {
        if (d === null) setStatus('pending');
      }}
    />
  );
}

<FlowDemo />
```

### Pending, with records and provider hints

```tsx
import { CustomDomainConnect, type CustomDomainRecord } from '@rookcom/monarch-react/patterns';

const RECORDS: CustomDomainRecord[] = [
  {
    type: 'CNAME',
    name: 'help',
    value: 'pages.example.net',
    purpose: 'Points your address to the page',
  },
  {
    type: 'TXT',
    name: '_verify.help',
    value: 'verify=4f1c9a2e7b',
    purpose: 'Proves you own the domain',
    ttl: 'Auto',
  },
];

<CustomDomainConnect
  defaultDomain="help.example.com"
  status="pending"
  onConnect={() => {}}
  onVerify={() => {}}
  onRemove={() => {}}
  records={[
    { ...(RECORDS[0] as CustomDomainRecord), found: true },
    { ...(RECORDS[1] as CustomDomainRecord), found: false },
  ]}
  lastChecked="Checked 2 minutes ago"
  providerHints={[
    {
      id: 'generic',
      label: 'Most providers',
      steps: [
        'Open the DNS settings for example.com.',
        'Add each record above with its exact type, name and value.',
        'Save, then come back and verify.',
      ],
    },
  ]}
/>
```

### Failed with a reason

```tsx
import { CustomDomainConnect, type CustomDomainRecord } from '@rookcom/monarch-react/patterns';

const RECORDS: CustomDomainRecord[] = [
  {
    type: 'CNAME',
    name: 'help',
    value: 'pages.example.net',
    purpose: 'Points your address to the page',
  },
  {
    type: 'TXT',
    name: '_verify.help',
    value: 'verify=4f1c9a2e7b',
    purpose: 'Proves you own the domain',
    ttl: 'Auto',
  },
];

<CustomDomainConnect
  defaultDomain="status.example.org"
  status="failed"
  failureReason="status.example.org points to another host. Change the CNAME value to pages.example.net."
  onConnect={() => {}}
  onVerify={() => {}}
  records={RECORDS.slice(0, 1).map((r) => ({ ...r, name: 'status', found: false }))}
/>
```

### Active

```tsx
import { CustomDomainConnect, type CustomDomainRecord } from '@rookcom/monarch-react/patterns';

const RECORDS: CustomDomainRecord[] = [
  {
    type: 'CNAME',
    name: 'help',
    value: 'pages.example.net',
    purpose: 'Points your address to the page',
  },
  {
    type: 'TXT',
    name: '_verify.help',
    value: 'verify=4f1c9a2e7b',
    purpose: 'Proves you own the domain',
    ttl: 'Auto',
  },
];

<CustomDomainConnect
  defaultDomain="book.example.com"
  status="active"
  onConnect={() => {}}
  onRemove={() => {}}
  records={RECORDS}
/>
```
