Component library
Back to catalog

Shipment quote form

A freight quote request in one rounded, hairline-bordered card: portrait photo left, a real form right in two fieldsets — Personal Info and Shipment Info (freight type, cities, incoterms, weight and dimensions) — pill inputs with orange icons and an orange Request A Quote pill.

formquotefreightlogisticsappear

Open preview in a new tab

Use this component

Three ways to get Shipment quote 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 2 files 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 Shipment quote form from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/shipment-quote-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 2 files 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: "Shipment quote form"
Library page: https://astro.baysixmedia.com/components/shipment-quote-form/
Files: 2 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/ShipmentQuoteForm.astro =====
---
/**
 * ShipmentQuoteForm
 *
 * A freight quote request in one rounded, hairline-bordered card: an optional
 * portrait photo on the left and a real form on the right, in two fieldsets —
 * "Personal Info" (name, email, phone) and "Shipment Info" (freight type,
 * city of departure, delivery city, incoterms, weight, height, width,
 * length) — separated by hairlines, then an orange "Request A Quote ↗" pill.
 *
 * Every field is a pill with a small orange icon or chevron. The design shows
 * placeholders only, so every control also has a real, visually hidden
 * <label>. `action` is unset by default: point it at your handler.
 *
 * Appear on scroll: the photo slides in from −40px, the two fieldsets and the
 * button rise 40px, staggered 0.1s. Without JS or under reduced motion
 * everything is at rest.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface ShipmentQuoteLabels {
	name?: string;
	email?: string;
	phone?: string;
	freightType?: string;
	origin?: string;
	destination?: string;
	incoterm?: string;
	weight?: string;
	height?: string;
	width?: string;
	length?: string;
}

export interface ShipmentQuoteFormProps {
	/** Where the form posts. Unset: the browser submits to the current URL. */
	action?: string;
	method?: "get" | "post";
	formName?: string;
	personalLegend?: string;
	shipmentLegend?: string;
	submitLabel?: string;
	/** Freight type options (required select). `[]` drops the select. */
	freightTypes?: string[];
	/** Incoterm options (optional select). `[]` drops the select. */
	incoterms?: string[];
	/** Accessible labels (visually hidden). */
	labels?: ShipmentQuoteLabels;
	/** Placeholders — hints only, never the label. */
	placeholders?: ShipmentQuoteLabels;
	/** The portrait photo. Unset or `null`: none, and the form spans the card. `src: ""` renders the labelled placeholder. */
	image?: { src?: string; alt?: string } | null;
	/** The band around the card. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	/** Root id. Also seeds every field id. */
	id?: string;
}

interface Props extends ShipmentQuoteFormProps {}

