Skip to content

Components / Sections

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

tone
density
media

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

Limited availability

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:

terminal
npx shadcn@latest add @moonarc-pro/contact-split
src/pages/index.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. -->

Without a key the registry answers 401 with an upgrade message. Everything this section composes is free: check-draw, stepper, stateful-button.

Props

PropTypeDefaultDescription
headlinestringnoneHeadline; required unless the headline slot is filled.
eyebrowstringnoneShort line above the headline. Slot "eyebrow" wins.
copystringnoneSupporting copy. Slot "copy" wins.
level1 | 21Heading 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.
sentbooleanfalseRender the confirmation (sentHeadline, sentCopy) in place of the form: the page a native post lands on. No action is needed then.
sentHeadlinestring'Message sent'Heading of the confirmation. Slot "sent-headline" wins and may hold an <em>.
sentCopystring'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 requiredThe 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[] }[]noneThe 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? }noneThe Stepper's words: its two buttons and the line a screen reader hears on a new step.
optionalLabelstring'optional'Text in parentheses after the label of a field that is not required.
consentstringnoneA required CheckDraw inside the form, named consent (value yes), with this text as its label; off by default.
privacystringnoneURL of the privacy policy, linked under the form.
privacyLabelstring'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.
actionstringnoneWhere the form posts without JavaScript. Required unless demo or sent; the build fails without it.
endpointstringnoneWhere 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.
successstringnoneA 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.
honeypotstringnoneName of a honeypot field: rendered display:none, tabindex -1, autocomplete off. A filled one marks a bot for your service.
formAttrsRecord<string, string | number | boolean>noneExtra 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 textsThe 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.
demobooleanfalseA 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.
demoResponseRecord<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

Replaces: Contact sections (Tailwind Plus) · Contact (shadcnblocks) · Contact forms (Framer templates). See the migration table.

a native select · options stagger in

A native select with an animated picker.

Chrome 138 · not Firefox · Safari 27click

a tick that draws · one indeterminate

A checkbox whose tick is drawn.

every browserclick

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.

every browserclick
What is it for?
When suits you?
How do we reach you?

A form, or any flow, in steps: a rail of the steps' names over the step in hand, with Back and Next under it.

every browserclick

a checkbox underneath · Space flips it

A toggle that springs.

every browserclick

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.

newly · Chrome 125 · Firefox 128 · Safari 16.4loadclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown