Component library
Back to catalog

Map contact form

A contact section: centred GET IN TOUCH eyebrow between rules and a big heading, then an embedded map beside a light grey rounded form panel (name, email, phone, freight type, load, message) with a full-width orange Submit Request pill.

contactformmaplogisticsappear

Open preview in a new tab

Use this component

Three ways to get Map contact 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 Map contact form from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/map-contact-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: "Map contact form"
Library page: https://astro.baysixmedia.com/components/map-contact-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/MapContactForm.astro =====
---
/**
 * MapContactForm
 *
 * A contact section: a centred orange eyebrow between two short rules and a
 * big navy heading, then two columns — an embedded map on the left and a
 * light grey rounded form panel on the right (name, email, phone, freight
 * type, load, message, and a full-width orange "Submit Request" pill).
 *
 * The map is a lazy <iframe> of `mapEmbedUrl`. Unset (the default), there is
 * no map and the form stands alone, centred; `mapEmbedUrl: ""` renders a
 * labelled placeholder instead and makes no third-party request. No heading
 * or place is assumed. The form is native: `action` is unset by default,
 * every control has a real (visually hidden) label.
 *
 * Appear on scroll: the title rises 40px; the map slides in from −40px and
 * the form from +40px. Without JS or under reduced motion all at rest.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface MapContactLabels {
	name?: string;
	email?: string;
	phone?: string;
	freightType?: string;
	load?: string;
	message?: string;
}

export interface MapContactFormProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** A map embed URL. Unset: no map. `""` renders the labelled placeholder instead. */
	mapEmbedUrl?: string;
	/** The iframe's title — what the map shows. */
	mapTitle?: string;
	/** Where the form posts. Unset: the browser submits to the current URL. */
	action?: string;
	method?: "get" | "post";
	formName?: string;
	submitLabel?: string;
	/** Freight type options. `[]` drops the select. */
	freightTypes?: string[];
	/** Load options. `[]` drops the select. */
	loads?: string[];
	/** Accessible labels (visually hidden). */
	labels?: MapContactLabels;
	/** Placeholders — hints only, never the label. */
	placeholders?: MapContactLabels;
	background?: string;
	fontStylesheet?: string;
	class?: string;
	/** Root id. Also seeds every field id. */
	id?: string;
}

interface Props extends MapContactFormProps {}