const {
	action,
	method = "post",
	formName,
	personalLegend = "Personal Info",
	shipmentLegend = "Shipment Info",
	submitLabel = "Request A Quote",
	freightTypes = ["Air Freight", "Sea Freight", "Road Freight", "Rail Freight", "Warehousing"],
	incoterms = ["EXW", "FCA", "FOB", "CFR", "CIF", "DAP", "DDP"],
	labels = {},
	placeholders = {},
	image: imageProp,
	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;

/* No photo unless one is passed; a passed photo is taken as given, alt "" (decorative) if unset. */
const image = imageProp ? { src: imageProp.src ?? "", alt: imageProp.alt ?? "" } : null;

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

const L: Required<ShipmentQuoteLabels> = {
	name: "Your name",
	email: "Your email",
	phone: "Phone number",
	freightType: "Freight type",
	origin: "City of departure",
	destination: "Delivery city",
	incoterm: "Incoterms",
	weight: "Weight (kg)",
	height: "Height (cm)",
	width: "Width (cm)",
	length: "Length (cm)",
	...labels,
};

const P: Required<ShipmentQuoteLabels> = {
	name: "Your Name",
	email: "Your Email",
	phone: "Phone No:",
	freightType: "Freight Type",
	origin: "City Departure",
	destination: "Delivery City",
	incoterm: "Incoterms",
	weight: "Weight",
	height: "Height",
	width: "Width",
	length: "Length",
	...placeholders,
};

const dims = [
	{ key: "weight", name: "weight" },
	{ key: "height", name: "height" },
	{ key: "width", name: "width" },
	{ key: "length", name: "length" },
] as const;
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["sqf", className]} id={id} data-sqf-root style={`--sqf-band: ${background};`}>
	<div class="sqf-inner">
		<div class:list={["sqf-card", { "sqf-card--solo": !image }]}>
			{
				image && (
					<div class="sqf-media" data-sqf-appear="left">
						{image.src ? (
							<img src={image.src} alt={image.alt} width="716" height="964" loading="lazy" decoding="async" />
						) : (
							<div class="sqf-ph" role="img" aria-label="Image placeholder: truck in a freight yard, portrait">
								<div class="sqf-ph-text" aria-hidden="true">
									<span class="sqf-ph-label">Image</span>
									<span class="sqf-ph-hint">Truck in a freight yard, portrait</span>
									<span class="sqf-ph-spec">~3:4 · 716 × 964 px</span>
								</div>
							</div>
						)}
					</div>
				)
			}

			<form class="sqf-form" action={action} method={method} name={formName}>
				<fieldset class="sqf-set" data-sqf-appear style="--sqf-i: 0">
					<legend class="sqf-legend">{personalLegend}</legend>
					<div class="sqf-row sqf-row--3">
						<div class="sqf-field">
							<label class="sqf-sr" for={fid("name")}>{L.name}</label>
							<input class="sqf-input" type="text" id={fid("name")} name="name" autocomplete="name" placeholder={P.name} required />
							<svg class="sqf-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="sqf-field">
							<label class="sqf-sr" for={fid("email")}>{L.email}</label>
							<input class="sqf-input" type="email" id={fid("email")} name="email" autocomplete="email" placeholder={P.email} required />
							<svg class="sqf-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="sqf-field">
							<label class="sqf-sr" for={fid("phone")}>{L.phone}</label>
							<input class="sqf-input" type="tel" id={fid("phone")} name="phone" autocomplete="tel" placeholder={P.phone} />
							<svg class="sqf-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>
				</fieldset>

				<fieldset class="sqf-set" data-sqf-appear style="--sqf-i: 1">
					<legend class="sqf-legend">{shipmentLegend}</legend>
					<div class="sqf-row sqf-row--3">
						{freightTypes.length > 0 && (
						<div class="sqf-field sqf-field--select">
							<label class="sqf-sr" for={fid("freight")}>{L.freightType}</label>
							<select class="sqf-input sqf-select" id={fid("freight")} name="freightType" required>
								<option value="" selected disabled>{P.freightType}</option>
								{freightTypes.map((o) => <option value={o}>{o}</option>)}
							</select>
							<svg class="sqf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
						</div>
						)}
						<div class="sqf-field">
							<label class="sqf-sr" for={fid("origin")}>{L.origin}</label>
							<input class="sqf-input sqf-input--plain" type="text" id={fid("origin")} name="originCity" autocomplete="address-level2" placeholder={P.origin} required />
						</div>
						<div class="sqf-field">
							<label class="sqf-sr" for={fid("destination")}>{L.destination}</label>
							<input class="sqf-input sqf-input--plain" type="text" id={fid("destination")} name="destinationCity" placeholder={P.destination} required />
						</div>
					</div>
					<div class="sqf-row sqf-row--split">
						{incoterms.length > 0 && (
						<div class="sqf-field sqf-field--select">
							<label class="sqf-sr" for={fid("incoterm")}>{L.incoterm}</label>
							<select class="sqf-input sqf-select" id={fid("incoterm")} name="incoterm">
								<option value="" selected>{P.incoterm}</option>
								{incoterms.map((o) => <option value={o}>{o}</option>)}
							</select>
							<svg class="sqf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
						</div>
						)}
						<div class="sqf-row sqf-row--dims">
							{dims.map((d) => (
								<div class="sqf-field">
									<label class="sqf-sr" for={fid(d.name)}>{L[d.key]}</label>
									<input class="sqf-input sqf-input--plain sqf-input--dim" type="text" inputmode="decimal" id={fid(d.name)} name={d.name} placeholder={P[d.key]} />
								</div>
							))}
						</div>
					</div>
				</fieldset>

				<div class="sqf-submit-wrap" data-sqf-appear style="--sqf-i: 2">
					<button class="sqf-btn" type="submit">
						{submitLabel}
						<svg class="sqf-btn-arrow" viewBox="0 0 12 12" width="11" height="11" aria-hidden="true">
							<path d="M2 10 10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
						</svg>
					</button>
				</div>
			</form>
		</div>
	</div>
