Patterns · Technical infrastructure
CustomDomainConnect
Connect a custom domain: enter it, copy the DNS records, verify, and see pending, verifying, active or failed.
Full flow (simulated checks)
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 />Usage examples
Pending, with records and provider hints
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
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
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}
/>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
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 callsonConnect. On success focus moves to the domain heading so screen-reader users hear the next step. - Verify calls
onVerify; Remove opens a ConfirmDialog and callsonRemove, 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[].foundandlastCheckedas 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-labelto 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-describedbyand 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-surfaceand--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
providerHintsfrom 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: One value with a copy button; used for every record here.
- Stepper: The step indicator this pattern uses.
- ConfirmDialog: Confirms removal.
- SecretField: For tokens that must be hidden until revealed.
Dependencies
Testing coverage
normalizeDomainstrips schemes, paths, ports and trailing dots, lowercases, and converts international names to punycode.domainSyntaxErrorrejects 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
onConnectand productvalidatemessages under the field. - Records: type, purpose, TTL, found state, named Name and Value copy fields; axe.
- Verifying while
onVerifyruns, then active: stepper, records and Verify hidden. - Failed: alert with the reason, error step, “Check again”, rejected verify message,
lastCheckedoutside the live region; axe. - Remove: confirm dialog, Cancel keeps the domain, confirm calls
onRemoveand 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