const {
	eyebrow = "Get In Touch",
	heading = "",
	headingLevel = "h2",
	mapEmbedUrl,
	mapTitle = "",
	action,
	method = "post",
	formName,
	submitLabel = "Submit Request",
	freightTypes = ["Air Freight", "Sea Freight", "Road Freight", "Rail Freight"],
	loads = ["Under 1 ton", "1 ton", "2 tons", "5 tons", "Full truckload"],
	labels = {},
	placeholders = {},
	background = "#ffffff",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
const uid = (id ?? `mcf-${Math.random().toString(36).slice(2, 9)}`).replace(/[^A-Za-z0-9_-]/g, "") || "mcf";
const fid = (name: string) => `${uid}-${name}`;

/* Only an http(s) page may be framed. */
const mapSrc = mapEmbedUrl && /^https:\/\//i.test(mapEmbedUrl.trim()) ? mapEmbedUrl.trim() : "";
/* No map at all unless one (or the placeholder, with "") is asked for. */
const showMap = mapEmbedUrl !== undefined;
const mapLabel = mapTitle || "Map";

const L: Required<MapContactLabels> = {
	name: "Your name",
	email: "Your email",
	phone: "Phone number",
	freightType: "Freight type",
	load: "Load",
	message: "Message",
	...labels,
};

const P: Required<MapContactLabels> = {
	name: "Your Name",
	email: "Your Email",
	phone: "Phone No:",
	freightType: "Freight Type",
	load: "Load",
	message: "Message",
	...placeholders,
};
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["mcf", className]} id={id} data-mcf-root style={`--mcf-band: ${background};`}>
	<div class="mcf-inner">
		{(eyebrow || heading) && (
		<div class="mcf-title" data-mcf-appear>
			{
				eyebrow && (
					<p class="mcf-eyebrow">
						<span class="mcf-rule" aria-hidden="true" />
						<span>{eyebrow}</span>
						<span class="mcf-rule" aria-hidden="true" />
					</p>
				)
			}
			{heading && <Heading class="mcf-heading">{heading}</Heading>}
		</div>
		)}

		<div class="mcf-body" style={showMap ? undefined : "grid-template-columns: minmax(0, 630px); justify-content: center;"}>
			{showMap && (
			<div class="mcf-map" data-mcf-appear="left">
				{
					mapSrc ? (
						<iframe
							src={mapSrc}
							title={mapLabel}
							loading="lazy"
							referrerpolicy="no-referrer-when-downgrade"
							allowfullscreen
						/>
					) : (
						<div class="mcf-ph" role="img" aria-label={`Map placeholder: ${mapLabel}`}>
							<div class="mcf-ph-text" aria-hidden="true">
								<svg viewBox="0 0 24 24" width="28" height="28"><path fill="currentColor" d="M12 2a7 7 0 0 0-7 7c0 5 6.1 11.4 6.4 11.7a.8.8 0 0 0 1.2 0C12.9 20.4 19 14 19 9a7 7 0 0 0-7-7Zm0 9.6A2.6 2.6 0 1 1 12 6.4a2.6 2.6 0 0 1 0 5.2Z" /></svg>
								<span class="mcf-ph-label">Map</span>
								<span class="mcf-ph-hint">{mapLabel}</span>
								<span class="mcf-ph-spec">Pass mapEmbedUrl to embed a map</span>
							</div>
						</div>
					)
				}
			</div>
			)}

			<form class="mcf-form" action={action} method={method} name={formName} data-mcf-appear="right">
				<div class="mcf-field">
					<label class="mcf-sr" for={fid("name")}>{L.name}</label>
					<input class="mcf-input" type="text" id={fid("name")} name="name" autocomplete="name" placeholder={P.name} required />
					<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M6 6a2.6 2.6 0 1 0 0-5.2A2.6 2.6 0 0 0 6 6Zm0 1.1c-2.4 0-4.6 1.2-4.6 2.9v1.2h9.2V10c0-1.7-2.2-2.9-4.6-2.9Z" /></svg>
				</div>
				<div class="mcf-pair">
					<div class="mcf-field">
						<label class="mcf-sr" for={fid("email")}>{L.email}</label>
						<input class="mcf-input" type="email" id={fid("email")} name="email" autocomplete="email" placeholder={P.email} required />
						<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M1 2.5h10a.5.5 0 0 1 .5.5v.4L6 6.6.5 3.4V3a.5.5 0 0 1 .5-.5Zm-.5 2.1L6 7.8l5.5-3.2V9a.5.5 0 0 1-.5.5H1a.5.5 0 0 1-.5-.5Z" /></svg>
					</div>
					<div class="mcf-field">
						<label class="mcf-sr" for={fid("phone")}>{L.phone}</label>
						<input class="mcf-input" type="tel" id={fid("phone")} name="phone" autocomplete="tel" placeholder={P.phone} />
						<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M3.3 5.3a8 8 0 0 0 3.4 3.4l1.1-1.1a.5.5 0 0 1 .5-.13 5.7 5.7 0 0 0 1.8.29.5.5 0 0 1 .5.5V10a.5.5 0 0 1-.5.5A8.5 8.5 0 0 1 1.5 2a.5.5 0 0 1 .5-.5h1.75a.5.5 0 0 1 .5.5c0 .63.1 1.23.29 1.8a.5.5 0 0 1-.13.5Z" /></svg>
					</div>
				</div>
				{(freightTypes.length > 0 || loads.length > 0) && (
				<div class="mcf-pair">
					{freightTypes.length > 0 && (
					<div class="mcf-field">
						<label class="mcf-sr" for={fid("freight")}>{L.freightType}</label>
						<select class="mcf-input mcf-select" id={fid("freight")} name="freightType">
							<option value="" selected>{P.freightType}</option>
							{freightTypes.map((o) => <option value={o}>{o}</option>)}
						</select>
						<svg class="mcf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
					</div>
					)}
					{loads.length > 0 && (
					<div class="mcf-field">
						<label class="mcf-sr" for={fid("load")}>{L.load}</label>
						<select class="mcf-input mcf-select" id={fid("load")} name="load">
							<option value="" selected>{P.load}</option>
							{loads.map((o) => <option value={o}>{o}</option>)}
						</select>
						<svg class="mcf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
					</div>
					)}
				</div>
				)}
				<div class="mcf-field">
					<label class="mcf-sr" for={fid("message")}>{L.message}</label>
					<textarea class="mcf-input mcf-textarea" id={fid("message")} name="message" rows="5" placeholder={P.message} required></textarea>
				</div>
				<button class="mcf-btn" type="submit">{submitLabel}</button>
			</form>
		</div>
	</div>
</section>