</section>

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

		box-sizing: border-box;
		padding: 60px 0;
		/* Contains the photo's −40px appear slide on narrow screens. */
		overflow-x: clip;
		background: var(--sqf-band);
		color: var(--sqf-navy);
		font-family: var(--sqf-body);
	}

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

	.sqf-inner {
		max-width: 1293px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.sqf-card {
		display: flex;
		flex-direction: column;
		gap: 30px;
		padding: 16px;
		border: 1px solid var(--sqf-line);
		border-radius: 30px;
	}

	/* On phones and tablets the form comes first and the photo follows it,
	   as the reference does. The photo holds nothing focusable, so the swap
	   cannot scramble the tab order. */
	.sqf-media {
		order: 1;
		position: relative;
		aspect-ratio: 4 / 3.5;
		border-radius: 20px;
		overflow: hidden;
	}

	.sqf-media img {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.sqf-form {
		display: flex;
		flex-direction: column;
		gap: 16px;
		min-width: 0;
		margin: 0;
	}

	.sqf .sqf-set {
		min-width: 0;
		margin: 0;
		padding: 0 0 16px;
		border: 0;
		border-bottom: 1px solid var(--sqf-line);
	}

	.sqf .sqf-legend {
		float: left;
		width: 100%;
		margin: 0 0 16px;
		padding: 0;
		font: 500 20px/34px var(--sqf-display);
		color: var(--sqf-navy);
	}

	/* The floated legend needs its following rows cleared. */
	.sqf-legend + .sqf-row {
		clear: both;
	}

	.sqf-row {
		display: grid;
		gap: 12px;
		min-width: 0;
	}

	.sqf-row + .sqf-row {
		margin-top: 12px;
	}

	.sqf-row--dims {
		margin-top: 0;
	}

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

	.sqf-input {
		display: block;
		width: 100%;
		min-height: 44px;
		margin: 0;
		padding: 11px 44px 11px 18px;
		border: 1px solid var(--sqf-line);
		border-radius: 100px;
		background: #fff;
		color: var(--sqf-navy);
		font: 400 16px/20px var(--sqf-body);
		transition: border-color 0.3s ease-out;
	}

	.sqf-input--plain {
		padding-right: 18px;
	}

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

	.sqf-input:hover {
		border-color: #b9c0c7;
	}

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

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

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

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

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

	.sqf-chevron {
		width: 18px;
		height: 18px;
		right: 18px;
		color: var(--sqf-muted);
	}

	.sqf-submit-wrap {
		padding-top: 8px;
	}

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

	.sqf-btn-arrow {
		flex: none;
		transition: transform 0.35s ease-out;
	}

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

	.sqf-btn:hover .sqf-btn-arrow {
		transform: rotate(45deg);
	}

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

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

	.sqf-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);
	}

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

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

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

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

	/* ---- appear ---- */
	[data-sqf-appear] {
		transition:
			opacity 0.8s var(--sqf-ease),
			transform 0.8s var(--sqf-ease);
		transition-delay: calc(var(--sqf-i, 0) * 0.1s);
	}

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

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

	/* ---- tablet: three-up rows, photo still below ---- */
	@media (min-width: 810px) {
		.sqf {
			padding: 90px 0;
		}

		.sqf-card {
			padding: 30px;
		}

		.sqf-form {
			gap: 25px;
		}

		.sqf-submit-wrap {
			padding-top: 11px;
		}

		.sqf .sqf-set {
			padding-bottom: 25px;
		}

		.sqf .sqf-legend {
			margin-bottom: 25px;
			font-size: 24px;
		}

		.sqf-row {
			gap: 25px 30px;
		}

		.sqf-row + .sqf-row {
			margin-top: 25px;
		}

		.sqf-row--dims {
			margin-top: 0;
		}

		.sqf-row--3 {
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}

		.sqf-row--split {
			grid-template-columns: minmax(0, 228fr) minmax(0, 482fr);
		}

		.sqf-row--dims {
			grid-template-columns: repeat(4, minmax(0, 1fr));
		}

		/* Incoterms dropped: the four measurements take the whole row. */
		.sqf-row--split:has(> .sqf-row--dims:only-child) {
			grid-template-columns: minmax(0, 1fr);
		}

		.sqf-input {
			min-height: 56px;
			padding: 17px 44px 17px 24px;
		}

		.sqf-input--plain {
			padding-right: 24px;
		}

		.sqf-input--dim {
			padding-inline: 10px;
			text-align: center;
		}

		.sqf-icon {
			right: 24px;
		}

		.sqf-btn {
			width: 240px;
			min-height: 58px;
			padding: 22px 20px;
		}

		.sqf-media {
			aspect-ratio: 16 / 9;
		}
	}

	/* ---- desktop: photo left, form right ---- */
	@media (min-width: 1200px) {
		.sqf {
			padding: 120px 0;
		}

		.sqf-card {
			display: grid;
			grid-template-columns: minmax(0, 358fr) minmax(0, 740fr);
			justify-content: space-between;
			align-items: start;
			gap: 95px;
		}

		.sqf-card--solo {
			grid-template-columns: minmax(0, 1fr);
		}

		.sqf-media {
			order: 0;
			align-self: stretch;
			aspect-ratio: auto;
			min-height: 482px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.sqf-btn:hover .sqf-btn-arrow {
			transform: none;
		}

		[data-sqf-appear] {
			transition: none;
		}
	}

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

		.sqf-icon,
		.sqf-chevron {
			color: CanvasText;
		}
	}
