# Contact Split (Moonarc)

A contact section in two columns: the ways to reach you (email, phone, office) on one side, a form that really sends on the other. The fields are data (text, email, phone, a message, a select and a switch, in your order) with an optional consent box. It is a real form first (method="post" to your action, labelled, validated by the browser), so it sends with JavaScript off. One small script binds it with the core form lib for the fetch, the pending lock, the spoken status and the button's faces. `sent` renders the confirmation in the form's place, for the page a native post lands on.

- Pro block. Install: `npx shadcn@latest add @moonarc-pro/contact-split` (license key required)
- Tier B · category section · trigger click
- Readout: `<ContactSplit channels={[…]} fields={[…]} action="/api/contact">`
- Browser support: limited (Chrome 135 · not Firefox · Safari 27); elsewhere: Firefox, and Safari before 27, show the native select in the token border instead of the styled picker; the form sends the same in every engine.
- Measured cost: 43 B raw JS · 63 B gzip · + form + runtime (with dependencies 4.7 kB raw; CSS 32.6 kB raw)
- Page: https://moonarc.dev/components/contact-split/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `headline` | `string` | none | Headline; required unless the headline slot is filled. |
| `eyebrow` | `string` | none | Short line above the headline. Slot "eyebrow" wins. |
| `copy` | `string` | none | Supporting copy. Slot "copy" wins. |
| `level` | `1 | 2` | `1` | Heading level of the headline: 1 on the contact page itself, 2 under another h1 (a demo). The sent block's heading is one level below. |
| `channels` | `{ label, value, href? }[]` | `[]` | Ways to reach you, as a description list beside the form: an email with a mailto: link, a phone with a tel: link, an office with a map link or none. |
| `media` | `'right' | 'left'` | `'right'` | The form's side from 56 rem of the section's width: the inline end (right in a left-to-right page) or the start. Below that, the channels come first and the form under them. |
| `sent` | `boolean` | `false` | Render the confirmation (sentHeadline, sentCopy) in place of the form: the page a native post lands on. No action is needed then. |
| `sentHeadline` | `string` | `'Message sent'` | Heading of the confirmation. Slot "sent-headline" wins and may hold an <em>. |
| `sentCopy` | `string` | `'Thanks for writing. The reply comes to the email address you gave.'` | Text of the confirmation. |
| `fields` | `{ name, label, type: 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'switch' | 'date' | 'choice' | 'choices', required?, autocomplete?, options?, placeholder? }[]` | `name, email and message, all required` | The controls, in order. Each has a visible <label>; a field that is not required says so after its label. select is an AnimatedSelect (options as strings or { value, label }; placeholder is its first, empty choice), switch a Switch that sends yes when on, textarea the section's own message box, date the browser's date field, choice one of its options and choices any of them, both as chips over native radios and checkboxes. A duplicate name, or a select, a choice or choices without options, fails the build. |
| `steps` | `{ label, legend?, fields: string[] }[]` | none | The form in steps through Stepper: each a name on the rail, a legend over its fields and the names of the fields it holds. Every field sits in exactly one step, or the build stops; the consent box goes in the last. Without JavaScript the steps read as one form. |
| `stepWords` | `{ back?, next?, step? }` | none | The Stepper's words: its two buttons and the line a screen reader hears on a new step. |
| `optionalLabel` | `string` | `'optional'` | Text in parentheses after the label of a field that is not required. |
| `consent` | `string` | none | A required CheckDraw inside the form, named consent (value yes), with this text as its label; off by default. |
| `privacy` | `string` | none | URL of the privacy policy, linked under the form. |
| `privacyLabel` | `string` | `'Privacy policy'` | Text of that link. |
| `submitLabels` | `{ idle?, pending?, done?, error? }` | `'Send message' · 'Sending' · 'Sent' · 'Try again'` | The submit button's four faces (StatefulButton). The face follows the form's state, and the face that shows is the button's name. |
| `action` | `string` | none | Where the form posts without JavaScript. Required unless demo or sent; the build fails without it. |
| `endpoint` | `string` | none | Where the lib posts with JavaScript (data-endpoint); unset, the action. |
| `method` | `'post'` | `'post'` | Only post: nothing a reader types goes in the URL. Any other value fails the build with that rule in the message. |
| `encode` | `'multipart' | 'urlencoded' | 'json'` | `'multipart'` | Body of the fetch (data-encode). Every control with a name is in it: the select's value, the switch and the consent box as yes when they are on. |
| `success` | `string` | none | A same-origin page to go to after success (data-success). Unset, the form stays, says it is done, and the button shows its done face. |
| `honeypot` | `string` | none | Name of a honeypot field: rendered display:none, tabindex -1, autocomplete off. A filled one marks a bot for your service. |
| `formAttrs` | `Record<string, string | number | boolean>` | none | Extra attributes on the <form>: a service's flag (data-netlify), name, class, id. The attributes the section writes (action, method, data-ma-form*, data-endpoint, data-encode, data-success, data-msg-*) fail the build here. |
| `messages` | `{ pending?, done?, exists?, error?, 429?, offline?, demo? }` | `English texts` | The status line's texts (data-msg-*): 'Sending…', 'Thanks. Your message is on its way.', an exists, an error, a 429 and an offline text; demo is the visible note of a demo form, 'Demo: nothing is sent.' A service's own error text is never shown: it cannot be translated. |
| `demo` | `boolean` | `false` | A form that sends nothing: method="dialog" (goes nowhere outside a <dialog>, even without JavaScript), data-ma-form-demo with the sample response, and a visible note from messages.demo. |
| `demoResponse` | `Record<string, unknown>` | `{}` | What a demo form answers with; { ok: false } answers as a failure, which shows the error face and text. |
| `tone` | `'surface' | 'muted' | 'ink' | 'brand'` | `unset (paints nothing)` | The ground: the page surface, a muted band, an always-dark ink panel, or a deep brand panel. Each paints --background, --ma-tone-muted (over --muted, with a --border hairline), --ma-tone-ink or --ma-tone-brand (over --primary) with the matching text. With theme.css, a painted tone also re-declares --card, --border, --input and --ring inside the section. The form's plate takes the same ground and the submit inverts on ink and brand. |
| `density` | `'compact' | 'cozy' | 'roomy'` | `'cozy'` | Block padding and the main gaps ×0.8 / ×1 / ×1.25 through --ma-density. |

