Component library
Back to catalog

Contact switchboard

A contact section organised around why the visitor is getting in touch: one framed board of reasons — urgent problem, booking, quote, billing — each with a line on what it covers, what happens next, and the channel that suits it as a full-strength action (call, email or the site's own request page), with the urgent route set in reverse. Hours, towns served and the office address close the board in a tinted strip. Every channel and fact only if supplied.

contactroutingclick-to-callemailurgenthourslightlocal service

Open preview in a new tab

Use this component

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

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

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 switchboard"
Library page: https://astro.baysixmedia.com/components/contact-switchboard/
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/ContactSwitchboard.astro =====
---
/**
 * ContactSwitchboard (candidate — original, role `contact`)
 *
 * A contact section organised around why the visitor is getting in touch, not around the list of
 * channels. Under a split header (heading left, intro right) sits one framed board: each row is a
 * reason — "Something is broken right now", "A quote for new work", "Billing and paperwork" — with a
 * line on what it covers, a line on what happens next, and the channel that suits it best as a
 * full-strength action (call, email, or the site's own booking/quote page), with an optional second
 * channel under it. One row can be marked as the priority route and is set in reverse, so the urgent
 * path is the first thing the eye lands on. The board closes on a tinted strip of the standing facts
 * — hours, towns served, the office address — each only if supplied.
 *
 * Truth rules. Nothing about the business has a default. A route renders only when at least one of
 * its channels was supplied (a phone number, an email address, a real page link); a channel that was
 * not supplied is dropped from every row. With no routes passed, one plain row per supplied channel
 * is drawn. With no channel at all, the section renders nothing. tel: and mailto: are built from the
 * supplied values; the address links only to a supplied https maps URL.
 *
 * No script. Container queries: stacked below 600px of the section's own width, reason | actions from
 * 600px, reason | what happens | actions with column heads from 900px.
 *
 * Reads, never declares: --csw-bg, --csw-panel, --csw-text, --csw-text-muted, --csw-brand,
 * --csw-brand-text, --csw-border, --csw-strip, --csw-family.
 */

export type ContactSwitchboardChannel = "phone" | "email" | "request";

export interface ContactSwitchboardRoute {
	/** The reason for getting in touch, in the visitor's words. */
	need: string;
	/** One line on what the reason covers. */
	detail?: string;
	/** One line on what happens after the visitor uses this route. */
	expect?: string;
	/** The channels for this reason, best first: the first supplied one is the main action, the second a quieter alternative. */
	via: readonly ContactSwitchboardChannel[];
	/** Set this row in reverse as the urgent path. */
	priority?: boolean;
}

export interface ContactSwitchboardProps {
	heading: string;
	headingLevel?: "h2" | "h3";
	eyebrow?: string;
	intro?: string;
	phone?: { number: string; label?: string; href?: string } | null;
	email?: string;
	/** The site's own booking, quote or form page. */
	request?: { label: string; href: string } | null;
	routes?: readonly ContactSwitchboardRoute[];
	hours?: string | readonly string[];
	serviceAreas?: readonly string[];
	address?: string | readonly string[];
	addressHref?: string;
	labels?: {
		call?: string;
		email?: string;
		byPhone?: string;
		byEmail?: string;
		need?: string;
		expect?: string;
		reach?: string;
		hours?: string;
		areas?: string;
		address?: string;
	};
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends ContactSwitchboardProps {}

const {
	heading,
	headingLevel = "h2",
	eyebrow = "",
	intro = "",
	phone = null,
	email = "",
	request = null,
	routes = [],
	hours,
	serviceAreas = [],
	address,
	addressHref,
	labels = {},
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400..700&display=swap",
	class: className,
	id,
} = Astro.props;

const L = {
	call: "Call",
	email: "Email",
	byPhone: "By phone",
	byEmail: "By email",
	need: "If you need",
	expect: "What happens",
	reach: "Best way to reach us",
	hours: "Hours",
	areas: "Towns served",
	address: "Office",
	...labels,
};

const safeHref = (href: string | undefined) => {
	const h = (href ?? "").trim();
	return /^(https?:\/\/|mailto:|tel:|#|\/(?!\/)|\.{1,2}\/|\?)/i.test(h) ? h : "";
};
const lines = (v: string | readonly string[] | undefined) =>
	(Array.isArray(v) ? v : typeof v === "string" && v ? [v] : []).map((x) => String(x).trim()).filter(Boolean);

/* ---- the channels that were actually supplied ---- */
const number = phone?.number?.trim() ?? "";
const digits = number.replace(/\D/g, "");
const telHref = digits.length >= 7 ? `tel:${number.startsWith("+") ? "+" : ""}${digits}` : "";
const callHref = number ? safeHref(phone?.href) || telHref : "";
const mail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? email.trim() : "";
/* Break points for a long address: after @, before dots, after hyphens and underscores. */
const mailParts = mail.split(/(?<=[@_-])|(?=\.)/);
const requestHref = request?.label?.trim() ? safeHref(request.href) : "";

type Action = { kind: ContactSwitchboardChannel; href: string };
const channel = (kind: ContactSwitchboardChannel): Action | null => {
	if (kind === "phone") return callHref ? { kind, href: callHref } : null;
	if (kind === "email") return mail ? { kind, href: `mailto:${mail}` } : null;
	return requestHref ? { kind, href: requestHref } : null;
};

type Row = { need: string; detail: string; expect: string; priority: boolean; actions: Action[] };
const supplied: Row[] = (routes ?? [])
	.map((r) => {
		const via = (Array.isArray(r?.via) ? r.via : [r?.via]) as ContactSwitchboardChannel[];
		const actions = [...new Set(via)].map(channel).filter((a): a is Action => a !== null).slice(0, 2);
		return { need: (r?.need ?? "").trim(), detail: (r?.detail ?? "").trim(), expect: (r?.expect ?? "").trim(), priority: Boolean(r?.priority), actions };
	})
	.filter((r) => r.need && r.actions.length > 0);

/* No routes passed: one plain row per supplied channel. */
const fallback: Row[] = (["phone", "email", "request"] as const)
	.map(channel)
	.filter((a): a is Action => a !== null)
	.map((a) => ({
		need: a.kind === "phone" ? L.byPhone : a.kind === "email" ? L.byEmail : request!.label.trim(),
		detail: "",
		expect: "",
		priority: false,
		actions: [a],
	}));

const routed = (routes ?? []).length > 0;
const rows = routed ? supplied : fallback;
/* Only the first priority row is set in reverse. */
const priorityIndex = rows.findIndex((r) => r.priority);
const hasExpect = rows.some((r) => r.expect);

const hourLines = lines(hours);
const areas = (serviceAreas ?? []).map((a) => String(a).trim()).filter(Boolean);
const addressLines = lines(address);
const mapHref = addressHref && /^https:\/\//i.test(addressHref.trim()) ? addressHref.trim() : "";
const hasFacts = hourLines.length > 0 || areas.length > 0 || addressLines.length > 0;

const uid = `${id ?? "csw"}-${Math.random().toString(36).slice(2, 7)}`;
const Heading = headingLevel;
const RowHeading = headingLevel === "h2" ? "h3" : "h4";
---

{
	rows.length > 0 && (
		<section class:list={["csw", { "csw--expect": hasExpect }, className]} id={id} aria-labelledby={`${uid}-title`}>
			{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
			<div class="csw-inner">
				<header class="csw-head">
					<div class="csw-titles">
						{eyebrow.trim() && <p class="csw-eyebrow">{eyebrow.trim()}</p>}
						<Heading class="csw-title" id={`${uid}-title`}>
							{heading}
						</Heading>
					</div>
					{intro.trim() && <p class="csw-intro">{intro.trim()}</p>}
				</header>

				<div class="csw-board">
					{routed && (
						<div class="csw-cols" aria-hidden="true">
							<span>{L.need}</span>
							{hasExpect && <span>{L.expect}</span>}
							<span>{L.reach}</span>
						</div>
					)}

					<ul class="csw-routes" role="list">
						{rows.map((row, i) => (
							<li class:list={["csw-route", { "csw-route--priority": i === priorityIndex }]}>
								<div class="csw-need">
									<RowHeading class="csw-need-title">{row.need}</RowHeading>
									{row.detail && <p class="csw-detail">{row.detail}</p>}
								</div>
								{hasExpect && <div class="csw-expect">{row.expect && <p>{row.expect}</p>}</div>}
								<div class="csw-reach">
									{row.actions.map((a, n) => (
										<a class:list={["csw-action", n === 0 ? "csw-action--main" : "csw-action--alt", `csw-action--${a.kind}`]} href={a.href}>
											<span class="csw-action-text">
												{a.kind === "phone" && (
													<>
														{phone?.label?.trim() || L.call} <span class="csw-action-value csw-nowrap">{number}</span>
													</>
												)}
												{a.kind === "email" && (
													<>
														{L.email}{" "}
														<span class="csw-action-value csw-mail">
															{mailParts.map((part, k) => (
																<>
																	{k > 0 && <wbr />}
																	{part}
																</>
															))}
														</span>
													</>
												)}
												{a.kind === "request" && request!.label.trim()}
											</span>
											<svg class="csw-arrow" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
												<path d="M4 10h11M11 5.5 15.5 10 11 14.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
											</svg>
										</a>
									))}
								</div>
							</li>
						))}
					</ul>

					{
						hasFacts && (
							<dl class="csw-facts">
								{hourLines.length > 0 && (
									<div class="csw-fact">
										<dt>{L.hours}</dt>
										<dd>
											{hourLines.map((h) => (
												<span class:list={["csw-line", { "csw-nowrap": h.length <= 26 }]}>{h}</span>
											))}
										</dd>
									</div>
								)}
								{areas.length > 0 && (
									<div class="csw-fact csw-fact--areas">
										<dt>{L.areas}</dt>
										<dd>
											<ul class="csw-areas" role="list">
												{areas.map((a) => (
													<li>{a}</li>
												))}
											</ul>
										</dd>
									</div>
								)}
								{addressLines.length > 0 && (
									<div class="csw-fact">
										<dt>{L.address}</dt>
										<dd>
											{mapHref ? (
												<a class="csw-address-link" href={mapHref}>
													{addressLines.map((l) => (
														<span class="csw-line">{l}</span>
													))}
												</a>
											) : (
												addressLines.map((l) => <span class="csw-line">{l}</span>)
											)}
										</dd>
									</div>
								)}
							</dl>
						)
					}
				</div>
			</div>
		</section>
	)
}

<style>
	.csw {
		--_bg: var(--csw-bg, #f5f3ed);
		--_panel: var(--csw-panel, #ffffff);
		--_ink: var(--csw-text, #16181b);
		--_soft: var(--csw-text-muted, #4b4f56);
		--_brand: var(--csw-brand, #b93d0c);
		--_brand-text: var(--csw-brand-text, #ffffff);
		--_rule: var(--csw-border, #d8d4c9);
		--_strip: var(--csw-strip, #ece8de);
		--_font: var(--csw-family, "Instrument Sans", ui-sans-serif, system-ui, sans-serif);

		container-type: inline-size;
		background: var(--_bg);
		color: var(--_ink);
		font-family: var(--_font);
	}

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

	.csw-inner {
		max-width: 1240px;
		margin: 0 auto;
		padding: 64px 16px;
	}

	/* ---- header: heading left, intro right ---- */

	.csw-head {
		display: grid;
		gap: 18px;
		margin-bottom: 36px;
	}

	.csw-titles {
		min-width: 0;
	}

	.csw-eyebrow {
		margin: 0 0 12px;
		color: var(--_brand);
		font: 600 15px/1.4 var(--_font);
	}

	.csw-title {
		max-width: 17ch;
		margin: 0;
		font: 700 clamp(32px, 8.4cqi, 60px) / 1.04 var(--_font);
		letter-spacing: -0.035em;
		text-wrap: balance;
		overflow-wrap: break-word;
	}

	.csw-intro {
		max-width: 46ch;
		margin: 0;
		color: var(--_soft);
		font: 400 17px/1.6 var(--_font);
		text-wrap: pretty;
	}

	/* ---- the board ---- */

	.csw-board {
		overflow: hidden;
		border: 1.5px solid var(--_ink);
		border-radius: 6px;
		background: var(--_panel);
	}

	.csw-cols {
		display: none;
	}

	.csw-routes {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.csw-route {
		display: grid;
		gap: 16px;
		padding: 24px 20px;
		border-top: 1px solid var(--_rule);
	}

	.csw-route:first-child {
		border-top: 0;
	}

	.csw-need,
	.csw-expect,
	.csw-reach {
		min-width: 0;
	}

	.csw-need-title {
		margin: 0;
		font: 650 clamp(21px, 3.1cqi, 27px) / 1.2 var(--_font);
		letter-spacing: -0.02em;
		text-wrap: balance;
		overflow-wrap: break-word;
	}

	.csw-detail {
		max-width: 44ch;
		margin: 8px 0 0;
		color: var(--_soft);
		font: 400 16px/1.55 var(--_font);
	}

	.csw-expect:empty {
		display: none;
	}

	.csw-expect p {
		max-width: 40ch;
		margin: 0;
		padding-left: 14px;
		border-left: 3px solid var(--_brand);
		color: var(--_ink);
		font: 500 16px/1.5 var(--_font);
	}

	/* ---- actions ---- */

	.csw-reach {
		display: grid;
		gap: 6px;
		align-content: start;
	}

	.csw-action {
		color: inherit;
		text-decoration: none;
		transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, text-decoration-color 0.15s ease;
	}

	.csw-action--main {
		display: flex;
		align-items: center;
		gap: 12px;
		min-height: 52px;
		padding: 12px 18px;
		border: 1.5px solid var(--_ink);
		border-radius: 6px;
		background: var(--_ink);
		color: var(--_bg);
		font: 600 17px/1.25 var(--_font);
	}

	.csw-action--main:hover {
		border-color: var(--_brand);
		background: var(--_brand);
		color: var(--_brand-text);
	}

	.csw-action--alt {
		justify-self: start;
		display: inline-block;
		max-width: 100%;
		padding: 12px 2px;
		color: var(--_ink);
		font: 600 16px/1.3 var(--_font);
		text-decoration: underline;
		text-decoration-thickness: 1.5px;
		text-decoration-color: var(--_rule);
		text-underline-offset: 5px;
	}

	.csw-action--alt:hover {
		text-decoration-color: var(--_brand);
	}

	.csw-action--alt .csw-arrow {
		display: none;
	}

	.csw-action-text {
		flex: 1;
		min-width: 0;
	}

	.csw-action-value {
		font-variant-numeric: tabular-nums;
	}

	.csw-action--main .csw-action-value {
		font-weight: 700;
	}

	.csw-mail {
		min-width: 0;
		overflow-wrap: anywhere;
	}

	.csw-action--main .csw-mail {
		display: block;
		margin-top: 2px;
		font-size: 15px;
		font-weight: 500;
	}

	.csw-nowrap {
		white-space: nowrap;
	}

	.csw-arrow {
		flex: none;
	}

	.csw-action:focus-visible,
	.csw-address-link:focus-visible {
		outline: 3px solid var(--_brand);
		outline-offset: 3px;
	}

	/* ---- the priority route, set in reverse ---- */

	.csw-route--priority {
		border-top-color: var(--_ink);
		background: var(--_ink);
		color: var(--_bg);
	}

	.csw-route--priority + .csw-route {
		border-top-color: var(--_ink);
	}

	.csw-route--priority .csw-detail {
		color: color-mix(in srgb, var(--_bg) 80%, var(--_ink));
	}

	.csw-route--priority .csw-expect p {
		color: var(--_bg);
	}

	.csw-route--priority .csw-action--main {
		border-color: var(--_brand);
		background: var(--_brand);
		color: var(--_brand-text);
	}

	.csw-route--priority .csw-action--main:hover {
		border-color: var(--_bg);
		background: var(--_bg);
		color: var(--_ink);
	}

	.csw-route--priority .csw-action--alt {
		color: var(--_bg);
		text-decoration-color: color-mix(in srgb, var(--_bg) 45%, var(--_ink));
	}

	.csw-route--priority .csw-action--alt:hover {
		text-decoration-color: var(--_bg);
	}

	.csw-route--priority .csw-action:focus-visible {
		outline-color: var(--_bg);
	}

	/* ---- standing facts ---- */

	.csw-facts {
		display: grid;
		margin: 0;
		border-top: 1.5px solid var(--_ink);
		background: var(--_strip);
	}

	.csw-fact {
		display: grid;
		gap: 6px;
		min-width: 0;
		padding: 18px 20px;
		border-top: 1px solid var(--_rule);
	}

	.csw-fact:first-child {
		border-top: 0;
	}

	.csw-fact dt {
		color: var(--_soft);
		font: 600 14px/1.4 var(--_font);
	}

	.csw-fact dd {
		margin: 0;
		font: 500 16px/1.5 var(--_font);
		overflow-wrap: break-word;
	}

	.csw-line {
		display: block;
	}

	.csw-areas {
		display: flex;
		flex-wrap: wrap;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.csw-areas li {
		white-space: nowrap;
	}

	.csw-areas li:not(:last-child)::after {
		content: "·";
		margin: 0 0.5em;
		color: var(--_soft);
	}

	.csw-address-link {
		display: inline-block;
		color: var(--_ink);
		text-decoration: underline;
		text-decoration-color: var(--_rule);
		text-decoration-thickness: 1.5px;
		text-underline-offset: 4px;
	}

	.csw-address-link:hover {
		text-decoration-color: var(--_brand);
	}

	/* ---- 600px: reason | actions ---- */

	@container (min-width: 600px) {
		.csw-inner {
			padding: 80px 28px;
		}

		.csw-route {
			grid-template-columns: minmax(0, 1fr) minmax(230px, 0.62fr);
			column-gap: 28px;
			align-items: start;
			padding: 28px 28px;
		}

		.csw-expect {
			grid-column: 1;
			grid-row: 2;
		}

		.csw-reach {
			grid-column: 2;
			grid-row: 1;
		}

		.csw--expect .csw-reach {
			grid-row: 1 / span 2;
		}

		.csw-facts {
			grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
		}

		.csw-fact {
			padding: 20px 28px;
			border-top: 0;
			border-left: 1px solid var(--_rule);
		}

		.csw-fact:first-child {
			border-left: 0;
		}

		.csw-fact--areas {
			grid-column: span 2;
		}
	}

	/* ---- 900px: reason | what happens | actions, with column heads ---- */

	@container (min-width: 900px) {
		.csw-inner {
			padding: 104px 40px;
		}

		.csw-head {
			grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
			column-gap: 64px;
			align-items: end;
			margin-bottom: 48px;
		}

		.csw-cols {
			display: grid;
			column-gap: 40px;
			padding: 14px 36px;
			border-bottom: 1px solid var(--_rule);
			color: var(--_soft);
			font: 600 14px/1.4 var(--_font);
		}

		.csw-cols,
		.csw-route {
			grid-template-columns: minmax(0, 1fr) minmax(280px, 0.62fr);
		}

		.csw--expect .csw-cols,
		.csw--expect .csw-route {
			grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(280px, 0.78fr);
		}

		.csw-route {
			column-gap: 40px;
			padding: 32px 36px;
		}

		.csw-route:first-child {
			border-top: 0;
		}

		.csw--expect .csw-expect {
			grid-column: 2;
			grid-row: 1;
			padding-top: 4px;
		}

		.csw--expect .csw-expect:empty {
			display: block;
		}

		.csw--expect .csw-reach {
			grid-column: 3;
			grid-row: 1;
		}

		.csw-fact {
			padding: 22px 36px;
		}

		.csw-facts {
			grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.csw-action {
			transition: none;
		}
	}

	@media (forced-colors: active) {
		.csw-action--main {
			border: 2px solid ButtonText;
		}

		.csw-route--priority {
			border: 2px solid CanvasText;
		}
	}
</style>

===== END src/components/ContactSwitchboard.astro =====

===== FILE (supporting): src/components/ContactSwitchboard.md =====
# Contact switchboard

A contact section organised around why the visitor is getting in touch: one framed board of reasons — urgent problem, booking, quote, billing — each with a line on what it covers, what happens next, and the channel that suits it as a full-strength action (call, email or the site's own request page), with the urgent route set in reverse. Hours, towns served and the office address close the board in a tinted strip. Every channel and fact only if supplied.

## Integration

- A section heading (h2 by default); each route's reason is one level down. The routes are a list inside one framed board.
- Channels are stated once — phone, email, request — and routes name which to use. A channel that is not supplied is dropped from every route; a route left with no channel is dropped; with no routes, one plain row per supplied channel; with no channel at all, nothing renders.
- The first route with priority: true is set in reverse (ink ground, accent action). Use it for the genuinely urgent path only.
- Hours, towns and address render in the closing strip only when passed; the address links only to a supplied https maps URL. The number never wraps; a long email address breaks inside its own action.
- Container queries: stacked below 600px; reason | actions from 600px; column heads and reason | what happens | actions from 900px (two columns when no route has a what-happens line). Reads --csw-bg, --csw-panel, --csw-text, --csw-text-muted, --csw-brand, --csw-brand-text, --csw-border, --csw-strip and --csw-family.
- No script. Loads Instrument Sans from Google Fonts by default; pass fontStylesheet="" when the site loads it.

## Provenance

Staged as candidate `contact-switchboard` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): none. Quality history: quality/candidates/records/contact-switchboard.json.

===== END src/components/ContactSwitchboard.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: 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 switchboard” from my Astro Component Library:
https://astro.baysixmedia.com/components/contact-switchboard/

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
Never
Client navigation
Not applicable — ships no script
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 fixture

src/components/library/ContactSwitchboard.astro

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

---
/**
 * ContactSwitchboard (candidate — original, role `contact`)
 *
 * A contact section organised around why the visitor is getting in touch, not around the list of
 * channels. Under a split header (heading left, intro right) sits one framed board: each row is a
 * reason — "Something is broken right now", "A quote for new work", "Billing and paperwork" — with a
 * line on what it covers, a line on what happens next, and the channel that suits it best as a
 * full-strength action (call, email, or the site's own booking/quote page), with an optional second
 * channel under it. One row can be marked as the priority route and is set in reverse, so the urgent
 * path is the first thing the eye lands on. The board closes on a tinted strip of the standing facts
 * — hours, towns served, the office address — each only if supplied.
 *
 * Truth rules. Nothing about the business has a default. A route renders only when at least one of
 * its channels was supplied (a phone number, an email address, a real page link); a channel that was
 * not supplied is dropped from every row. With no routes passed, one plain row per supplied channel
 * is drawn. With no channel at all, the section renders nothing. tel: and mailto: are built from the
 * supplied values; the address links only to a supplied https maps URL.
 *
 * No script. Container queries: stacked below 600px of the section's own width, reason | actions from
 * 600px, reason | what happens | actions with column heads from 900px.
 *
 * Reads, never declares: --csw-bg, --csw-panel, --csw-text, --csw-text-muted, --csw-brand,
 * --csw-brand-text, --csw-border, --csw-strip, --csw-family.
 */

export type ContactSwitchboardChannel = "phone" | "email" | "request";

export interface ContactSwitchboardRoute {
	/** The reason for getting in touch, in the visitor's words. */
	need: string;
	/** One line on what the reason covers. */
	detail?: string;
	/** One line on what happens after the visitor uses this route. */
	expect?: string;
	/** The channels for this reason, best first: the first supplied one is the main action, the second a quieter alternative. */
	via: readonly ContactSwitchboardChannel[];
	/** Set this row in reverse as the urgent path. */
	priority?: boolean;
}

export interface ContactSwitchboardProps {
	heading: string;
	headingLevel?: "h2" | "h3";
	eyebrow?: string;
	intro?: string;
	phone?: { number: string; label?: string; href?: string } | null;
	email?: string;
	/** The site's own booking, quote or form page. */
	request?: { label: string; href: string } | null;
	routes?: readonly ContactSwitchboardRoute[];
	hours?: string | readonly string[];
	serviceAreas?: readonly string[];
	address?: string | readonly string[];
	addressHref?: string;
	labels?: {
		call?: string;
		email?: string;
		byPhone?: string;
		byEmail?: string;
		need?: string;
		expect?: string;
		reach?: string;
		hours?: string;
		areas?: string;
		address?: string;
	};
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends ContactSwitchboardProps {}

const {
	heading,
	headingLevel = "h2",
	eyebrow = "",
	intro = "",
	phone = null,
	email = "",
	request = null,
	routes = [],
	hours,
	serviceAreas = [],
	address,
	addressHref,
	labels = {},
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400..700&display=swap",
	class: className,
	id,
} = Astro.props;

const L = {
	call: "Call",
	email: "Email",
	byPhone: "By phone",
	byEmail: "By email",
	need: "If you need",
	expect: "What happens",
	reach: "Best way to reach us",
	hours: "Hours",
	areas: "Towns served",
	address: "Office",
	...labels,
};

const safeHref = (href: string | undefined) => {
	const h = (href ?? "").trim();
	return /^(https?:\/\/|mailto:|tel:|#|\/(?!\/)|\.{1,2}\/|\?)/i.test(h) ? h : "";
};
const lines = (v: string | readonly string[] | undefined) =>
	(Array.isArray(v) ? v : typeof v === "string" && v ? [v] : []).map((x) => String(x).trim()).filter(Boolean);

/* ---- the channels that were actually supplied ---- */
const number = phone?.number?.trim() ?? "";
const digits = number.replace(/\D/g, "");
const telHref = digits.length >= 7 ? `tel:${number.startsWith("+") ? "+" : ""}${digits}` : "";
const callHref = number ? safeHref(phone?.href) || telHref : "";
const mail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? email.trim() : "";
/* Break points for a long address: after @, before dots, after hyphens and underscores. */
const mailParts = mail.split(/(?<=[@_-])|(?=\.)/);
const requestHref = request?.label?.trim() ? safeHref(request.href) : "";

type Action = { kind: ContactSwitchboardChannel; href: string };
const channel = (kind: ContactSwitchboardChannel): Action | null => {
	if (kind === "phone") return callHref ? { kind, href: callHref } : null;
	if (kind === "email") return mail ? { kind, href: `mailto:${mail}` } : null;
	return requestHref ? { kind, href: requestHref } : null;
};

type Row = { need: string; detail: string; expect: string; priority: boolean; actions: Action[] };
const supplied: Row[] = (routes ?? [])
	.map((r) => {
		const via = (Array.isArray(r?.via) ? r.via : [r?.via]) as ContactSwitchboardChannel[];
		const actions = [...new Set(via)].map(channel).filter((a): a is Action => a !== null).slice(0, 2);
		return { need: (r?.need ?? "").trim(), detail: (r?.detail ?? "").trim(), expect: (r?.expect ?? "").trim(), priority: Boolean(r?.priority), actions };
	})
	.filter((r) => r.need && r.actions.length > 0);

/* No routes passed: one plain row per supplied channel. */
const fallback: Row[] = (["phone", "email", "request"] as const)
	.map(channel)
	.filter((a): a is Action => a !== null)
	.map((a) => ({
		need: a.kind === "phone" ? L.byPhone : a.kind === "email" ? L.byEmail : request!.label.trim(),
		detail: "",
		expect: "",
		priority: false,
		actions: [a],
	}));

const routed = (routes ?? []).length > 0;
const rows = routed ? supplied : fallback;
/* Only the first priority row is set in reverse. */
const priorityIndex = rows.findIndex((r) => r.priority);
const hasExpect = rows.some((r) => r.expect);

const hourLines = lines(hours);
const areas = (serviceAreas ?? []).map((a) => String(a).trim()).filter(Boolean);
const addressLines = lines(address);
const mapHref = addressHref && /^https:\/\//i.test(addressHref.trim()) ? addressHref.trim() : "";
const hasFacts = hourLines.length > 0 || areas.length > 0 || addressLines.length > 0;

const uid = `${id ?? "csw"}-${Math.random().toString(36).slice(2, 7)}`;
const Heading = headingLevel;
const RowHeading = headingLevel === "h2" ? "h3" : "h4";
---

{
	rows.length > 0 && (
		<section class:list={["csw", { "csw--expect": hasExpect }, className]} id={id} aria-labelledby={`${uid}-title`}>
			{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
			<div class="csw-inner">
				<header class="csw-head">
					<div class="csw-titles">
						{eyebrow.trim() && <p class="csw-eyebrow">{eyebrow.trim()}</p>}
						<Heading class="csw-title" id={`${uid}-title`}>
							{heading}
						</Heading>
					</div>
					{intro.trim() && <p class="csw-intro">{intro.trim()}</p>}
				</header>

				<div class="csw-board">
					{routed && (
						<div class="csw-cols" aria-hidden="true">
							<span>{L.need}</span>
							{hasExpect && <span>{L.expect}</span>}
							<span>{L.reach}</span>
						</div>
					)}

					<ul class="csw-routes" role="list">
						{rows.map((row, i) => (
							<li class:list={["csw-route", { "csw-route--priority": i === priorityIndex }]}>
								<div class="csw-need">
									<RowHeading class="csw-need-title">{row.need}</RowHeading>
									{row.detail && <p class="csw-detail">{row.detail}</p>}
								</div>
								{hasExpect && <div class="csw-expect">{row.expect && <p>{row.expect}</p>}</div>}
								<div class="csw-reach">
									{row.actions.map((a, n) => (
										<a class:list={["csw-action", n === 0 ? "csw-action--main" : "csw-action--alt", `csw-action--${a.kind}`]} href={a.href}>
											<span class="csw-action-text">
												{a.kind === "phone" && (
													<>
														{phone?.label?.trim() || L.call} <span class="csw-action-value csw-nowrap">{number}</span>
													</>
												)}
												{a.kind === "email" && (
													<>
														{L.email}{" "}
														<span class="csw-action-value csw-mail">
															{mailParts.map((part, k) => (
																<>
																	{k > 0 && <wbr />}
																	{part}
																</>
															))}
														</span>
													</>
												)}
												{a.kind === "request" && request!.label.trim()}
											</span>
											<svg class="csw-arrow" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
												<path d="M4 10h11M11 5.5 15.5 10 11 14.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
											</svg>
										</a>
									))}
								</div>
							</li>
						))}
					</ul>

					{
						hasFacts && (
							<dl class="csw-facts">
								{hourLines.length > 0 && (
									<div class="csw-fact">
										<dt>{L.hours}</dt>
										<dd>
											{hourLines.map((h) => (
												<span class:list={["csw-line", { "csw-nowrap": h.length <= 26 }]}>{h}</span>
											))}
										</dd>
									</div>
								)}
								{areas.length > 0 && (
									<div class="csw-fact csw-fact--areas">
										<dt>{L.areas}</dt>
										<dd>
											<ul class="csw-areas" role="list">
												{areas.map((a) => (
													<li>{a}</li>
												))}
											</ul>
										</dd>
									</div>
								)}
								{addressLines.length > 0 && (
									<div class="csw-fact">
										<dt>{L.address}</dt>
										<dd>
											{mapHref ? (
												<a class="csw-address-link" href={mapHref}>
													{addressLines.map((l) => (
														<span class="csw-line">{l}</span>
													))}
												</a>
											) : (
												addressLines.map((l) => <span class="csw-line">{l}</span>)
											)}
										</dd>
									</div>
								)}
							</dl>
						)
					}
				</div>
			</div>
		</section>
	)
}

<style>
	.csw {
		--_bg: var(--csw-bg, #f5f3ed);
		--_panel: var(--csw-panel, #ffffff);
		--_ink: var(--csw-text, #16181b);
		--_soft: var(--csw-text-muted, #4b4f56);
		--_brand: var(--csw-brand, #b93d0c);
		--_brand-text: var(--csw-brand-text, #ffffff);
		--_rule: var(--csw-border, #d8d4c9);
		--_strip: var(--csw-strip, #ece8de);
		--_font: var(--csw-family, "Instrument Sans", ui-sans-serif, system-ui, sans-serif);

		container-type: inline-size;
		background: var(--_bg);
		color: var(--_ink);
		font-family: var(--_font);
	}

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

	.csw-inner {
		max-width: 1240px;
		margin: 0 auto;
		padding: 64px 16px;
	}

	/* ---- header: heading left, intro right ---- */

	.csw-head {
		display: grid;
		gap: 18px;
		margin-bottom: 36px;
	}

	.csw-titles {
		min-width: 0;
	}

	.csw-eyebrow {
		margin: 0 0 12px;
		color: var(--_brand);
		font: 600 15px/1.4 var(--_font);
	}

	.csw-title {
		max-width: 17ch;
		margin: 0;
		font: 700 clamp(32px, 8.4cqi, 60px) / 1.04 var(--_font);
		letter-spacing: -0.035em;
		text-wrap: balance;
		overflow-wrap: break-word;
	}

	.csw-intro {
		max-width: 46ch;
		margin: 0;
		color: var(--_soft);
		font: 400 17px/1.6 var(--_font);
		text-wrap: pretty;
	}

	/* ---- the board ---- */

	.csw-board {
		overflow: hidden;
		border: 1.5px solid var(--_ink);
		border-radius: 6px;
		background: var(--_panel);
	}

	.csw-cols {
		display: none;
	}

	.csw-routes {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.csw-route {
		display: grid;
		gap: 16px;
		padding: 24px 20px;
		border-top: 1px solid var(--_rule);
	}

	.csw-route:first-child {
		border-top: 0;
	}

	.csw-need,
	.csw-expect,
	.csw-reach {
		min-width: 0;
	}

	.csw-need-title {
		margin: 0;
		font: 650 clamp(21px, 3.1cqi, 27px) / 1.2 var(--_font);
		letter-spacing: -0.02em;
		text-wrap: balance;
		overflow-wrap: break-word;
	}

	.csw-detail {
		max-width: 44ch;
		margin: 8px 0 0;
		color: var(--_soft);
		font: 400 16px/1.55 var(--_font);
	}

	.csw-expect:empty {
		display: none;
	}

	.csw-expect p {
		max-width: 40ch;
		margin: 0;
		padding-left: 14px;
		border-left: 3px solid var(--_brand);
		color: var(--_ink);
		font: 500 16px/1.5 var(--_font);
	}

	/* ---- actions ---- */

	.csw-reach {
		display: grid;
		gap: 6px;
		align-content: start;
	}

	.csw-action {
		color: inherit;
		text-decoration: none;
		transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, text-decoration-color 0.15s ease;
	}

	.csw-action--main {
		display: flex;
		align-items: center;
		gap: 12px;
		min-height: 52px;
		padding: 12px 18px;
		border: 1.5px solid var(--_ink);
		border-radius: 6px;
		background: var(--_ink);
		color: var(--_bg);
		font: 600 17px/1.25 var(--_font);
	}

	.csw-action--main:hover {
		border-color: var(--_brand);
		background: var(--_brand);
		color: var(--_brand-text);
	}

	.csw-action--alt {
		justify-self: start;
		display: inline-block;
		max-width: 100%;
		padding: 12px 2px;
		color: var(--_ink);
		font: 600 16px/1.3 var(--_font);
		text-decoration: underline;
		text-decoration-thickness: 1.5px;
		text-decoration-color: var(--_rule);
		text-underline-offset: 5px;
	}

	.csw-action--alt:hover {
		text-decoration-color: var(--_brand);
	}

	.csw-action--alt .csw-arrow {
		display: none;
	}

	.csw-action-text {
		flex: 1;
		min-width: 0;
	}

	.csw-action-value {
		font-variant-numeric: tabular-nums;
	}

	.csw-action--main .csw-action-value {
		font-weight: 700;
	}

	.csw-mail {
		min-width: 0;
		overflow-wrap: anywhere;
	}

	.csw-action--main .csw-mail {
		display: block;
		margin-top: 2px;
		font-size: 15px;
		font-weight: 500;
	}

	.csw-nowrap {
		white-space: nowrap;
	}

	.csw-arrow {
		flex: none;
	}

	.csw-action:focus-visible,
	.csw-address-link:focus-visible {
		outline: 3px solid var(--_brand);
		outline-offset: 3px;
	}

	/* ---- the priority route, set in reverse ---- */

	.csw-route--priority {
		border-top-color: var(--_ink);
		background: var(--_ink);
		color: var(--_bg);
	}

	.csw-route--priority + .csw-route {
		border-top-color: var(--_ink);
	}

	.csw-route--priority .csw-detail {
		color: color-mix(in srgb, var(--_bg) 80%, var(--_ink));
	}

	.csw-route--priority .csw-expect p {
		color: var(--_bg);
	}

	.csw-route--priority .csw-action--main {
		border-color: var(--_brand);
		background: var(--_brand);
		color: var(--_brand-text);
	}

	.csw-route--priority .csw-action--main:hover {
		border-color: var(--_bg);
		background: var(--_bg);
		color: var(--_ink);
	}

	.csw-route--priority .csw-action--alt {
		color: var(--_bg);
		text-decoration-color: color-mix(in srgb, var(--_bg) 45%, var(--_ink));
	}

	.csw-route--priority .csw-action--alt:hover {
		text-decoration-color: var(--_bg);
	}

	.csw-route--priority .csw-action:focus-visible {
		outline-color: var(--_bg);
	}

	/* ---- standing facts ---- */

	.csw-facts {
		display: grid;
		margin: 0;
		border-top: 1.5px solid var(--_ink);
		background: var(--_strip);
	}

	.csw-fact {
		display: grid;
		gap: 6px;
		min-width: 0;
		padding: 18px 20px;
		border-top: 1px solid var(--_rule);
	}

	.csw-fact:first-child {
		border-top: 0;
	}

	.csw-fact dt {
		color: var(--_soft);
		font: 600 14px/1.4 var(--_font);
	}

	.csw-fact dd {
		margin: 0;
		font: 500 16px/1.5 var(--_font);
		overflow-wrap: break-word;
	}

	.csw-line {
		display: block;
	}

	.csw-areas {
		display: flex;
		flex-wrap: wrap;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.csw-areas li {
		white-space: nowrap;
	}

	.csw-areas li:not(:last-child)::after {
		content: "·";
		margin: 0 0.5em;
		color: var(--_soft);
	}

	.csw-address-link {
		display: inline-block;
		color: var(--_ink);
		text-decoration: underline;
		text-decoration-color: var(--_rule);
		text-decoration-thickness: 1.5px;
		text-underline-offset: 4px;
	}

	.csw-address-link:hover {
		text-decoration-color: var(--_brand);
	}

	/* ---- 600px: reason | actions ---- */

	@container (min-width: 600px) {
		.csw-inner {
			padding: 80px 28px;
		}

		.csw-route {
			grid-template-columns: minmax(0, 1fr) minmax(230px, 0.62fr);
			column-gap: 28px;
			align-items: start;
			padding: 28px 28px;
		}

		.csw-expect {
			grid-column: 1;
			grid-row: 2;
		}

		.csw-reach {
			grid-column: 2;
			grid-row: 1;
		}

		.csw--expect .csw-reach {
			grid-row: 1 / span 2;
		}

		.csw-facts {
			grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
		}

		.csw-fact {
			padding: 20px 28px;
			border-top: 0;
			border-left: 1px solid var(--_rule);
		}

		.csw-fact:first-child {
			border-left: 0;
		}

		.csw-fact--areas {
			grid-column: span 2;
		}
	}

	/* ---- 900px: reason | what happens | actions, with column heads ---- */

	@container (min-width: 900px) {
		.csw-inner {
			padding: 104px 40px;
		}

		.csw-head {
			grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
			column-gap: 64px;
			align-items: end;
			margin-bottom: 48px;
		}

		.csw-cols {
			display: grid;
			column-gap: 40px;
			padding: 14px 36px;
			border-bottom: 1px solid var(--_rule);
			color: var(--_soft);
			font: 600 14px/1.4 var(--_font);
		}

		.csw-cols,
		.csw-route {
			grid-template-columns: minmax(0, 1fr) minmax(280px, 0.62fr);
		}

		.csw--expect .csw-cols,
		.csw--expect .csw-route {
			grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(280px, 0.78fr);
		}

		.csw-route {
			column-gap: 40px;
			padding: 32px 36px;
		}

		.csw-route:first-child {
			border-top: 0;
		}

		.csw--expect .csw-expect {
			grid-column: 2;
			grid-row: 1;
			padding-top: 4px;
		}

		.csw--expect .csw-expect:empty {
			display: block;
		}

		.csw--expect .csw-reach {
			grid-column: 3;
			grid-row: 1;
		}

		.csw-fact {
			padding: 22px 36px;
		}

		.csw-facts {
			grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.csw-action {
			transition: none;
		}
	}

	@media (forced-colors: active) {
		.csw-action--main {
			border: 2px solid ButtonText;
		}

		.csw-route--priority {
			border: 2px solid CanvasText;
		}
	}
</style>