</style>

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

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

		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-sqf-appear]").forEach((el) => observer.observe(el));
		signal.addEventListener("abort", () => observer.disconnect());

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

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

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

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

===== END src/components/ShipmentQuoteForm.astro =====

===== FILE (supporting): src/components/ShipmentQuoteForm.md =====
# Shipment quote form

A freight quote request in one rounded, hairline-bordered card: an optional
portrait photo on the left, a real form on the right in two fieldsets — **Personal
Info** (name, email, phone) and **Shipment Info** (freight type, city of
departure, delivery city, incoterms, weight, height, width, length) —
separated by hairlines, then an orange **Request A Quote ↗** pill.

Catalog page: `/components/shipment-quote-form`

## Purpose

The body of a "Request a quote" page. Collects enough to price a lane in one
screen. Part of the Northpass (Transigo-style) family; reads the `--tg-*`
tokens.

## Files required

**`ShipmentQuoteForm.astro`** — the whole component. No imports.

`ShipmentQuoteForm.md` — this file.

`public/shipment-quote-form/truck-yard.webp` (716×964) — the catalog demo's
photo, passed as a prop. Only needed if you pass it: there is no default
photo, so with no props the form fills the whole card.

## Basic usage

```astro
<ShipmentQuoteForm action="/api/quote" method="post" />
```

## It has no back end

**`action` is unset by default**, so the browser submits to the current URL.
Point it at your handler. There is no success/error state; whatever receives
the request owns the response.

Field names as submitted: `name`, `email`, `phone`, `freightType`,
`originCity`, `destinationCity`, `incoterm`, `weight`, `height`, `width`,
`length`. Required: name, email, freight type, both cities.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `action` | `string?` | — | See above. |
| `method` | `"get" \| "post"?` | `"post"` | |
| `formName` | `string?` | — | Added to the `<form>`. |
| `personalLegend` | `string?` | `"Personal Info"` | First fieldset's legend. |
| `shipmentLegend` | `string?` | `"Shipment Info"` | Second fieldset's legend. |
| `submitLabel` | `string?` | `"Request A Quote"` | |
| `freightTypes` | `string[]?` | Air, Sea, Road, Rail, Warehousing | Required select. `[]` drops it. |
| `incoterms` | `string[]?` | EXW, FCA, FOB, CFR, CIF, DAP, DDP | Optional select. `[]` drops it. |
| `labels` | `ShipmentQuoteLabels?` | English | The visually hidden labels (e.g. `"Weight (kg)"`). |
| `placeholders` | `ShipmentQuoteLabels?` | as the design | Hints only. |
| `image` | `{ src?, alt? } \| null` | none | Unset or `null`: no photo column. `src: ""` → labelled placeholder. `alt` defaults to `""` (decorative). |
| `background` | `string?` | `#ffffff` | The band around the card. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips the link. |
| `class` | `string?` | — | |
| `id` | `string?` | — | Root id. **Also seeds every field id**; random when omitted, so two forms never collide. |

