The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Contact split form"
Library page: https://astro.baysixmedia.com/components/contact-split-form/
Files: 1 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/ContactSplitForm.astro =====
---
/**
* Contact split form
*
* A contact section split down the middle: a heading, a line of reach-us copy
* and a real form on one side, a full-bleed image on the other. Below the
* two-column breakpoint the image drops out entirely and the form takes the
* page.
*
* Static: no script at all. Every control is a native form element, so
* validation, keyboard behaviour and submission are the browser's, and the
* whole thing works with JavaScript switched off.
*
* Self-contained: no imports, no packages, no global stylesheet, no webfont.
* Every colour and measurement is a `--csf-*` custom property on the root.
* The image is the only file it reaches for, and it is a prop.
*/
export interface ContactCountry {
/** Shown in the closed select — "US", "GB". */
code: string;
/** Submitted as the value. Usually the dial code. */
value: string;
/** The option's full text. Falls back to `code`. */
label?: string;
}
export interface ContactLabels {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
country?: string;
message?: string;
}
export interface ContactPlaceholders {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
message?: string;
}
interface Props {
/** The section heading. Unset: none. */
heading?: string;
/** Heading level, so it fits the page outline. */
headingLevel?: "h1" | "h2" | "h3";
/** The words before the email address. `""` drops the whole line. */
ledeText?: string;
/** The address in that line. `""` leaves the text with no link. */
email?: string;
/**
* Where the form posts. Left unset the form has no action, which means the
* browser submits to the current URL — fine for a demo, wrong for a site.
* Point it at a handler.
*/
action?: string;
/** `post` unless you have a reason. */
method?: "get" | "post";
/** Added to the <form>, for handlers that route on it. */
formName?: string;
/** The submit button. */
submitLabel?: string;
/** Visible labels for the five fields. */
labels?: ContactLabels;
/** Placeholder text. Placeholders are hints, never labels. */
placeholders?: ContactPlaceholders;
/** The checkbox group's legend. `""` drops the group. */
servicesLegend?: string;
/** The checkboxes. Each becomes a `service` value on submit. Unset or `[]`: no group. */
services?: string[];
/** Show the phone row at all. */
showPhone?: boolean;
/**
* The dial-code select. `[]` renders the phone field without one.
*
* Option text defaults to `code value` — "US +1" — because a native
* select shows the SELECTED option's text when closed, so whatever is in
* the list is also what sits in the field. The reference shows a bare
* "US" there and full country names in its list, which only a custom
* listbox can do, and a custom listbox is a keyboard widget to build and
* maintain. Pass `label` for anything else.
*/
countries?: ContactCountry[];
/**
* The image beside the form. Empty by default, which is the centred
* single-column form; pass one and the section becomes the two-column
* split it was measured from.
*/
image?: string;
/**
* The image's aspect ratio, as a CSS `aspect-ratio` value. The default
* gives it a shape of its own and centres it in the column; `"auto"`
* restores the reference's behaviour, where the image is full-bleed and
* takes whatever height the form column happens to be.
*/
imageRatio?: string;
/**
* Description of the image. Left empty it is marked decorative, which is
* right for a mood shot beside a form — it carries no information the
* copy does not.
*/
imageAlt?: string;
/** The section surface. */
background?: string;
/** Heading and input text. */
ink?: string;
/** Lede, placeholders, legend. */
mutedInk?: string;
/** Field labels. */
labelInk?: string;
/** Input rings, checkbox edges, the whole hairline system. */
borderColor?: string;
/** Button fill, link colour, focus ring, required marks. */
accent?: string;
/** Text on the button. */
accentInk?: string;
/** Where the form column stops widening. */
maxWidth?: string;
/** Added to the root. */
class?: string;
/** Root element id. Also seeds every generated id. */
id?: string;
}
const {
/* No default heading or service list: both describe a particular business. The demo passes them. */
heading = "",
headingLevel = "h2",
ledeText = "You can reach us any time at",
/* No default address: an email is a fact about a real business. The demo passes one. */
email = "",
action,
method = "post",
formName,
submitLabel = "Start the conversation",
labels = {},
placeholders = {},
servicesLegend = "What do you need?",
services = [],
showPhone = true,
countries = [
{ code: "US", value: "+1" },
{ code: "CA", value: "+1" },
{ code: "GB", value: "+44" },
{ code: "IE", value: "+353" },
{ code: "AU", value: "+61" },
{ code: "NZ", value: "+64" },
{ code: "DE", value: "+49" },
{ code: "FR", value: "+33" },
{ code: "ES", value: "+34" },
{ code: "MX", value: "+52" },
],
image = "",
imageRatio = "5 / 4",
imageAlt = "",
background = "#ffffff",
ink = "#101828",
mutedInk = "#535862",
labelInk = "#414651",
borderColor = "#d5d7da",
accent = "#7f56d9",
accentInk = "#ffffff",
maxWidth = "480px",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
/*
* Every control needs an id its label can point at, and two of these on one
* page must not collide. Derived from `id` when the caller gives one, random
* otherwise — an index-derived id would collide on the second instance.
*/
const uid = id ?? `csf-${Math.random().toString(36).slice(2, 9)}`;
/* `uid` can arrive from the `id` prop, which is caller data, and it goes into
id and `for` attributes. Anything that is not an identifier character is
stripped before it gets there. */
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "") || "csf";
const fid = (name: string) => `${safeUid}-${name}`;
const L: Required<ContactLabels> = {
firstName: "First name",
lastName: "Last name",
email: "Email",
phone: "Phone number",
country: "Country dialling code",
message: "Message",
...labels,
};
const P: ContactPlaceholders = {
firstName: "First name",
lastName: "Last name",
email: "you@company.com",
phone: "(000) 000-0000",
message: "Tell us what you are working on…",
...placeholders,
};
/* Only schemes that resolve to an image. The src often comes from a content
record rather than from the person writing the template. */
const SAFE_SRC = /^(?:https?:|data:image\/|[#/.])/i;
const safeSrc = (value?: string) =>
value && SAFE_SRC.test(value.trim()) ? value.trim() : undefined;
const picture = safeSrc(image);
const mail = email?.trim();
const serviceList = (services ?? []).filter(Boolean);
const dialCodes = (countries ?? []).filter((c) => c?.code && c?.value);
const style = [
`--csf-bg:${background}`,
`--csf-ink:${ink}`,
`--csf-muted:${mutedInk}`,
`--csf-label:${labelInk}`,
`--csf-border:${borderColor}`,
`--csf-accent:${accent}`,
`--csf-accent-ink:${accentInk}`,
`--csf-max:${maxWidth}`,
`--csf-image-ratio:${imageRatio}`,
].join(";");
---
<section
class:list={["contact-split-form", className]}
id={id}
style={style}
data-contact-split-form
data-has-image={picture ? "" : undefined}
>
<div class="csf-pane">
<div class="csf-inner">
{heading && <Heading class="csf-heading">{heading}</Heading>}
{/* The lede leads into the address, so it renders only with one: no invented contact line. */}
{mail && (
<p class="csf-lede">
{ledeText}{" "}
{mail && <a class="csf-mail" href={`mailto:${mail}`}>{mail}</a>}
</p>
)}
<form class="csf-form" action={action} method={method} name={formName}>
<div class="csf-row">
<div class="csf-field">
<label class="csf-label" for={fid("first")}>
{L.firstName}
{/* `required` on the input is what a screen reader announces.
The mark is for sighted readers, so it is hidden from the
tree rather than read out as "star". */}
<span class="csf-req" aria-hidden="true">*</span>
</label>
<div class="csf-control">
<input
class="csf-input"
type="text"
id={fid("first")}
name="firstName"
autocomplete="given-name"
placeholder={P.firstName}
required
/>
</div>
</div>
<div class="csf-field">
<label class="csf-label" for={fid("last")}>
{L.lastName}
<span class="csf-req" aria-hidden="true">*</span>
</label>
<div class="csf-control">
<input
class="csf-input"
type="text"
id={fid("last")}
name="lastName"
autocomplete="family-name"
placeholder={P.lastName}
required
/>
</div>
</div>
</div>
<div class="csf-field">
<label class="csf-label" for={fid("email")}>
{L.email}
<span class="csf-req" aria-hidden="true">*</span>
</label>
<div class="csf-control">
<input
class="csf-input"
type="email"
id={fid("email")}
name="email"
autocomplete="email"
placeholder={P.email}
required
/>
</div>
</div>
{showPhone && (
<div class="csf-field">
<label class="csf-label" for={fid("phone")}>{L.phone}</label>
<div class="csf-control csf-control--split">
{dialCodes.length > 0 && (
<>
{/* A real label, hidden. The select sits inside the
phone field's box but it is its own control and
needs its own name. */}
<label class="csf-a11y" for={fid("dial")}>{L.country}</label>
<select class="csf-select" id={fid("dial")} name="countryCode">
{dialCodes.map((c) => (
<option value={c.value}>{c.label ?? `${c.code} ${c.value}`}</option>
))}
</select>
</>
)}
<input
class="csf-input"
type="tel"
id={fid("phone")}
name="phone"
autocomplete="tel"
placeholder={P.phone}
/>
</div>
</div>
)}
<div class="csf-field">
<label class="csf-label" for={fid("message")}>
{L.message}
<span class="csf-req" aria-hidden="true">*</span>
</label>
<div class="csf-control csf-control--area">
<textarea
class="csf-input csf-textarea"
id={fid("message")}
name="message"
rows="5"
placeholder={P.message}
required></textarea>
</div>
</div>
{servicesLegend && serviceList.length > 0 && (
/* A real fieldset with a real legend, which is what groups
checkboxes for assistive technology. */
<fieldset class="csf-fieldset">
<legend class="csf-legend">{servicesLegend}</legend>
<div class="csf-services">
{serviceList.map((service, i) => (
<div class="csf-check">
<input
class="csf-checkbox"
type="checkbox"
id={fid(`service-${i}`)}
name="services"
value={service}
/>
<label class="csf-check-label" for={fid(`service-${i}`)}>{service}</label>
</div>
))}
</div>
</fieldset>
)}
<button class="csf-submit" type="submit">{submitLabel}</button>
</form>
</div>
</div>
{picture && (
<div class="csf-media">
<img
class="csf-image"
src={picture}
alt={imageAlt}
width="1200"
height="960"
loading="lazy"
decoding="async"
/>
</div>
)}
</section>
<style>
.contact-split-form {
--csf-pad-block: 64px;
--csf-pad-inline: 16px;
/*
* Size, leading and tracking all step together, because the reference
* changes all three: 30px on 38px with normal tracking below the
* breakpoint, 36px on 44px at -0.02em above it. One ratio cannot
* express both — measured 43.92px against its 44px when it was tried.
*/
--csf-heading-size: 30px;
--csf-heading-line: 38px;
--csf-heading-track: normal;
--csf-radius: 8px;
--csf-control-h: 44px;
--csf-duration: 150ms;
--csf-ease: cubic-bezier(0.4, 0, 0.2, 1);
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr);
inline-size: 100%;
background: var(--csf-bg);
color: var(--csf-ink);
/* A system stack on purpose: a component in this library fetches
nothing, so it can never be waiting on a webfont. */
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif;
}
.contact-split-form *,
.contact-split-form *::before,
.contact-split-form *::after {
box-sizing: border-box;
}
/*
* A scoped reset, and it earns its place. Form controls do not inherit
* type from their ancestors, and a page that styles `input`, `label`,
* `fieldset` or `button` globally is the normal case rather than the
* exception — a contact section lands in exactly those pages.
*
* `:where()` keeps this at the specificity of the root alone, so the rules
* underneath still win on source order while a host's element selector
* loses. The `:global()` is load-bearing: Astro cannot put its scope
* attribute inside `:where()`, so without it the compiler emits a second
* attribute beside it and the whole thing outranks the class rules.
*/
.contact-split-form
:global(:where(h1, h2, h3, p, a, label, legend, input, select, textarea, button, fieldset, form, div)) {
margin: 0;
padding: 0;
border: 0;
font: inherit;
color: inherit;
letter-spacing: inherit;
background: none;
}
.csf-pane {
padding: var(--csf-pad-block) var(--csf-pad-inline);
}
/*
* Centred in the pane, which is what makes the imageless form sit in the
* middle of the page rather than hard against the left. With an image the
* pane is one half of the grid and this centres the form inside that half,
* which is where the reference puts it.
*/
.csf-inner {
margin-inline: auto;
}
/* ---- header --------------------------------------------------------- */
.csf-heading {
font-size: var(--csf-heading-size);
line-height: var(--csf-heading-line);
font-weight: 600;
letter-spacing: var(--csf-heading-track);
color: var(--csf-ink);
text-wrap: balance;
}
.csf-lede {
margin-block-start: 20px;
font-size: 20px;
line-height: 1.5;
color: var(--csf-muted);
}
.csf-mail {
/* A step darker than the button. The reference uses two brand values —
#6941c6 for the link against #7f56d9 for the fill — rather than one,
and a link at the fill's lightness is thin against white. Derived
rather than exposed, so there is still only one accent to set. */
color: color-mix(in srgb, var(--csf-accent) 84%, #000);
font-weight: 500;
text-decoration: none;
}
.csf-mail:hover {
text-decoration: underline;
}
/* ---- form ----------------------------------------------------------- */
.csf-form {
display: flex;
flex-direction: column;
gap: 32px;
margin-block-start: 48px;
}
.csf-row {
display: flex;
flex-direction: column;
gap: 24px 32px;
}
.csf-field {
display: flex;
flex-direction: column;
gap: 6px;
/* So a field in a flex row can shrink instead of forcing the row open. */
min-inline-size: 0;
flex: 1 1 0;
}
.csf-label {
font-size: 14px;
line-height: 1.43;
font-weight: 500;
color: var(--csf-label);
}
.csf-req {
color: var(--csf-accent);
}
/*
* The ring is an inset shadow rather than a border, which is how the
* reference draws it, and it keeps the control's box exactly 44px however
* thick the ring gets.
*/
.csf-control {
display: flex;
align-items: stretch;
min-block-size: var(--csf-control-h);
border-radius: var(--csf-radius);
background: #fff;
box-shadow:
inset 0 0 0 1px var(--csf-border),
0 1px 2px rgb(16 24 40 / 0.05);
transition: box-shadow var(--csf-duration) var(--csf-ease);
}
.csf-control--area {
min-block-size: 0;
}
.csf-input {
inline-size: 100%;
min-inline-size: 0;
padding: 10px 14px;
border-radius: inherit;
background: transparent;
color: var(--csf-ink);
/* 16px, and not for looks: anything smaller makes iOS Safari zoom the
page when the field takes focus. */
font-size: 16px;
line-height: 1.5;
}
.csf-input::placeholder {
color: var(--csf-muted);
opacity: 1;
}
/*
* The focus indicator is an outline rather than the reference's inset
* shadow. It looks the same drawn one pixel inside the box, and unlike a
* shadow it survives a forced palette — where a shadow simply is not
* painted and the field would have no focus indicator at all.
*/
.csf-input:focus,
.csf-select:focus {
outline: none;
}
.csf-control:focus-within {
box-shadow:
inset 0 0 0 1px var(--csf-accent),
0 1px 2px rgb(16 24 40 / 0.05);
}
.csf-input:focus-visible,
.csf-select:focus-visible {
outline: 2px solid var(--csf-accent);
outline-offset: -2px;
border-radius: inherit;
}
.csf-textarea {
min-block-size: 144px;
/* Vertical only: a horizontal drag can push a textarea past its column
and take the page's width with it. */
resize: vertical;
}
/* ---- the phone composite -------------------------------------------- */
.csf-select {
flex: 0 0 auto;
padding: 10px 8px 10px 14px;
border-start-start-radius: var(--csf-radius);
border-end-start-radius: var(--csf-radius);
color: var(--csf-ink);
font-size: 16px;
line-height: 1.5;
cursor: pointer;
/* The UA arrow is kept. Drawing a replacement means hiding the real
one, and a select that has lost its affordance is worse than one
whose arrow is the platform's. */
}
.csf-control--split .csf-input {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
/* ---- services -------------------------------------------------------- */
.csf-fieldset {
min-inline-size: 0;
}
.csf-legend {
/* `display: contents` would drop it from the tree. A legend positioned
normally is what names the group. */
margin-block-end: 16px;
font-size: 14px;
line-height: 1.43;
font-weight: 500;
color: var(--csf-label);
}
.csf-services {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px 64px;
}
.csf-check {
display: flex;
align-items: center;
gap: 12px;
min-inline-size: 0;
}
/*
* A real checkbox, restyled rather than replaced. `appearance: none` keeps
* the element — its focus behaviour, its label association, its place in
* the form — and only takes away the platform's painting. The tick is a
* background image rather than a pseudo-element, because a pseudo-element
* on a replaced element is not reliable across engines.
*/
.csf-checkbox {
flex: 0 0 auto;
appearance: none;
-webkit-appearance: none;
inline-size: 20px;
block-size: 20px;
border-radius: 6px;
background: #fff;
box-shadow: inset 0 0 0 1px var(--csf-border);
cursor: pointer;
transition:
background-color var(--csf-duration) var(--csf-ease),
box-shadow var(--csf-duration) var(--csf-ease);
}
.csf-checkbox:checked {
background-color: var(--csf-accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3.5 8.5 3 3 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: 16px 16px;
box-shadow: inset 0 0 0 1px var(--csf-accent);
}
.csf-checkbox:focus-visible {
outline: 2px solid var(--csf-accent);
outline-offset: 2px;
}
.csf-check-label {
font-size: 16px;
line-height: 1.5;
color: var(--csf-ink);
cursor: pointer;
}
/* ---- submit ---------------------------------------------------------- */
.csf-submit {
inline-size: 100%;
padding: 12px 18px;
border-radius: var(--csf-radius);
background: var(--csf-accent);
color: var(--csf-accent-ink);
font-size: 16px;
line-height: 1.5;
font-weight: 600;
cursor: pointer;
box-shadow:
inset 0 0 0 1px rgb(16 24 40 / 0.18),
inset 0 -2px 0 rgb(16 24 40 / 0.05),
0 1px 2px rgb(16 24 40 / 0.05);
transition: background-color var(--csf-duration) var(--csf-ease);
}
.csf-submit:hover {
background: color-mix(in srgb, var(--csf-accent) 88%, #000);
}
.csf-submit:focus-visible {
outline: 2px solid var(--csf-accent);
outline-offset: 2px;
}
/* ---- media ----------------------------------------------------------- */
/*
* Not rendered at all below the two-column breakpoint rather than hidden
* with CSS: the reference sets `display: none`, which still downloads the
* image on some engines. Here the column simply does not exist in the
* grid, and the <img> is lazy, so a phone pays nothing for it.
*/
.csf-media {
display: none;
position: relative;
overflow: hidden;
/*
* A shape of its own rather than whatever height the form column ends
* up being. `aspect-ratio` needs the box to stop stretching first —
* a grid item defaults to `stretch`, which sets a definite height and
* makes the ratio a no-op — so the item is centred in its track and
* takes its height from its width.
*
* `auto` restores the reference: the image fills the column edge to
* edge, however tall the form makes it.
*/
align-self: center;
aspect-ratio: var(--csf-image-ratio);
}
.csf-image {
display: block;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
/* Read, not seen. */
.csf-a11y {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* ---- responsive ------------------------------------------------------ */
/*
* Mobile first, so each boundary lands on the same pixel as the
* reference's — 640, 768 and 1024. Written as `max-width` they would each
* miss a viewport reporting a fractional width just under the round
* number, which is a mistake this library has already made once.
*/
@media (min-width: 640px) {
.csf-row {
flex-direction: row;
}
.csf-services {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 768px) {
.contact-split-form {
--csf-pad-block: 96px;
--csf-pad-inline: 32px;
--csf-heading-size: 36px;
--csf-heading-line: 44px;
--csf-heading-track: -0.02em;
}
.csf-inner {
max-inline-size: var(--csf-max);
}
}
@media (min-width: 1024px) {
/*
* Only when there is an image. Declared unconditionally, a section
* with no second child still reserved half the width for it and the
* form centred inside the left half rather than the page — measured
* 120px of space on its left against 840px on its right at 1440.
*/
.contact-split-form[data-has-image] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.csf-media {
display: block;
}
}
/* ---- preferences ----------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
/* One token, because every transition in the file resolves through it.
Nothing rests at zero opacity or zero size, so removing the motion
leaves the section correct rather than blank. */
.contact-split-form {
--csf-duration: 0.01ms;
}
}
@media (forced-colors: active) {
/*
* Every ring here is an inset shadow, and a shadow is not painted at
* all under a forced palette — without this the fields, the checkboxes
* and the button would have no visible edge whatsoever.
*/
.csf-control,
.csf-checkbox,
.csf-submit {
border: 1px solid CanvasText;
box-shadow: none;
}
.csf-control:focus-within {
border-color: Highlight;
}
.csf-checkbox:checked {
background-color: Highlight;
}
.csf-submit {
background: ButtonFace;
color: ButtonText;
}
}
</style>
===== END src/components/ContactSplitForm.astro =====
===== FILE (supporting): src/components/ContactSplitForm.md =====
# Contact split form
A centred contact form — heading, reach-us line, real validating fields and
a checkbox group. Pass an image and it becomes the two-column split it was
measured from.
Nothing about the business is assumed: the heading, the contact address and
the services checkboxes all describe a particular business, so none is
defaulted. With no props it renders the form alone. The catalog demo passes
them as props.
Catalog page: `/components/contact-split-form`
## Purpose
The contact page, or the block at the bottom of one. It asks for a name, an
address, an optional number, a message and a rough idea of what the person
wants — which is about as much as anyone will fill in before they give up.
Use something else if you need conditional fields, multiple steps or a file
upload. This is one screen of plain inputs; the moment it needs logic it
needs a form library, and that is a different component.
## Files required
**`ContactSplitForm.astro`** — one file, the whole component.
- No imports at all, not even a type.
- No npm packages, no icon library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour and size is a `--csf-*` custom property
on the component's own root element.
- No webfonts.
- **No images.** The default is a centred form and reaches for nothing.
`ContactSplitForm.md` — this file. Documentation, not required to run.
`public/contact-split-form/studio.svg` — this library's own artwork at
1200×960, used only if you pass it to `image`. Optional, and not part of the
default render.
## Basic usage
```astro
---
import ContactSplitForm from "../components/ContactSplitForm.astro";
---
<ContactSplitForm
heading="Let's build something worth visiting."
email="studio@example.com"
action="/api/contact"
method="post"
/>
```
No client directive, no wrapper, no init call — the component ships **no
script at all**. Every control is native, so validation, keyboard behaviour
and submission are the browser's.
It is a full-bleed band: it paints its own background edge to edge and
applies its own padding, so it drops straight into the page flow. With no
image the form is a single column centred in it.
### The two-column split
Pass an image and the section becomes the layout it was measured from — form
on the left, picture on the right, above 1024px:
```astro
<ContactSplitForm image="/img/studio.jpg" imageRatio="5 / 4" />
```
## It has no back end
**`action` is unset by default**, which means the browser submits to the
current URL. That is fine in a preview and wrong on a site — point it at
something:
```astro
<ContactSplitForm action="/api/contact" method="post" />
```
There is no success state, no error state and no spinner, because there is
no submission handling. This is the form; whatever receives the POST owns
the response. If you want an inline thank-you you will be adding script, and
that is yours to add.
Field names as submitted: `firstName`, `lastName`, `email`, `countryCode`,
`phone`, `message`, and `services` once per ticked box.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string?` | none | The section heading. Unset: no heading. |
| `headingLevel` | `"h1" \| "h2" \| "h3"?` | `"h2"` | So it fits your page outline. |
| `ledeText` | `string?` | `"You can reach us any time at"` | Words before the address. The line renders only when `email` is set. |
| `email` | `string?` | none — no contact line | The address in that line. Never defaulted: a real business's address only. |
| `action` | `string?` | — | Where the form posts. See above. |
| `method` | `"get" \| "post"?` | `"post"` | |
| `formName` | `string?` | — | Added to the `<form>`. |
| `submitLabel` | `string?` | `"Start the conversation"` | The button. |
| `labels` | `ContactLabels?` | English | Visible field labels. |
| `placeholders` | `ContactPlaceholders?` | English | Hints, never labels. |
| `servicesLegend` | `string?` | `"What do you need?"` | The group's legend. `""` drops the group. |
| `services` | `string[]?` | `[]` | The checkboxes — your real services. Empty: no group (the legend is not drawn). |
| `showPhone` | `boolean?` | `true` | Render the phone row. |
| `countries` | `ContactCountry[]?` | ten | The dial-code select. `[]` drops it. |
| `image` | `string?` | — | Pass one to get the two-column split. Empty is a centred form. |
| `imageRatio` | `string?` | `"5 / 4"` | Any CSS `aspect-ratio`. `"auto"` makes it full-bleed. |
| `imageAlt` | `string?` | `""` | Empty marks it decorative. |
| `background` | `string?` | `#ffffff` | The section surface. |
| `ink` | `string?` | `#101828` | Heading and input text. |
| `mutedInk` | `string?` | `#535862` | Lede, placeholders. |
| `labelInk` | `string?` | `#414651` | Field labels and the legend. |
| `borderColor` | `string?` | `#d5d7da` | Input rings and checkbox edges. |
| `accent` | `string?` | `#7f56d9` | Button, link, focus ring, required marks. |
| `accentInk` | `string?` | `#ffffff` | Text on the button. |
| `maxWidth` | `string?` | `480px` | Where the form column stops widening. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Root id. **Also seeds every field id.** |
### The types
```ts
interface ContactCountry {
code: string; // "US"
value: string; // "+1" — what gets submitted
label?: string; // option text; defaults to "US +1"
}
interface ContactLabels {
firstName?; lastName?; email?; phone?; country?; message?: string;
}
interface ContactPlaceholders {
firstName?; lastName?; email?; phone?; message?: string;
}
```
### One accent, two purples
`accent` drives the button, the required marks and the focus ring. The email
link is derived from it a step darker, because a link at the button's
lightness is thin against white. The reference uses two brand values for
exactly this; here it is one prop and a `color-mix`.
### The image's shape
`imageRatio` is a plain CSS `aspect-ratio`, so `"5 / 4"`, `"1"`, `"16 / 9"`
and `"1.6"` all work. It applies only when there is an image. The picture is
centred in its column and the section background shows above and below it,
because the form column is almost always the taller of the two.
`imageRatio="auto"` restores the reference's behaviour: the image fills the
column edge to edge and takes whatever height the form makes it. Note that
this crops — `object-fit` is `cover` either way — so a ratio that matches
your artwork is the one that shows all of it.
## Behaviour
Native, all of it.
| Input | What happens |
| :--- | :--- |
| Submit with a required field empty | The browser blocks it and points at the field. No script involved. |
| Submit with a malformed address | Same — `type="email"` does it. |
| <kbd>Tab</kbd> | Link, then the six fields in order, then the six checkboxes, then the button. |
| <kbd>Space</kbd> on a checkbox | Ticks it. It is a real checkbox. |
## Accessibility
- **Every control has a real `<label for>`.** No placeholder is doing a
label's job, and the country select gets a visually hidden label of its
own rather than being covered by the phone field's.
- **The asterisks are `aria-hidden`.** `required` on the input is what a
screen reader announces; the mark is for sighted readers. The reference
puts the asterisk in the label text, where it is read out as "star".
- The checkbox group is a real `<fieldset>` with a real `<legend>`, which is
what groups checkboxes for assistive technology.
- `autocomplete` on the name, email and phone fields, so a browser can fill
them.
- Inputs are set at **16px**, and not for looks: anything smaller makes iOS
Safari zoom the page when the field takes focus.
- The focus indicator is an `outline`, not the reference's inset shadow. It
looks the same drawn a pixel inside the box and, unlike a shadow, it
survives a forced palette — where a shadow is simply not painted and the
field would have no focus indicator at all.
- Under `forced-colors: active` the fields, checkboxes and button take real
borders, since every edge here is otherwise an inset shadow.
- The image is decorative by default with an empty `alt`.
- The section has **no accessible name**, deliberately. Its heading is caller
copy that may not suit as a landmark name; wrap it or give it an
`aria-label` if the page has several landmarks.
## Reduced motion
Under `prefers-reduced-motion: reduce` the one duration token collapses to
`0.01ms`, so the ring and fill transitions become cuts. Nothing rests at
zero opacity or zero size, so the section is correct rather than blank.
## Without JavaScript
Identical, and that is the point of building it out of native controls. The
fields validate, the checkboxes tick, the select opens and the form submits.
## Responsive
Three steps, and they are the reference's own pixels:
| Width | Layout | Padding | Heading | Name row | Services |
| :--- | :--- | :--- | :--- | :--- | :--- |
| ≥ 1024 | two columns **if there is an image** | 96/32 | 36px/44px | side by side | 2-up |
| 768–1023 | one column | 96/32 | 36px/44px | side by side | 2-up |
| 640–767 | one column | 64/16 | 30px/38px | side by side | 2-up |
| < 640 | one column | 64/16 | 30px/38px | stacked | 1-up |
With no image there is one column at every width and the form is centred in
it — 480/480 either side at 1440px.
The image column is not *hidden* below 1024 — it is not in the grid at all,
and the `<img>` is lazy, so a phone does not pay for it.
## Sizing
Full width, painting its own background edge to edge. The form column is
capped at `maxWidth` and centred within its half.
Retune from CSS without touching the file:
```css
.my-page .contact-split-form {
--csf-control-h: 48px;
--csf-radius: 12px;
--csf-pad-block: 72px;
}
```
## Differences from the reference
Rebuilt from a live page, measured rather than copied. Every value taken
matches: the two 720px columns at 1440, 96/32 pane padding, a 480px form
column, the heading at 36px/44px and −0.72px, the lede at 20px/30px, 32px
between fields and 48px above them, 44px controls at an 8px radius with a
1px `#d5d7da` inset ring, inputs at 10/14 and 16px, a 144px textarea, a
208+208 checkbox grid at 16/64, 20px checkboxes at a 6px radius, and a 48px
button at 12/18. What differs:
- **The copy and the artwork are original.** The reference's photograph is a
licensed stock image and is not reproduced; the demo ships this library's
own drawing.
- **There is no image by default**, where the reference always has one. The
section is then a single centred column. Pass `image` for the split.
- **The image is 5:4 and centred** when there is one, where the reference's
is full-bleed and as tall as the form column. `imageRatio="auto"` puts it
back.
- **The country select shows `US +1`, not `US`.** A native select displays
the selected option's own text, so the list and the field cannot differ.
The reference shows a bare code in the field and full country names in its
list, which takes a custom listbox — a keyboard widget to build and keep
working. The field comes out 99px against its 63px.
- **Ten dial codes, not 189.** Pass your own `countries`.
- **The asterisks are hidden from assistive technology** and `required` does
the announcing. The reference reads them out.
- **The focus indicator is an outline**, for the forced-colors reason above.
- **`autocomplete` is set.** The reference sets none.
- The reference sets its type in Inter. This uses the system stack, because
a component in this library fetches nothing.
===== END src/components/ContactSplitForm.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-22 at 1600, 1440, 1280, 1024, 1023, 900, 834, 768, 767, 640, 639, 480, 390, 360, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:8b1c5738bf8aec8eb22853bf4b9edeeac8ecfa7e5d80c99574079c3284c14e5b — MATCHES the files below.
--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.