## Usage

```astro
---
import ContactSplit from '@/components/moonarc/pro/ContactSplit.astro';
---
<!-- Without JavaScript the form posts to action and your endpoint answers with a 303 to the page that renders
     <ContactSplit sent … />. With it, the section's script (bindForms() from the core form lib) sends the same fields
     with fetch, and the form stays and says it is done. -->
<ContactSplit
  eyebrow="Contact"
  headline="Talk to the team"
  copy="Questions about plans, a demo, or a bug: write, and a person answers."
  channels={[
    { label: 'Email', value: 'hello@example.com', href: 'mailto:hello@example.com' },
    { label: 'Phone', value: '+1 555 0100', href: 'tel:+15550100' },
    { label: 'Office', value: '1 Example Street, Springfield' },
  ]}
  fields={[
    { name: 'name', label: 'Name', type: 'text', required: true, autocomplete: 'name' },
    { name: 'email', label: 'Email', type: 'email', required: true, autocomplete: 'email' },
    { name: 'topic', label: 'Topic', type: 'select', required: true, placeholder: 'Choose one', options: ['Sales', 'Support', 'Press'] },
    { name: 'message', label: 'Message', type: 'textarea', required: true },
    { name: 'updates', label: 'Send me product updates', type: 'switch' },
  ]}
  consent="I agree to be contacted about this message."
  privacy="/privacy/"
  action="/api/contact"
  encode="json"
  honeypot="website"
/>

<!-- the page a native post lands on (noindex): the same section, the confirmation in place of the form -->
<ContactSplit headline="Talk to the team" sent sentHeadline="Message sent" />

<!-- A service that reads its own attributes and hidden inputs gets them passed through: action="/contact/sent/"
     endpoint="/" encode="urlencoded" formAttrs={{ name: 'contact', 'data-netlify': 'true' }}, and in the fields slot
     <input slot="fields" type="hidden" name="form-name" value="contact" />.
     While no service is set up, demo instead of action: nothing is sent, and a note under the form says so. -->
```

## Reduced motion

The button's faces change in place, the switch's thumb and the checkbox's tick land without travel, the select opens without its bloom, and the sent tick is drawn from the start. The form sends the same.

## With ClientRouter

The script calls bindForms(), which binds every form[data-ma-form] through the runtime's onMount. A form that arrives by a ClientRouter navigation is bound fresh, and a second call (another section, a layout) is a no-op. Leaving for success is a full navigation.

## Craft

- Every control is a native one with a <label>: the select is a real <select>, the switch a checkbox with role="switch", the consent a checkbox. So the request carries the same fields with and without JavaScript, in every encoding.
- The status line exists at load, is never hidden, and reserves one line (min-block-size: 1lh): a live region that appears with its text is not announced. The button is not a live region; its face follows the form's data-pending and data-state and is its name.
- The button goes aria-disabled while pending, never disabled, so the focus stays on it. After a failure every value is still in its field, and the focus goes to the first field that held text.
- The fields' border is the tone's text at 60 % over the plate and their focus ring is the text itself, measured at 3 : 1 or more on every tone. The theme's --input and --ring are greys made for a white card. The :user-invalid border mixes the theme's destructive colour half and half with the text for the same reason.
- Every text field is 16 px or larger, so a phone never zooms the page when one is focused.
- The native path lands on a page you render with sent: your endpoint answers the post with a 303 to it (Netlify posts to it directly). The form never navigates after a fetch unless you set success.

## Replaces

- Contact sections (Tailwind Plus)
- Contact (shadcnblocks)
- Contact forms (Framer templates)

## Source

Pro block. The source is served by the license-gated registry: `npx shadcn@latest add @moonarc-pro/contact-split` with a key in components.json (https://moonarc.dev/account/). Everything it composes is free: check-draw, stepper, stateful-button.