`ShipmentQuoteLabels` keys: `name, email, phone, freightType, origin,
destination, incoterm, weight, height, width, length`.

## Behaviour

| Element | Effect | Measured / chosen |
| :--- | :--- | :--- |
| Submit hover | background `#fd5523` → `#020e28`, ↗ rotates 45° | as measured; 0.35s ease-out |
| Field hover | hairline darkens `#d8dde1` → `#b9c0c7` | addition |
| Field focus | 2px orange outline, 2px offset | see Accessibility |
| Photo appear | from `translateX(-40px)`, fading | split-section rule from the brief |
| Fieldsets and button appear | from `translateY(40px)`, staggered 0.1s | form-row rule from the brief |

One IntersectionObserver per instance; hidden poses only while the root has
`data-sqf-anim`; released on `astro:before-swap`.

## Accessibility

- Two real `<fieldset>`s with `<legend>`s, which is what groups the fields for
  assistive technology (the reference uses `h5`s).
- **Every control has a real `<label for>`**, visually hidden because the
  design shows placeholders only. No placeholder is doing a label's job.
- Inputs are **16px** so iOS Safari does not zoom on focus.
- `autocomplete` on name, email, phone and origin city; dimensions use
  `inputmode="decimal"`.
- The focus indicator is an `outline`, so it survives forced colours; icons
  and chevrons are `aria-hidden` and `pointer-events: none`.
- The select placeholder is a disabled, pre-selected empty option on the
  required select (so `required` works) and a normal empty option on the
  optional one; both render in the muted colour while empty.

## Reduced motion / without JavaScript

Nothing starts hidden; the arrow does not rotate. The form is entirely native
and works identically without JS.

## Responsive

| Width | Layout |
| :--- | :--- |
| ≥ 1200 | photo 358 / form 740 (of 1193), 95px apart, card padding 30, radius 30; 56px pills; rows of three; incoterms 228 + four 98px dimension pills |
| 810–1199 | form full width with the same rows; photo below at 16:9 |
| < 810 | card padding 16; every field full width, 44px min height; full-width button; photo below the form (as the phone capture) |

## Images and credits

`truck-yard.webp` (catalog demo only) — Unsplash `9unRFufhNUw` (Unsplash
License — free for commercial use, no attribution required), optimised to
WebP. Decorative (`alt=""`).

## Approximations

- **Appear.** The quote page's own appear offsets were not sampled; the
  family's measured rules (split image from −40px, form rows rise 40px) are
  applied.
- **Card border and field hairlines** use `#d8dde1`, the measured line colour;
  the dump omits the card border, which is read from the screenshot.
- **Field focus** is an outline, not the reference's shadow transition, so it
  survives forced colours.
