a native select · options stagger in
Contact Split
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.
Live demo
Contact · sample page
Write to the team.
Sample channels on the left. The form on the right is real and sends nothing here: fill it in and watch the button.
- Email (sample)
- hello@studio.example
- Phone (sample)
- +1 555 0100
- Office (sample)
- 1 Sample Street, Springfield
Demo: nothing is sent.
Live, from the library itself, at full size: scroll, hover, navigate away and back. The demo is never gated.
Variants · each row writes the attribute the prop writes; the section's own CSS answers
Measured
JavaScript of its own
43 B raw
63 B gzip.
Uses form and the shared runtime (1.9 kB raw, once per site). With those included: 4.7 kB raw.
CSS 32.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 135 · not Firefox · Safari 27; needs base-select, user-invalid, has. 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..
Install Pro
The demo above is never gated; the source is. With a key in components.json (see account), the registry serves it like any other item:
npx shadcn@latest add @moonarc-pro/contact-split---
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. -->Without a key the registry answers 401 with an upgrade message. Everything this section composes is free: check-draw, stepper, stateful-button.
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. |
Theming and overrides
The section reads the theme contract (shadcn's variable names with the base tokens as fallback), so a pasted theme or one --ma-brand recolours it. Its class names are the override surface and stay stable; a named slot replaces the matching prop with your own markup.
Classes
.ma-aselect.ma-aselect__label.ma-aselect__select.ma-contact.ma-contact__actions.ma-contact__aside.ma-contact__channel.ma-contact__channel-label.ma-contact__channel-value.ma-contact__channels.ma-contact__chip.ma-contact__chips.ma-contact__consent.ma-contact__copy.ma-contact__demo.ma-contact__eyebrow.ma-contact__field.ma-contact__field--switch.ma-contact__fine.ma-contact__form.ma-contact__group.ma-contact__head.ma-contact__headline.ma-contact__info.ma-contact__inner.ma-contact__input.ma-contact__label.ma-contact__legend.ma-contact__panel.ma-contact__privacy.ma-contact__sent.ma-contact__sent-copy.ma-contact__sent-title.ma-contact__status.ma-contact__step.ma-contact__stepper.ma-contact__submit.ma-contact__textarea.ma-contact__tick
Slots
slot="eyebrow"slot="headline"slot="copy"slot="aside"slot="actions"slot="fields"
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.
Why it is built this way
- 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). See the migration table.
Related
a tick that draws · one indeterminate
idle → pending → done · one attribute
One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.
Stepper
UIA form, or any flow, in steps: a rail of the steps' names over the step in hand, with Back and Next under it.
a checkbox underneath · Space flips it
Waitlist · private beta
Doors open soon.
One email when they do. Type it in and watch it go. The form underneath is real.
Opens
Demo: nothing is sent.
A pre-launch hero with a waitlist form: a flip countdown over a retro grid floor, an email field whose text blurs away on submit, a status line that says what happened, and the people already waiting.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown