Component library
Back to catalog

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.

contactformfieldscenteredstatic

Open preview in a new tab

Use this component

Three ways to get Contact split form into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Contact split form from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/contact-split-form/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

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.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Contact split form” from my Astro Component Library:
https://astro.baysixmedia.com/components/contact-split-form/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
2026-09-22
Client navigation
Not applicable — ships no script
Source fingerprint
Matches the verified source
Production ready
Yes

Production-ready component

Every required check passed against the code below, and the recorded fingerprint still matches it.

Verified 2026-09-22 at 1600, 1440, 1280, 1024, 1023, 900, 834, 768, 767, 640, 639, 480, 390, 360, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecycle — declared to ship no scriptnot-applicable
33 verification notes
  • Fingerprint re-recorded 2026-09-24 (Step 14, core page safety) after a no-behaviour content change, following the precedent above: the default heading and services checkbox list became empty and moved into the registry demo props. Style and script blocks are byte-identical to the verified revision, and every preview, integration-test and template page was built before and after and compared — this component's pages are identical apart from per-build random ids. The no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts (no props, one prop, one nested field). No new full verification run was performed.
  • Fingerprint re-recorded 2026-09-24 (Step 13, content safety) after a no-behaviour content change, following the precedent above: the default contact address studio@example.com was removed (moved into the registry demo props) and the lede line now renders only with an address. Style and script blocks are byte-identical to the verified revision. Evidence for keeping this record: every preview, integration-test and template page was built before and after the change and compared — identical apart from per-build random ids — so each check below still describes what renders with the catalog's configuration. The new no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts rather than by a fixture, and was checked in Chromium at 320, 390, 768 and 1440 (no overflow, no console errors, no duplicate ids, nothing rendered). No new full verification run was performed.
  • Every check below was run against the dev server and then re-run against the production build served by `astro preview`. Both agreed.
  • Rebuilt from a live page by measurement, not by copying. Read at 1440px and compared value by value; everything taken matches. Two 720px columns; 96px block and 32px inline padding on the form pane; a 480px form column centred in its half; the heading at 36px on 44px at -0.72px; the lede at 20px on 30px, 20px under the heading; 32px between fields and 48px above the first; 6px between a label and its control; labels at 14px weight 500 in #414651; 44px controls at an 8px radius over a 1px #d5d7da inset ring and a 0 1px 2px shadow at 5 per cent; inputs at 10px/14px padding and 16px type; a 144px textarea; the name row at 24px/32px gaps; the checkbox grid at 208+208 with 16px and 64px gaps; 20px checkboxes at a 6px radius; and a 48px button at 12px/18px, an 8px radius and 16px weight 600 on #7f56d9.
  • The heading needed three tokens rather than one ratio. The reference steps size, leading AND tracking together - 30px on 38px at normal tracking below the breakpoint, 36px on 44px at -0.02em above it - and a single line-height ratio cannot express both. Written that way first it measured 43.92px against the reference's 44px, which is the kind of miss that never shows up by eye.
  • The three breakpoints are the reference's own pixels, found by bisection: 1024, 768 and 640. At 1024 the second column appears and the image with it; at 768 the pane padding goes 64/16 to 96/32 and the heading steps up; at 640 the name row goes side by side and the checkbox grid goes two up. Verified on both sides of each boundary - 1024 against 1023, 768 against 767, 640 against 639 - and each flips on the same pixel as the reference.
  • Written mobile-first, so those boundaries land exactly. As `max-width` queries each would miss a viewport reporting a fractional width just under the round number, which is a mistake this library has already made once and now writes around by default.
  • There is no image by default, so the section is one centred column, and the two-column split is what an image turns on. That needed the grid rule made conditional: declared unconditionally at 1024, a section with no second child still reserved half its width for the missing column and the form centred inside the left half - measured 120px of space on its left against 840px on its right at 1440. Keyed off a `data-has-image` attribute now, and verified at thirteen widths from 1600 to 320: equal margins at every one, 480px either side at 1440. Both branches render on the fixture, which carries one instance of each.
  • The image column is not hidden below 1024, it is absent. The reference sets `display: none`, which on several engines still fetches the image; here the grid has one column and the second child is simply not in it, and the <img> is lazy on top of that. A phone pays nothing for a decorative photograph it will never see.
  • The image has a shape of its own - 5:4 by default, centred in its column - where the reference's is full-bleed and as tall as whatever the form column turns out to be. Setting that up needed one thing that is easy to miss: a grid item defaults to `align-self: stretch`, which gives it a definite height, and a definite height makes `aspect-ratio` a no-op. The item has to stop stretching before the ratio means anything. Measured after: the box is 720 x 576 at a 1440px viewport, a ratio of 1.250 exactly, against a section 1106px tall. `imageRatio` takes any CSS aspect-ratio and `auto` restores the reference's full-bleed behaviour.
  • The demo artwork was redrawn at 1200 x 960 to match that 5:4 box. `object-fit` is `cover` whatever the ratio, so artwork at a different shape is cropped rather than letterboxed - at the previous 900 x 1200 the same drawing lost its top and bottom third. Stated in the README beside the prop, because it is the caller's artwork that has to match, not the component's.
  • Every control has a real `<label for>` pointing at a real id, and the ids are seeded from the `id` prop when there is one and from a random suffix when there is not - an index-derived id would collide on the second instance, which this library has shipped before. Verified on the fixture: two instances, zero duplicate ids, and zero controls without either a label or an aria-label.
  • The country select has its own visually hidden label rather than borrowing the phone field's. It is a separate control submitting a separate value, and a control inside another control's box is still a control that needs a name. The reference gives it an aria-label, which works; a real label was preferred because it survives being restyled.
  • The required marks are hidden from assistive technology and `required` does the announcing. The reference puts its asterisk in the label text, where a screen reader reads "First name star". Here the attribute carries the meaning and the glyph is decoration - verified: every `.csf-req` carries aria-hidden, and four inputs carry `required`.
  • The checkbox group is a real `<fieldset>` with a real `<legend>`, which is what groups checkboxes for assistive technology, and the reference does this correctly too - it is recorded because it is the kind of thing that gets replaced with a styled <p> and quietly lost.
  • Checkboxes are native inputs restyled with `appearance: none`, not divs with a script behind them. The element keeps its focus behaviour, its label association and its place in the form; only the platform's painting is taken away. The tick is a background image rather than a pseudo-element, because a pseudo-element on a replaced element is not reliable across engines.
  • Inputs are set at 16px, and that is a functional value rather than a typographic one: anything smaller makes iOS Safari zoom the page when a field takes focus, which on a contact form is the difference between filling it in and giving up. It happens to be the reference's value too.
  • The focus indicator is an `outline`, where the reference swaps its 1px grey inset ring for a 2px purple one. They look the same - the outline is drawn two pixels inside the box - but a box-shadow is not painted at all under a forced palette, and a form whose fields have no focus indicator in high contrast is a form that cannot be filled in. The ring is kept as well, on `:focus-within`, so the look is the reference's and the guarantee is not.
  • Keyboard: fourteen stops in document order - the email link, the six fields, the six checkboxes, the submit button - and nothing after them inside the section. The ring measures 2px solid at -2px offset. Native validation was exercised: submitting with a required field empty is blocked by the browser, with no script involved.
  • Static, and checked rather than assumed: zero `<script>` elements. With JavaScript disabled the section is unchanged - twelve controls, the submit button, the image column, no overflow - because there was never anything for a script to do. Validation and submission are the browser's.
  • Lifecycle and client navigation are not-applicable. The component registers no listeners, holds no state and writes nothing to the document, so there is nothing for a router to strand. A pass would imply a teardown was observed working; there is no teardown.
  • Reduced motion: the single duration token collapses to 1e-05s, so the ring and fill transitions on the fields, the checkboxes and the button all become cuts. Nothing rests at zero opacity or zero size, so the section stays correct rather than going blank.
  • Forced colours: every edge in this component is an inset shadow - the field rings, the checkbox boxes, the button's bevel - and a shadow is not painted under a forced palette, so without intervention the whole form would be invisible chrome. The fields, checkboxes and button each take a real border instead and the shadows are dropped. Observed under emulation: all three resolve to `1px solid` and the field's box-shadow reports `none`.
  • Two instances on one page are independent and differ in STATE rather than only in identity, and between them they cover every optional branch in the component. The first is the default: image, phone row with its select, services fieldset, lede line, POST with no action. The second has no image, no phone row, no services group and no lede, a GET to /thanks, relabelled fields, a wider column, a different accent and a heading one level down. Zero duplicate ids, no page overflow.
  • Declarations corroborated against the rendered fixture: twelve focusable controls against hasFocusableContent, measured transition durations against hasMotion, zero script elements against interactivity: static, both supplied ids reaching their roots and seeding their field ids against acceptsIdProp, and two independently-configured instances against multiInstanceSupport.
  • No overflow anywhere. Checked at fifteen widths from 1600 down to 320 on both the preview and the fixture route: no descendant escapes the section box on either side, and `scrollWidth === clientWidth` on the document at every one. The textarea is `resize: vertical` for the same reason - a horizontal drag can otherwise push it past its column and take the page's width with it.
  • Install kit confirmed from the component's own page: one required component file and one required asset, each carried inline with its destination path, plus the README as supporting.
  • Zero console messages and zero page errors across the preview route, the fixture route and the component page.
  • The copy and the artwork are original to this library. The reference's photograph is a licensed stock image and is not reproduced - the demo ships an overhead desk drawing made here, at 1200 x 960. An earlier attempt at a seated figure was replaced because flat SVG anatomy read badly at this size; the object arrangement carries the same idea without it.
  • The country select shows "US +1" where the reference shows "US", and that is a limit of the element rather than a choice. A native select displays the selected option's own text, so the list and the closed field cannot say different things; the reference shows a bare code in the field and full names in its list, which only a custom listbox can do. Measured: the select is 99px against the reference's 62.7px. Ten dial codes ship rather than its 189, and `countries` replaces them.
  • The section carries no accessible name, deliberately, and that is worth stating because a `<section>` without one is not a landmark. Its heading is caller copy that may or may not suit as a landmark name, so the component does not invent one; the integration notes say to wrap it or pass an aria-label on a page with several landmarks.
  • There is no success state, no error state and no submission handling of any kind, and `action` is unset by default - which means the browser posts to the current URL. That is stated in the integration notes rather than papered over: this is the form, not the plumbing, and shipping it without pointing `action` somewhere is a live defect in the installation rather than in the component.
  • Chromium only. No WebKit or Gecko build was available in this session, and no claim is made about either. The places that gap matters most are `appearance: none` on the checkbox, which is where engines differ most in form painting, and `color-mix` for the derived link colour.

Open the verification fixture

src/components/library/ContactSplitForm.astro

one fileNo imports, no companion files, no packages. Copy ContactSplitForm.astro into any Astro project's components folder and render it.

---
/**
 * 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>