- **Chevrons** are muted grey here as on the reference quote page (the
  contact page's are orange).
- Tablet layout is inferred.

===== END src/components/ShipmentQuoteForm.md =====

===== FILE (required): public/shipment-quote-form/truck-yard.webp =====
[binary asset — download https://astro.baysixmedia.com/shipment-quote-form/truck-yard.webp and save it to public/shipment-quote-form/truck-yard.webp]

--- 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
“Shipment quote form” from my Astro Component Library:
https://astro.baysixmedia.com/components/shipment-quote-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
2 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/ShipmentQuoteForm.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/shipment-quote-form/truck-yard.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * ShipmentQuoteForm
 *
 * A freight quote request in one rounded, hairline-bordered card: an optional
 * portrait photo on the left and a real form on the right, in two fieldsets —
 * "Personal Info" (name, email, phone) and "Shipment Info" (freight type,
 * city of departure, delivery city, incoterms, weight, height, width,
 * length) — separated by hairlines, then an orange "Request A Quote ↗" pill.
 *
 * Every field is a pill with a small orange icon or chevron. The design shows
 * placeholders only, so every control also has a real, visually hidden
 * <label>. `action` is unset by default: point it at your handler.
 *
 * Appear on scroll: the photo slides in from −40px, the two fieldsets and the
 * button rise 40px, staggered 0.1s. Without JS or under reduced motion
 * everything is at rest.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface ShipmentQuoteLabels {
	name?: string;
	email?: string;
	phone?: string;
	freightType?: string;
	origin?: string;
	destination?: string;
	incoterm?: string;
	weight?: string;
	height?: string;
	width?: string;
	length?: string;
}

export interface ShipmentQuoteFormProps {
	/** Where the form posts. Unset: the browser submits to the current URL. */
	action?: string;
	method?: "get" | "post";
	formName?: string;
	personalLegend?: string;
	shipmentLegend?: string;
	submitLabel?: string;
	/** Freight type options (required select). `[]` drops the select. */
	freightTypes?: string[];
	/** Incoterm options (optional select). `[]` drops the select. */
	incoterms?: string[];
	/** Accessible labels (visually hidden). */
	labels?: ShipmentQuoteLabels;
	/** Placeholders — hints only, never the label. */
	placeholders?: ShipmentQuoteLabels;
	/** The portrait photo. Unset or `null`: none, and the form spans the card. `src: ""` renders the labelled placeholder. */
	image?: { src?: string; alt?: string } | null;
	/** The band around the card. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	/** Root id. Also seeds every field id. */
	id?: string;
}

interface Props extends ShipmentQuoteFormProps {}

const {
	action,
	method = "post",
	formName,
	personalLegend = "Personal Info",
	shipmentLegend = "Shipment Info",
	submitLabel = "Request A Quote",
	freightTypes = ["Air Freight", "Sea Freight", "Road Freight", "Rail Freight", "Warehousing"],
	incoterms = ["EXW", "FCA", "FOB", "CFR", "CIF", "DAP", "DDP"],
	labels = {},
	placeholders = {},
	image: imageProp,
	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;

/* No photo unless one is passed; a passed photo is taken as given, alt "" (decorative) if unset. */
const image = imageProp ? { src: imageProp.src ?? "", alt: imageProp.alt ?? "" } : null;

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

const L: Required<ShipmentQuoteLabels> = {
	name: "Your name",
	email: "Your email",
	phone: "Phone number",
	freightType: "Freight type",
	origin: "City of departure",
	destination: "Delivery city",
	incoterm: "Incoterms",
	weight: "Weight (kg)",
	height: "Height (cm)",
	width: "Width (cm)",
	length: "Length (cm)",
	...labels,
};

const P: Required<ShipmentQuoteLabels> = {
	name: "Your Name",
	email: "Your Email",
	phone: "Phone No:",
	freightType: "Freight Type",
	origin: "City Departure",
	destination: "Delivery City",
	incoterm: "Incoterms",
	weight: "Weight",
	height: "Height",
	width: "Width",
	length: "Length",
	...placeholders,
};

const dims = [
	{ key: "weight", name: "weight" },
	{ key: "height", name: "height" },
	{ key: "width", name: "width" },
	{ key: "length", name: "length" },
] as const;
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["sqf", className]} id={id} data-sqf-root style={`--sqf-band: ${background};`}>
	<div class="sqf-inner">
		<div class:list={["sqf-card", { "sqf-card--solo": !image }]}>
			{
				image && (
					<div class="sqf-media" data-sqf-appear="left">
						{image.src ? (
							<img src={image.src} alt={image.alt} width="716" height="964" loading="lazy" decoding="async" />
						) : (
							<div class="sqf-ph" role="img" aria-label="Image placeholder: truck in a freight yard, portrait">
								<div class="sqf-ph-text" aria-hidden="true">
									<span class="sqf-ph-label">Image</span>
									<span class="sqf-ph-hint">Truck in a freight yard, portrait</span>
									<span class="sqf-ph-spec">~3:4 · 716 × 964 px</span>
								</div>
							</div>
						)}
					</div>
				)
			}

			<form class="sqf-form" action={action} method={method} name={formName}>
				<fieldset class="sqf-set" data-sqf-appear style="--sqf-i: 0">
					<legend class="sqf-legend">{personalLegend}</legend>
					<div class="sqf-row sqf-row--3">
						<div class="sqf-field">
							<label class="sqf-sr" for={fid("name")}>{L.name}</label>
							<input class="sqf-input" type="text" id={fid("name")} name="name" autocomplete="name" placeholder={P.name} required />
							<svg class="sqf-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="sqf-field">
							<label class="sqf-sr" for={fid("email")}>{L.email}</label>
							<input class="sqf-input" type="email" id={fid("email")} name="email" autocomplete="email" placeholder={P.email} required />
							<svg class="sqf-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="sqf-field">
							<label class="sqf-sr" for={fid("phone")}>{L.phone}</label>
							<input class="sqf-input" type="tel" id={fid("phone")} name="phone" autocomplete="tel" placeholder={P.phone} />
							<svg class="sqf-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>
				</fieldset>

				<fieldset class="sqf-set" data-sqf-appear style="--sqf-i: 1">
					<legend class="sqf-legend">{shipmentLegend}</legend>
					<div class="sqf-row sqf-row--3">
						{freightTypes.length > 0 && (
						<div class="sqf-field sqf-field--select">
							<label class="sqf-sr" for={fid("freight")}>{L.freightType}</label>
							<select class="sqf-input sqf-select" id={fid("freight")} name="freightType" required>
								<option value="" selected disabled>{P.freightType}</option>
								{freightTypes.map((o) => <option value={o}>{o}</option>)}
							</select>
							<svg class="sqf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
						</div>
						)}
						<div class="sqf-field">
							<label class="sqf-sr" for={fid("origin")}>{L.origin}</label>
							<input class="sqf-input sqf-input--plain" type="text" id={fid("origin")} name="originCity" autocomplete="address-level2" placeholder={P.origin} required />
						</div>
						<div class="sqf-field">
							<label class="sqf-sr" for={fid("destination")}>{L.destination}</label>
							<input class="sqf-input sqf-input--plain" type="text" id={fid("destination")} name="destinationCity" placeholder={P.destination} required />
						</div>
					</div>
					<div class="sqf-row sqf-row--split">
						{incoterms.length > 0 && (
						<div class="sqf-field sqf-field--select">
							<label class="sqf-sr" for={fid("incoterm")}>{L.incoterm}</label>
							<select class="sqf-input sqf-select" id={fid("incoterm")} name="incoterm">
								<option value="" selected>{P.incoterm}</option>
								{incoterms.map((o) => <option value={o}>{o}</option>)}
							</select>
							<svg class="sqf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
						</div>
						)}
						<div class="sqf-row sqf-row--dims">
							{dims.map((d) => (
								<div class="sqf-field">
									<label class="sqf-sr" for={fid(d.name)}>{L[d.key]}</label>
									<input class="sqf-input sqf-input--plain sqf-input--dim" type="text" inputmode="decimal" id={fid(d.name)} name={d.name} placeholder={P[d.key]} />
								</div>
							))}
						</div>
					</div>
				</fieldset>

				<div class="sqf-submit-wrap" data-sqf-appear style="--sqf-i: 2">
					<button class="sqf-btn" type="submit">
						{submitLabel}
						<svg class="sqf-btn-arrow" viewBox="0 0 12 12" width="11" height="11" aria-hidden="true">
							<path d="M2 10 10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
						</svg>
					</button>
				</div>
			</form>
		</div>
	</div>
</section>

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

		box-sizing: border-box;
		padding: 60px 0;
		/* Contains the photo's −40px appear slide on narrow screens. */
		overflow-x: clip;
		background: var(--sqf-band);
		color: var(--sqf-navy);
		font-family: var(--sqf-body);
	}

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

	.sqf-inner {
		max-width: 1293px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.sqf-card {
		display: flex;
		flex-direction: column;
		gap: 30px;
		padding: 16px;
		border: 1px solid var(--sqf-line);
		border-radius: 30px;
	}

	/* On phones and tablets the form comes first and the photo follows it,
	   as the reference does. The photo holds nothing focusable, so the swap
	   cannot scramble the tab order. */
	.sqf-media {
		order: 1;
		position: relative;
		aspect-ratio: 4 / 3.5;
		border-radius: 20px;
		overflow: hidden;
	}

	.sqf-media img {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.sqf-form {
		display: flex;
		flex-direction: column;
		gap: 16px;
		min-width: 0;
		margin: 0;
	}

	.sqf .sqf-set {
		min-width: 0;
		margin: 0;
		padding: 0 0 16px;
		border: 0;
		border-bottom: 1px solid var(--sqf-line);
	}

	.sqf .sqf-legend {
		float: left;
		width: 100%;
		margin: 0 0 16px;
		padding: 0;
		font: 500 20px/34px var(--sqf-display);
		color: var(--sqf-navy);
	}

	/* The floated legend needs its following rows cleared. */
	.sqf-legend + .sqf-row {
		clear: both;
	}

	.sqf-row {
		display: grid;
		gap: 12px;
		min-width: 0;
	}

	.sqf-row + .sqf-row {
		margin-top: 12px;
	}

	.sqf-row--dims {
		margin-top: 0;
	}

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

	.sqf-input {
		display: block;
		width: 100%;
		min-height: 44px;
		margin: 0;
		padding: 11px 44px 11px 18px;
		border: 1px solid var(--sqf-line);
		border-radius: 100px;
		background: #fff;
		color: var(--sqf-navy);
		font: 400 16px/20px var(--sqf-body);
		transition: border-color 0.3s ease-out;
	}

	.sqf-input--plain {
		padding-right: 18px;
	}

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

	.sqf-input:hover {
		border-color: #b9c0c7;
	}

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

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

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

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

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

	.sqf-chevron {
		width: 18px;
		height: 18px;
		right: 18px;
		color: var(--sqf-muted);
	}

	.sqf-submit-wrap {
		padding-top: 8px;
	}

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

	.sqf-btn-arrow {
		flex: none;
		transition: transform 0.35s ease-out;
	}

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

	.sqf-btn:hover .sqf-btn-arrow {
		transform: rotate(45deg);
	}

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

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

	.sqf-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);
	}

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

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

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

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

	/* ---- appear ---- */
	[data-sqf-appear] {
		transition:
			opacity 0.8s var(--sqf-ease),
			transform 0.8s var(--sqf-ease);
		transition-delay: calc(var(--sqf-i, 0) * 0.1s);
	}

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

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

	/* ---- tablet: three-up rows, photo still below ---- */
	@media (min-width: 810px) {
		.sqf {
			padding: 90px 0;
		}

		.sqf-card {
			padding: 30px;
		}

		.sqf-form {
			gap: 25px;
		}

		.sqf-submit-wrap {
			padding-top: 11px;
		}

		.sqf .sqf-set {
			padding-bottom: 25px;
		}

		.sqf .sqf-legend {
			margin-bottom: 25px;
			font-size: 24px;
		}

		.sqf-row {
			gap: 25px 30px;
		}

		.sqf-row + .sqf-row {
			margin-top: 25px;
		}

		.sqf-row--dims {
			margin-top: 0;
		}

		.sqf-row--3 {
			grid-template-columns: repeat(3, minmax(0, 1fr));
		}

		.sqf-row--split {
			grid-template-columns: minmax(0, 228fr) minmax(0, 482fr);
		}

		.sqf-row--dims {
			grid-template-columns: repeat(4, minmax(0, 1fr));
		}

		/* Incoterms dropped: the four measurements take the whole row. */
		.sqf-row--split:has(> .sqf-row--dims:only-child) {
			grid-template-columns: minmax(0, 1fr);
		}

		.sqf-input {
			min-height: 56px;
			padding: 17px 44px 17px 24px;
		}

		.sqf-input--plain {
			padding-right: 24px;
		}

		.sqf-input--dim {
			padding-inline: 10px;
			text-align: center;
		}

		.sqf-icon {
			right: 24px;
		}

		.sqf-btn {
			width: 240px;
			min-height: 58px;
			padding: 22px 20px;
		}

		.sqf-media {
			aspect-ratio: 16 / 9;
		}
	}

	/* ---- desktop: photo left, form right ---- */
	@media (min-width: 1200px) {
		.sqf {
			padding: 120px 0;
		}

		.sqf-card {
			display: grid;
			grid-template-columns: minmax(0, 358fr) minmax(0, 740fr);
			justify-content: space-between;
			align-items: start;
			gap: 95px;
		}

		.sqf-card--solo {
			grid-template-columns: minmax(0, 1fr);
		}

		.sqf-media {
			order: 0;
			align-self: stretch;
			aspect-ratio: auto;
			min-height: 482px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.sqf-btn:hover .sqf-btn-arrow {
			transform: none;
		}

		[data-sqf-appear] {
			transition: none;
		}
	}

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

		.sqf-icon,
		.sqf-chevron {
			color: CanvasText;
		}
	}
</style>

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

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

		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-sqf-appear]").forEach((el) => observer.observe(el));
		signal.addEventListener("abort", () => observer.disconnect());

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

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

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

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