<style>
	.mcf {
		--mcf-navy: var(--tg-navy, #020e28);
		--mcf-orange: var(--tg-orange, #fd5523);
		--mcf-muted: var(--tg-muted, #61697d);
		--mcf-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--mcf-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
		--mcf-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		padding: 0 0 60px;
		background: var(--mcf-band);
		color: var(--mcf-navy);
		font-family: var(--mcf-body);
		/* The form's entrance starts 40px to the right; until it plays, that
		   offset must not push the page sideways on a narrow screen. `clip`, not
		   `hidden`: no scroll container, so nothing about focus or sticky changes. */
		overflow-x: clip;
	}

	.mcf *,
	.mcf *::before,
	.mcf *::after {
		box-sizing: border-box;
	}

	.mcf-inner {
		display: flex;
		flex-direction: column;
		gap: 40px;
		max-width: 1293px;
		margin: 0 auto;
		padding: 0 20px;
		/* Contains the ±40px appear slides at every width. */
		overflow: clip;
		overflow-clip-margin: 8px;
	}

	.mcf-title {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 20px;
		text-align: center;
	}

	.mcf .mcf-eyebrow {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		font: 600 16px/1 var(--mcf-display);
		text-transform: uppercase;
		color: var(--mcf-orange);
	}

	.mcf-rule {
		display: block;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.mcf-heading {
		margin: 0;
		max-width: 680px;
		font: 600 32px/1.2 var(--mcf-display);
		color: var(--mcf-navy);
		overflow-wrap: anywhere;
	}

	.mcf-body {
		display: flex;
		flex-direction: column;
		gap: 10px;
	}

	/* On a phone the form comes first, as the reference does; the map's one
	   tab stop (the iframe) then follows the form in visual order too. */
	.mcf-map {
		order: 1;
		position: relative;
		height: 320px;
		border-radius: 20px;
		overflow: hidden;
		background: #e9ecef;
	}

	.mcf-map iframe {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		border: 0;
	}

	.mcf-map:has(iframe:focus-visible) {
		outline: 3px solid var(--mcf-orange);
		outline-offset: 3px;
	}

	.mcf-ph {
		position: absolute;
		inset: 0;
		display: grid;
		place-items: center;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.mcf-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

	.mcf-ph-text svg {
		color: var(--mcf-orange);
	}

	.mcf-ph-label {
		font: 500 12px/1 var(--mcf-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.mcf-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--mcf-body);
	}

	.mcf-ph-spec {
		font: 400 12px/1.2 var(--mcf-body);
	}

	.mcf-form {
		display: flex;
		flex-direction: column;
		gap: 12px;
		min-width: 0;
		margin: 0;
		padding: 16px;
		border-radius: 20px;
		background: #f4f4f4;
	}

	.mcf-pair {
		display: grid;
		gap: 12px;
	}

	.mcf-field {
		position: relative;
		min-width: 0;
	}

	.mcf-input {
		display: block;
		width: 100%;
		min-height: 44px;
		margin: 0;
		padding: 11px 44px 11px 18px;
		border: 0;
		border-radius: 100px;
		background: #fff;
		color: var(--mcf-navy);
		font: 400 16px/20px var(--mcf-body);
	}

	.mcf-input::placeholder {
		color: var(--mcf-muted);
		opacity: 1;
	}

	/* An outline, not a shadow: it survives forced colours. */
	.mcf-input:focus {
		outline: 2px solid var(--mcf-orange);
		outline-offset: 2px;
	}

	.mcf-select {
		appearance: none;
		-webkit-appearance: none;
		cursor: pointer;
	}

	.mcf-select:has(option[value=""]:checked) {
		color: var(--mcf-muted);
	}

	.mcf-select option {
		color: var(--mcf-navy);
	}

	.mcf-textarea {
		min-height: 100px;
		padding: 14px 18px;
		border-radius: 20px;
		resize: vertical;
	}

	.mcf-icon,
	.mcf-chevron {
		position: absolute;
		top: 50%;
		right: 20px;
		width: 12px;
		height: 12px;
		transform: translateY(-50%);
		color: var(--mcf-orange);
		pointer-events: none;
	}

	.mcf-chevron {
		width: 18px;
		height: 18px;
		right: 18px;
	}

	.mcf-btn {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 100%;
		min-height: 48px;
		padding: 16px 20px;
		border: 0;
		border-radius: 100px;
		background: var(--mcf-orange);
		color: #fff;
		font: 600 14px/1 var(--mcf-body);
		cursor: pointer;
		transition: background-color 0.35s ease-out;
	}

	.mcf-btn:hover {
		background: var(--mcf-navy);
	}

	.mcf-btn:focus-visible {
		outline: 2px solid var(--mcf-navy);
		outline-offset: 3px;
	}

	.mcf-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	/* ---- appear ---- */
	[data-mcf-appear] {
		transition:
			opacity 0.8s var(--mcf-ease),
			transform 0.8s var(--mcf-ease);
	}

	.mcf[data-mcf-anim] [data-mcf-appear]:not([data-in]) {
		opacity: 0;
		transform: translateY(40px);
	}

	.mcf[data-mcf-anim] [data-mcf-appear="left"]:not([data-in]) {
		transform: translateX(-40px);
	}

	.mcf[data-mcf-anim] [data-mcf-appear="right"]:not([data-in]) {
		transform: translateX(40px);
	}

	/* ---- tablet ---- */
	@media (min-width: 810px) {
		.mcf {
			padding-bottom: 90px;
		}

		.mcf-inner {
			gap: 50px;
		}

		.mcf-heading {
			font-size: 40px;
		}

		.mcf-form {
			gap: 25px;
			padding: 40px;
		}

		.mcf-pair {
			grid-template-columns: repeat(2, minmax(0, 1fr));
			gap: 30px;
		}

		/* One select left (the other's options were emptied): full width. */
		.mcf-pair:has(> .mcf-field:only-child) {
			grid-template-columns: minmax(0, 1fr);
		}

		.mcf-input {
			min-height: 56px;
			padding: 17px 48px 17px 24px;
		}

		.mcf-textarea {
			min-height: 175px;
			padding: 16px 24px;
		}

		.mcf-icon {
			right: 24px;
		}

		.mcf-chevron {
			right: 22px;
		}

		.mcf-btn {
			min-height: 56px;
			padding: 21px 20px;
		}

		.mcf-map {
			height: 400px;
		}
	}

	/* ---- desktop: map left, form right ---- */
	@media (min-width: 1200px) {
		.mcf {
			padding-bottom: 120px;
		}

		.mcf-inner {
			gap: 60px;
		}

		.mcf .mcf-eyebrow {
			font-size: 18px;
		}

		.mcf-heading {
			font-size: 48px;
			line-height: 57.6px;
		}

		.mcf-body {
			display: grid;
			grid-template-columns: minmax(0, 589fr) minmax(0, 630fr);
			padding: 0 12px;
		}

		.mcf-map {
			order: 0;
			height: auto;
			min-height: 579px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		[data-mcf-appear] {
			transition: none;
		}
	}

	@media (forced-colors: active) {
		.mcf-input,
		.mcf-btn {
			border: 1px solid ButtonText;
		}

		.mcf-form,
		.mcf-map {
			border: 1px solid CanvasText;
		}

		.mcf-icon,
		.mcf-chevron,
		.mcf-rule {
			color: CanvasText;
		}

		.mcf-rule {
			background: CanvasText;
		}
	}
</style>

<script>
	/*
	 * Appear on scroll only. The form is native and the map is an iframe;
	 * neither needs script. One IntersectionObserver per instance; each
	 * watched element gets `data-in` once. Released on astro:before-swap.
	 */
	function initMapContactForm(root: HTMLElement) {
		if (root.dataset.mcfReady !== undefined) return;
		root.dataset.mcfReady = "";
		if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
		if (!("IntersectionObserver" in window)) return;

		const controller = new AbortController();
		const { signal } = controller;
		root.dataset.mcfAnim = "";

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					(entry.target as HTMLElement).dataset.in = "";
					observer.unobserve(entry.target);
				}
			},
			{ threshold: 0.1 },
		);
		root.querySelectorAll<HTMLElement>("[data-mcf-appear]").forEach((el) => observer.observe(el));
		signal.addEventListener("abort", () => observer.disconnect());

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.mcfReady;
				delete root.dataset.mcfAnim;
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-mcf-root]").forEach(initMapContactForm);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/MapContactForm.astro =====

===== FILE (supporting): src/components/MapContactForm.md =====
# Map contact form

A contact section: a centred **GET IN TOUCH** eyebrow between two short rules
and a big heading, then an embedded map on the left and a light grey rounded
form panel on the right — name, email, phone, freight type, load, message and
a full-width orange **Submit Request** pill.

Catalog page: `/components/map-contact-form`

## Purpose

The main block of a contact page, after the info cards. Part of the Northpass
(Transigo-style) family; reads the `--tg-*` tokens.

## Files required

**`MapContactForm.astro`** — the whole component. No imports, no images.

`MapContactForm.md` — this file.

## Basic usage

```astro
<MapContactForm
  mapEmbedUrl="https://www.google.com/maps?q=2250%20Harbor%20Way%2C%20Oakland&output=embed"
  mapTitle="Map of our Oakland depot"
  action="/api/contact"
/>
```

## The map

The map is a lazy `<iframe>` of `mapEmbedUrl`. **There is no default map**:
a place is a fact about a real business, so with `mapEmbedUrl` unset there is
no map column and the form stands alone, centred (630px wide from 1200px).
The catalog demo passes an Oakland, CA embed as a prop. A framed map is a
**third-party request**: your CSP needs `frame-src https://www.google.com`,
and Google sets its own cookies inside the frame. Only `https:` URLs are
framed.

`mapEmbedUrl=""` renders a labelled striped placeholder instead and makes no
request. Use it in previews, or where a consent banner must come first.

With no props at all it renders the generic "Get In Touch" eyebrow and the
form — no heading, no map, no place name.

## It has no back end

**`action` is unset by default**, so the browser submits to the current URL.
Field names: `name`, `email`, `phone`, `freightType`, `load`, `message`.
Required: name, email, message.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Get In Touch"` | Between two 40px rules. `""` drops it. |
| `heading` | `string?` | none | Unset: no heading. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `mapEmbedUrl` | `string?` | none | Unset: no map, form centred. `""` → placeholder. |
| `mapTitle` | `string?` | `""` | The iframe's `title` (and the placeholder's label); falls back to `"Map"`. |
| `action` | `string?` | — | See above. |
| `method` | `"get" \| "post"?` | `"post"` | |
| `formName` | `string?` | — | |
| `submitLabel` | `string?` | `"Submit Request"` | |
| `freightTypes` | `string[]?` | Air, Sea, Road, Rail | `[]` drops the select. |
| `loads` | `string[]?` | five weights | `[]` drops the select; the other one then fills the row. |
| `labels` | `MapContactLabels?` | English | Visually hidden labels. |
| `placeholders` | `MapContactLabels?` | as the design | Hints only. |
| `background` | `string?` | `#ffffff` | |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips the link. |
| `class` | `string?` | — | |
| `id` | `string?` | — | Root id; **also seeds every field id** (random when omitted). |

## Behaviour

| Element | Effect | Measured / chosen |
| :--- | :--- | :--- |
| Submit hover | background `#fd5523` → `#020e28` | as measured; 0.35s ease-out (no arrow on this button, as the reference) |
| Title appear | from `translateY(40px)` | as measured |
| Map appear | from `translateX(-40px)` | as measured |
| Form appear | from `translateX(+40px)` | as measured |
| Field focus | 2px orange outline | see Accessibility |

One IntersectionObserver per instance; hidden poses only while the root has
`data-mcf-anim`; released on `astro:before-swap`. The inner column is
`overflow: clip` (with an 8px clip margin for focus rings) so the ±40px
slides never cause horizontal scroll.

## Accessibility

- Every control has a real, visually hidden `<label for>`; placeholders are
  hints only. Inputs are 16px.
- `autocomplete` on name, email and phone.
- The iframe has a `title`; when it has keyboard focus the map wrap shows an
  orange ring.
- Focus is an outline, so it survives forced colours; under
  `forced-colors: active` fields, panel and map get borders.
- On tablet and phone the form is shown before the map (as the reference);
  the map's single tab stop then comes first in DOM order but second on
  screen — a one-stop mismatch accepted to keep the desktop order natural.

## Reduced motion / without JavaScript

Everything at rest. The form and the iframe need no script.

## Responsive

| Width | Layout |
| :--- | :--- |
| ≥ 1200 | map 589 / 10 gap / form 630, inset 12px; map as tall as the form (579); form padding 40, gap 25, 56px pills, 175px textarea; heading 48/57.6 |
| 810–1199 | form above a 400px map; paired fields side by side |
| < 810 | form above a 320px map; every field full width, 44px min height, padding 16 |

No top padding at any width (measured: it follows the contact info cards);
bottom padding 120 / 90 / 60.

## Images and credits

None. The map comes from the embed URL you pass.

## Approximations

- **Map on phone.** The 390 capture shows the form and white space below it
  (the iframe had not loaded); the map is placed below the form at 320px tall.
- **Select options.** The reference's Freight Type list repeats "Air Freight";
  its Load list is "1 Ton / 2 Ton". Both are replaced with sensible lists.
- **Field focus** is an outline rather than the reference's shadow transition.
- Tablet layout is inferred.

===== END src/components/MapContactForm.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.

--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.

--- 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
“Map contact form” from my Astro Component Library:
https://astro.baysixmedia.com/components/map-contact-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
Ships script
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/MapContactForm.astro

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

---
/**
 * MapContactForm
 *
 * A contact section: a centred orange eyebrow between two short rules and a
 * big navy heading, then two columns — an embedded map on the left and a
 * light grey rounded form panel on the right (name, email, phone, freight
 * type, load, message, and a full-width orange "Submit Request" pill).
 *
 * The map is a lazy <iframe> of `mapEmbedUrl`. Unset (the default), there is
 * no map and the form stands alone, centred; `mapEmbedUrl: ""` renders a
 * labelled placeholder instead and makes no third-party request. No heading
 * or place is assumed. The form is native: `action` is unset by default,
 * every control has a real (visually hidden) label.
 *
 * Appear on scroll: the title rises 40px; the map slides in from −40px and
 * the form from +40px. Without JS or under reduced motion all at rest.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface MapContactLabels {
	name?: string;
	email?: string;
	phone?: string;
	freightType?: string;
	load?: string;
	message?: string;
}

export interface MapContactFormProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** A map embed URL. Unset: no map. `""` renders the labelled placeholder instead. */
	mapEmbedUrl?: string;
	/** The iframe's title — what the map shows. */
	mapTitle?: string;
	/** Where the form posts. Unset: the browser submits to the current URL. */
	action?: string;
	method?: "get" | "post";
	formName?: string;
	submitLabel?: string;
	/** Freight type options. `[]` drops the select. */
	freightTypes?: string[];
	/** Load options. `[]` drops the select. */
	loads?: string[];
	/** Accessible labels (visually hidden). */
	labels?: MapContactLabels;
	/** Placeholders — hints only, never the label. */
	placeholders?: MapContactLabels;
	background?: string;
	fontStylesheet?: string;
	class?: string;
	/** Root id. Also seeds every field id. */
	id?: string;
}

interface Props extends MapContactFormProps {}

const {
	eyebrow = "Get In Touch",
	heading = "",
	headingLevel = "h2",
	mapEmbedUrl,
	mapTitle = "",
	action,
	method = "post",
	formName,
	submitLabel = "Submit Request",
	freightTypes = ["Air Freight", "Sea Freight", "Road Freight", "Rail Freight"],
	loads = ["Under 1 ton", "1 ton", "2 tons", "5 tons", "Full truckload"],
	labels = {},
	placeholders = {},
	background = "#ffffff",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
const uid = (id ?? `mcf-${Math.random().toString(36).slice(2, 9)}`).replace(/[^A-Za-z0-9_-]/g, "") || "mcf";
const fid = (name: string) => `${uid}-${name}`;

/* Only an http(s) page may be framed. */
const mapSrc = mapEmbedUrl && /^https:\/\//i.test(mapEmbedUrl.trim()) ? mapEmbedUrl.trim() : "";
/* No map at all unless one (or the placeholder, with "") is asked for. */
const showMap = mapEmbedUrl !== undefined;
const mapLabel = mapTitle || "Map";

const L: Required<MapContactLabels> = {
	name: "Your name",
	email: "Your email",
	phone: "Phone number",
	freightType: "Freight type",
	load: "Load",
	message: "Message",
	...labels,
};

const P: Required<MapContactLabels> = {
	name: "Your Name",
	email: "Your Email",
	phone: "Phone No:",
	freightType: "Freight Type",
	load: "Load",
	message: "Message",
	...placeholders,
};
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["mcf", className]} id={id} data-mcf-root style={`--mcf-band: ${background};`}>
	<div class="mcf-inner">
		{(eyebrow || heading) && (
		<div class="mcf-title" data-mcf-appear>
			{
				eyebrow && (
					<p class="mcf-eyebrow">
						<span class="mcf-rule" aria-hidden="true" />
						<span>{eyebrow}</span>
						<span class="mcf-rule" aria-hidden="true" />
					</p>
				)
			}
			{heading && <Heading class="mcf-heading">{heading}</Heading>}
		</div>
		)}

		<div class="mcf-body" style={showMap ? undefined : "grid-template-columns: minmax(0, 630px); justify-content: center;"}>
			{showMap && (
			<div class="mcf-map" data-mcf-appear="left">
				{
					mapSrc ? (
						<iframe
							src={mapSrc}
							title={mapLabel}
							loading="lazy"
							referrerpolicy="no-referrer-when-downgrade"
							allowfullscreen
						/>
					) : (
						<div class="mcf-ph" role="img" aria-label={`Map placeholder: ${mapLabel}`}>
							<div class="mcf-ph-text" aria-hidden="true">
								<svg viewBox="0 0 24 24" width="28" height="28"><path fill="currentColor" d="M12 2a7 7 0 0 0-7 7c0 5 6.1 11.4 6.4 11.7a.8.8 0 0 0 1.2 0C12.9 20.4 19 14 19 9a7 7 0 0 0-7-7Zm0 9.6A2.6 2.6 0 1 1 12 6.4a2.6 2.6 0 0 1 0 5.2Z" /></svg>
								<span class="mcf-ph-label">Map</span>
								<span class="mcf-ph-hint">{mapLabel}</span>
								<span class="mcf-ph-spec">Pass mapEmbedUrl to embed a map</span>
							</div>
						</div>
					)
				}
			</div>
			)}

			<form class="mcf-form" action={action} method={method} name={formName} data-mcf-appear="right">
				<div class="mcf-field">
					<label class="mcf-sr" for={fid("name")}>{L.name}</label>
					<input class="mcf-input" type="text" id={fid("name")} name="name" autocomplete="name" placeholder={P.name} required />
					<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M6 6a2.6 2.6 0 1 0 0-5.2A2.6 2.6 0 0 0 6 6Zm0 1.1c-2.4 0-4.6 1.2-4.6 2.9v1.2h9.2V10c0-1.7-2.2-2.9-4.6-2.9Z" /></svg>
				</div>
				<div class="mcf-pair">
					<div class="mcf-field">
						<label class="mcf-sr" for={fid("email")}>{L.email}</label>
						<input class="mcf-input" type="email" id={fid("email")} name="email" autocomplete="email" placeholder={P.email} required />
						<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M1 2.5h10a.5.5 0 0 1 .5.5v.4L6 6.6.5 3.4V3a.5.5 0 0 1 .5-.5Zm-.5 2.1L6 7.8l5.5-3.2V9a.5.5 0 0 1-.5.5H1a.5.5 0 0 1-.5-.5Z" /></svg>
					</div>
					<div class="mcf-field">
						<label class="mcf-sr" for={fid("phone")}>{L.phone}</label>
						<input class="mcf-input" type="tel" id={fid("phone")} name="phone" autocomplete="tel" placeholder={P.phone} />
						<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M3.3 5.3a8 8 0 0 0 3.4 3.4l1.1-1.1a.5.5 0 0 1 .5-.13 5.7 5.7 0 0 0 1.8.29.5.5 0 0 1 .5.5V10a.5.5 0 0 1-.5.5A8.5 8.5 0 0 1 1.5 2a.5.5 0 0 1 .5-.5h1.75a.5.5 0 0 1 .5.5c0 .63.1 1.23.29 1.8a.5.5 0 0 1-.13.5Z" /></svg>
					</div>
				</div>
				{(freightTypes.length > 0 || loads.length > 0) && (
				<div class="mcf-pair">
					{freightTypes.length > 0 && (
					<div class="mcf-field">
						<label class="mcf-sr" for={fid("freight")}>{L.freightType}</label>
						<select class="mcf-input mcf-select" id={fid("freight")} name="freightType">
							<option value="" selected>{P.freightType}</option>
							{freightTypes.map((o) => <option value={o}>{o}</option>)}
						</select>
						<svg class="mcf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
					</div>
					)}
					{loads.length > 0 && (
					<div class="mcf-field">
						<label class="mcf-sr" for={fid("load")}>{L.load}</label>
						<select class="mcf-input mcf-select" id={fid("load")} name="load">
							<option value="" selected>{P.load}</option>
							{loads.map((o) => <option value={o}>{o}</option>)}
						</select>
						<svg class="mcf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
					</div>
					)}
				</div>
				)}
				<div class="mcf-field">
					<label class="mcf-sr" for={fid("message")}>{L.message}</label>
					<textarea class="mcf-input mcf-textarea" id={fid("message")} name="message" rows="5" placeholder={P.message} required></textarea>
				</div>
				<button class="mcf-btn" type="submit">{submitLabel}</button>
			</form>
		</div>
	</div>
</section>

<style>
	.mcf {
		--mcf-navy: var(--tg-navy, #020e28);
		--mcf-orange: var(--tg-orange, #fd5523);
		--mcf-muted: var(--tg-muted, #61697d);
		--mcf-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--mcf-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
		--mcf-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		padding: 0 0 60px;
		background: var(--mcf-band);
		color: var(--mcf-navy);
		font-family: var(--mcf-body);
		/* The form's entrance starts 40px to the right; until it plays, that
		   offset must not push the page sideways on a narrow screen. `clip`, not
		   `hidden`: no scroll container, so nothing about focus or sticky changes. */
		overflow-x: clip;
	}

	.mcf *,
	.mcf *::before,
	.mcf *::after {
		box-sizing: border-box;
	}

	.mcf-inner {
		display: flex;
		flex-direction: column;
		gap: 40px;
		max-width: 1293px;
		margin: 0 auto;
		padding: 0 20px;
		/* Contains the ±40px appear slides at every width. */
		overflow: clip;
		overflow-clip-margin: 8px;
	}

	.mcf-title {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 20px;
		text-align: center;
	}

	.mcf .mcf-eyebrow {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		font: 600 16px/1 var(--mcf-display);
		text-transform: uppercase;
		color: var(--mcf-orange);
	}

	.mcf-rule {
		display: block;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.mcf-heading {
		margin: 0;
		max-width: 680px;
		font: 600 32px/1.2 var(--mcf-display);
		color: var(--mcf-navy);
		overflow-wrap: anywhere;
	}

	.mcf-body {
		display: flex;
		flex-direction: column;
		gap: 10px;
	}

	/* On a phone the form comes first, as the reference does; the map's one
	   tab stop (the iframe) then follows the form in visual order too. */
	.mcf-map {
		order: 1;
		position: relative;
		height: 320px;
		border-radius: 20px;
		overflow: hidden;
		background: #e9ecef;
	}

	.mcf-map iframe {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		border: 0;
	}

	.mcf-map:has(iframe:focus-visible) {
		outline: 3px solid var(--mcf-orange);
		outline-offset: 3px;
	}

	.mcf-ph {
		position: absolute;
		inset: 0;
		display: grid;
		place-items: center;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.mcf-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

	.mcf-ph-text svg {
		color: var(--mcf-orange);
	}

	.mcf-ph-label {
		font: 500 12px/1 var(--mcf-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.mcf-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--mcf-body);
	}

	.mcf-ph-spec {
		font: 400 12px/1.2 var(--mcf-body);
	}

	.mcf-form {
		display: flex;
		flex-direction: column;
		gap: 12px;
		min-width: 0;
		margin: 0;
		padding: 16px;
		border-radius: 20px;
		background: #f4f4f4;
	}

	.mcf-pair {
		display: grid;
		gap: 12px;
	}

	.mcf-field {
		position: relative;
		min-width: 0;
	}

	.mcf-input {
		display: block;
		width: 100%;
		min-height: 44px;
		margin: 0;
		padding: 11px 44px 11px 18px;
		border: 0;
		border-radius: 100px;
		background: #fff;
		color: var(--mcf-navy);
		font: 400 16px/20px var(--mcf-body);
	}

	.mcf-input::placeholder {
		color: var(--mcf-muted);
		opacity: 1;
	}

	/* An outline, not a shadow: it survives forced colours. */
	.mcf-input:focus {
		outline: 2px solid var(--mcf-orange);
		outline-offset: 2px;
	}

	.mcf-select {
		appearance: none;
		-webkit-appearance: none;
		cursor: pointer;
	}

	.mcf-select:has(option[value=""]:checked) {
		color: var(--mcf-muted);
	}

	.mcf-select option {
		color: var(--mcf-navy);
	}

	.mcf-textarea {
		min-height: 100px;
		padding: 14px 18px;
		border-radius: 20px;
		resize: vertical;
	}

	.mcf-icon,
	.mcf-chevron {
		position: absolute;
		top: 50%;
		right: 20px;
		width: 12px;
		height: 12px;
		transform: translateY(-50%);
		color: var(--mcf-orange);
		pointer-events: none;
	}

	.mcf-chevron {
		width: 18px;
		height: 18px;
		right: 18px;
	}

	.mcf-btn {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 100%;
		min-height: 48px;
		padding: 16px 20px;
		border: 0;
		border-radius: 100px;
		background: var(--mcf-orange);
		color: #fff;
		font: 600 14px/1 var(--mcf-body);
		cursor: pointer;
		transition: background-color 0.35s ease-out;
	}

	.mcf-btn:hover {
		background: var(--mcf-navy);
	}

	.mcf-btn:focus-visible {
		outline: 2px solid var(--mcf-navy);
		outline-offset: 3px;
	}

	.mcf-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	/* ---- appear ---- */
	[data-mcf-appear] {
		transition:
			opacity 0.8s var(--mcf-ease),
			transform 0.8s var(--mcf-ease);
	}

	.mcf[data-mcf-anim] [data-mcf-appear]:not([data-in]) {
		opacity: 0;
		transform: translateY(40px);
	}

	.mcf[data-mcf-anim] [data-mcf-appear="left"]:not([data-in]) {
		transform: translateX(-40px);
	}

	.mcf[data-mcf-anim] [data-mcf-appear="right"]:not([data-in]) {
		transform: translateX(40px);
	}

	/* ---- tablet ---- */
	@media (min-width: 810px) {
		.mcf {
			padding-bottom: 90px;
		}

		.mcf-inner {
			gap: 50px;
		}

		.mcf-heading {
			font-size: 40px;
		}

		.mcf-form {
			gap: 25px;
			padding: 40px;
		}

		.mcf-pair {
			grid-template-columns: repeat(2, minmax(0, 1fr));
			gap: 30px;
		}

		/* One select left (the other's options were emptied): full width. */
		.mcf-pair:has(> .mcf-field:only-child) {
			grid-template-columns: minmax(0, 1fr);
		}

		.mcf-input {
			min-height: 56px;
			padding: 17px 48px 17px 24px;
		}

		.mcf-textarea {
			min-height: 175px;
			padding: 16px 24px;
		}

		.mcf-icon {
			right: 24px;
		}

		.mcf-chevron {
			right: 22px;
		}

		.mcf-btn {
			min-height: 56px;
			padding: 21px 20px;
		}

		.mcf-map {
			height: 400px;
		}
	}

	/* ---- desktop: map left, form right ---- */
	@media (min-width: 1200px) {
		.mcf {
			padding-bottom: 120px;
		}

		.mcf-inner {
			gap: 60px;
		}

		.mcf .mcf-eyebrow {
			font-size: 18px;
		}

		.mcf-heading {
			font-size: 48px;
			line-height: 57.6px;
		}

		.mcf-body {
			display: grid;
			grid-template-columns: minmax(0, 589fr) minmax(0, 630fr);
			padding: 0 12px;
		}

		.mcf-map {
			order: 0;
			height: auto;
			min-height: 579px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		[data-mcf-appear] {
			transition: none;
		}
	}

	@media (forced-colors: active) {
		.mcf-input,
		.mcf-btn {
			border: 1px solid ButtonText;
		}

		.mcf-form,
		.mcf-map {
			border: 1px solid CanvasText;
		}

		.mcf-icon,
		.mcf-chevron,
		.mcf-rule {
			color: CanvasText;
		}

		.mcf-rule {
			background: CanvasText;
		}
	}
</style>

<script>
	/*
	 * Appear on scroll only. The form is native and the map is an iframe;
	 * neither needs script. One IntersectionObserver per instance; each
	 * watched element gets `data-in` once. Released on astro:before-swap.
	 */
	function initMapContactForm(root: HTMLElement) {
		if (root.dataset.mcfReady !== undefined) return;
		root.dataset.mcfReady = "";
		if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
		if (!("IntersectionObserver" in window)) return;

		const controller = new AbortController();
		const { signal } = controller;
		root.dataset.mcfAnim = "";

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					(entry.target as HTMLElement).dataset.in = "";
					observer.unobserve(entry.target);
				}
			},
			{ threshold: 0.1 },
		);
		root.querySelectorAll<HTMLElement>("[data-mcf-appear]").forEach((el) => observer.observe(el));
		signal.addEventListener("abort", () => observer.disconnect());

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.mcfReady;
				delete root.dataset.mcfAnim;
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-mcf-root]").forEach(initMapContactForm);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>