Component library
Back to catalog

Trust reasons split

A tall port photo in a rounded frame beside an eyebrow, big heading, paragraph, a two-by-two list of "»" reasons, an orange pill and a call-us block with a phone link. Photo and text slide in from opposite sides.

splitwhy usimagelistctaphoneappearlogistics

Open preview in a new tab

Use this component

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

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

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: "Trust reasons split"
Library page: https://astro.baysixmedia.com/components/trust-reasons-split/
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/TrustReasonsSplit.astro =====
---
/**
 * TrustReasonsSplit
 *
 * A white split section: a tall photo in a 30px-rounded frame on one side;
 * on the other an orange eyebrow with a trailing line, a big navy heading, a
 * paragraph, a two-by-two list of reasons with orange "»" markers, and a row
 * holding an orange pill button and a "call us" block (navy round phone
 * icon, a label and an orange phone link).
 *
 * Hover, as measured: the pill turns navy and its arrow rotates 45deg; the
 * phone link turns from orange to navy (0.4s, cubic-bezier(.44,0,.56,1)).
 *
 * Appear on scroll: the photo slides in from -40px, the text from +40px
 * (mirrored with `imageSide="right"`). Reads --tg-* design tokens from any
 * ancestor, with fallbacks.
 */

export interface TrustReasonsLink {
	label: string;
	href: string;
}

export interface TrustReasonsContact {
	/** The small line above the number. */
	label?: string;
	/** The number as displayed. */
	phone: string;
	/** Defaults to a tel: link built from `phone`. */
	href?: string;
}

export interface TrustReasonsSplitProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	text?: string;
	/** The "»" list. Two columns from 810px. */
	reasons?: string[];
	/** The orange pill. `null` (the default) drops it. */
	cta?: TrustReasonsLink | null;
	/** The call-us block. `null` (the default) drops it; a phone with no digits and no `href` drops it too. */
	contact?: TrustReasonsContact | null;
	/** No `src` renders the labelled placeholder. */
	image?: { src?: string; alt?: string };
	/** Which side the photo sits on from 810px. */
	imageSide?: "left" | "right";
	/** The section surface. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends TrustReasonsSplitProps {}

/*
 * Every piece of copy, the reasons, the button, the phone and the photo are
 * the caller's: nothing about a business is filled in here. The catalog demo
 * passes its sample content through the registry's `props`.
 */
const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	text = "",
	reasons = [],
	cta = null,
	contact = null,
	image: imageProp,
	imageSide = "left",
	background,
	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 image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };

const Heading = headingLevel;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
	const trimmed = number.trim();
	const digits = trimmed.replace(/\D/g, "");
	return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};

/* A call link only with somewhere to dial: words in `phone` are not a number. */
const phoneNumber = typeof contact?.phone === "string" ? contact.phone.trim() : "";
const passedHref = contact?.href ? safeHref(contact.href) : "#";
const callHref = phoneNumber ? (passedHref !== "#" ? passedHref : telHref(phoneNumber)) : "";
const call = callHref ? { label: contact?.label ?? "", phone: phoneNumber, href: callHref } : null;

const reasonList = (reasons ?? []).filter((r) => typeof r === "string" && r.trim());

/* Nothing to say → nothing rendered: an eyebrow or a photo alone is not a section. */
const hasContent = Boolean(heading || text || reasonList.length > 0 || cta || call);

/* Measured slot: 537 × 644 at desktop (~0.83:1); the file is 1074 × 1288. */
const IMAGE_SPEC = "~5:6 · 1074 × 1288 px";
---

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section
	class:list={["trs", className]}
	id={id}
	style={background ? `--trs-bg: ${background};` : undefined}
	data-trs-root
	data-image-side={imageSide}
>
	<div class="trs-inner">
		<div class="trs-media" data-trs-media>
			{
				image.src ? (
					<img src={image.src} alt={image.alt} width="1074" height="1288" loading="lazy" decoding="async" />
				) : (
					<div class="trs-ph" role="img" aria-label="Image placeholder: portrait photo">
						<div class="trs-ph-text" aria-hidden="true">
							<span class="trs-ph-label">Image</span>
							<span class="trs-ph-hint">Portrait photo</span>
							<span class="trs-ph-spec">{IMAGE_SPEC}</span>
						</div>
					</div>
				)
			}
		</div>

		<div class="trs-copy" data-trs-copy>
			{(eyebrow || heading) && (
			<div class="trs-title">
				{
					eyebrow && (
						<p class="trs-eyebrow">
							{eyebrow}
							<span class="trs-eyebrow-line" aria-hidden="true" />
						</p>
					)
				}
				{heading && <Heading class="trs-heading">{heading}</Heading>}
			</div>
			)}
			{text && <p class="trs-text">{text}</p>}
			{
				(reasonList.length > 0 || cta || call) && (
					<div class="trs-items">
						{reasonList.length > 0 && (
							<ul class="trs-reasons" role="list">
								{reasonList.map((reason) => (
									<li class="trs-reason">
										<svg class="trs-mark" viewBox="0 0 18 14" width="18" height="14" aria-hidden="true" focusable="false">
											<path d="M1 1.5 6.5 7 1 12.5M9 1.5 14.5 7 9 12.5" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" />
										</svg>
										<span>{reason}</span>
									</li>
								))}
							</ul>
						)}
						{(cta || call) && (
							<div class="trs-actions">
								{cta && (
									<a class="trs-btn" href={safeHref(cta.href)}>
										{cta.label}
										<svg class="trs-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
											<path d="M2 10 10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
										</svg>
									</a>
								)}
								{call && (
									<div class="trs-contact">
										<span class="trs-contact-icon" aria-hidden="true">
											<svg viewBox="0 0 24 24" width="22" height="22">
												<path d="M6.6 10.8a15.2 15.2 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11.4 11.4 0 0 0 3.6.57 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1z" fill="currentColor" />
											</svg>
										</span>
										<p class="trs-contact-text">
											{call.label && <span class="trs-contact-label">{call.label}</span>}
											<a class="trs-phone" href={call.href}>{call.phone}</a>
										</p>
									</div>
								)}
							</div>
						)}
					</div>
				)
			}
		</div>
	</div>
</section>
)}

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

		box-sizing: border-box;
		padding: 80px 0;
		background: var(--trs-bg, #fff);
		color: var(--trs-navy);
		font-family: var(--trs-body);
		overflow-x: clip;
	}

	.trs[data-image-side="right"] {
		--trs-from-media: 40px;
		--trs-from-copy: -40px;
	}

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

	.trs-inner {
		display: flex;
		flex-direction: column;
		gap: 32px;
		max-width: 1234px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.trs-media {
		position: relative;
		aspect-ratio: 35 / 32;
		border-radius: 20px;
		overflow: hidden;
		background: #e6e9ee;
	}

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

	.trs-copy {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		min-width: 0;
	}

	.trs-title {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
	}

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

	.trs-eyebrow-line {
		display: none;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.trs .trs-heading {
		margin: 0;
		font: 600 32px/1.2 var(--trs-display);
		color: var(--trs-navy);
		overflow-wrap: break-word;
	}

	.trs .trs-text {
		margin: 0;
		font: 400 16px/21.04px var(--trs-body);
		color: var(--trs-muted);
	}

	.trs-items {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 32px;
		width: 100%;
	}

	.trs .trs-reasons {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 10px 24px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.trs-reason {
		display: flex;
		align-items: flex-start;
		gap: 12px;
		font: 500 16px/26px var(--trs-display);
		color: var(--trs-navy);
	}

	.trs-mark {
		flex: none;
		width: 18px;
		height: 14px;
		margin-top: 6px;
		color: var(--trs-orange);
	}

	.trs-actions {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 20px 34px;
	}

	.trs-btn {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		min-height: 48px;
		padding: 0 24px;
		border-radius: 100px;
		background: var(--trs-orange);
		color: #fff;
		font: 600 14px/1 var(--trs-body);
		text-decoration: none;
		white-space: nowrap;
		transition: background-color 0.4s var(--trs-ease);
	}

	.trs-btn-arrow {
		flex: none;
		transition: transform 0.4s var(--trs-ease);
	}

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

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

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

	.trs-contact {
		display: flex;
		align-items: center;
		gap: 20px;
		min-width: 0;
	}

	.trs-contact-icon {
		display: grid;
		flex: none;
		place-items: center;
		width: 56px;
		height: 56px;
		border-radius: 50%;
		background: var(--trs-navy);
		color: #fff;
	}

	.trs .trs-contact-text {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 4px;
		margin: 0;
		font: 600 16px/1.3 var(--trs-display);
		color: var(--trs-navy);
	}

	.trs-phone {
		display: inline-flex;
		align-items: center;
		/* 44px hit area without changing the measured line spacing. */
		min-height: 44px;
		margin: -10px 0;
		color: var(--trs-orange);
		text-decoration: none;
		transition: color 0.4s cubic-bezier(0.44, 0, 0.56, 1);
	}

	.trs-phone:hover {
		color: var(--trs-navy);
	}

	.trs-phone:focus-visible {
		outline: 2px solid var(--trs-orange);
		outline-offset: 3px;
		border-radius: 2px;
	}

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

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

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

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

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

	/* ---- appear on scroll ---- */
	.trs-media,
	.trs-copy {
		transition:
			opacity 0.9s var(--trs-ease),
			transform 0.9s var(--trs-ease);
	}

	.trs[data-trs-ready]:not([data-in]) .trs-media {
		opacity: 0;
		transform: translateX(var(--trs-from-media));
	}

	.trs[data-trs-ready]:not([data-in]) .trs-copy {
		opacity: 0;
		transform: translateX(var(--trs-from-copy));
	}

	/* ---- tablet and up: side by side ---- */
	@media (min-width: 810px) {
		.trs {
			padding: 100px 0;
		}

		.trs-inner {
			display: grid;
			grid-template-columns: minmax(0, 537fr) minmax(0, 597fr);
			align-items: center;
			gap: 40px;
		}

		.trs[data-image-side="right"] .trs-media {
			order: 2;
		}

		.trs-media {
			aspect-ratio: 537 / 644;
			border-radius: 30px;
		}

		.trs-copy {
			gap: 40px;
		}

		.trs-eyebrow-line {
			display: block;
		}

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

		.trs-items {
			gap: 50px;
		}

		.trs .trs-reasons {
			grid-template-columns: repeat(2, minmax(0, max-content));
			gap: 24px;
		}

		.trs-actions {
			flex-direction: row;
			flex-wrap: wrap;
			align-items: center;
		}

		.trs-btn {
			min-height: 56px;
			padding: 0 30px;
		}
	}

	@media (min-width: 1200px) {
		.trs {
			padding: 120px 0;
		}

		/* Step 39.2: --tg-grid-max / --tg-grid-gutter put the content on a site grid at desktop; the fallbacks are the original 1234 / 20. */
		.trs-inner {
			column-gap: 60px;
			max-width: var(--tg-grid-max, 1234px);
			padding-inline: var(--tg-grid-gutter, 20px);
		}

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

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

	@media (prefers-reduced-motion: reduce) {
		.trs-media,
		.trs-copy,
		.trs-btn-arrow {
			transition: none;
		}

		.trs-btn:hover .trs-btn-arrow {
			transform: none;
		}

		.trs[data-trs-ready]:not([data-in]) .trs-media,
		.trs[data-trs-ready]:not([data-in]) .trs-copy {
			opacity: 1;
			transform: none;
		}
	}

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

		.trs-contact-icon {
			border: 1px solid CanvasText;
		}
	}
</style>

<script>
	/*
	 * Appear-on-scroll. One IntersectionObserver per instance, scoped to its
	 * root, torn down on astro:before-swap. It sets `data-in` once and stops
	 * watching. Under reduced motion it marks the root in straight away.
	 */
	function initTrustReasonsSplit(root: HTMLElement) {
		if (root.dataset.trsReady === "" || root.dataset.in === "") return;

		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
			return;
		}

		root.dataset.trsReady = "";

		const observer = new IntersectionObserver(
			(entries) => {
				if (entries.some((entry) => entry.isIntersecting)) {
					root.dataset.in = "";
					observer.disconnect();
				}
			},
			{ threshold: 0.15 },
		);
		observer.observe(root);
		signal.addEventListener("abort", () => observer.disconnect());

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

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

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

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

===== END src/components/TrustReasonsSplit.astro =====

===== FILE (supporting): src/components/TrustReasonsSplit.md =====
# Trust reasons split

A white split section: a tall photo in a 30px-rounded frame on one side; on
the other an orange eyebrow with a trailing line, a big navy heading, a
paragraph, a two-by-two list of reasons with orange "»" markers, and a row
with an orange pill button and a "call us" block (navy round phone icon, a
label and an orange phone link).

Catalog page: `/components/trust-reasons-split`

Part of the Northpass Logistics family (`--tg-*` tokens). Home page.

## Files required

**`TrustReasonsSplit.astro`**: one file. No imports, no packages.

`TrustReasonsSplit.md`: this file.

`public/trust-reasons-split/ship-loading.webp`: the catalog demo photo (passed as `image`; there is no default photo).

## Basic usage

```astro
---
import TrustReasonsSplit from "../components/TrustReasonsSplit.astro";
---

<TrustReasonsSplit
  eyebrow="Why Northpass"
  heading="Why Shippers Keep Their Freight With Us"
  text="For more than 25 years we have moved freight for manufacturers, retailers and growers across four continents, with one coordinator on every account from first quote to final signature."
  reasons={["On-Time, Every Lane", "Door-to-Door Handling", "Live Shipment Tracking", "Honest, Fixed Pricing"]}
  cta={{ label: "About Us", href: "/about" }}
  contact={{ label: "Call Us Any Time", phone: "+1 (555) 014-2290" }}
  image={{ src: "/trust-reasons-split/ship-loading.webp", alt: "Container ship moored under orange gantry cranes while cargo is loaded" }}
/>
```

Every value above is demo content. Nothing about a business is a default:
with no heading, text, reasons, button or dialable phone the component
renders nothing.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `""` | Trailing line hidden on a phone. Hidden when empty; an eyebrow alone does not render the section. |
| `heading` | `string?` | `""` | Hidden when empty. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `text` | `string?` | `""` | Hidden when empty. |
| `reasons` | `string[]?` | `[]` | Two columns from 810px, one on a phone. Empty drops the list. |
| `cta` | `{ label, href } \| null?` | `null` | The orange pill. |
| `contact` | `{ label?, phone, href? } \| null?` | `null` | `href` defaults to `tel:` + a leading `+` and the digits of `phone`; a safe explicit `href` is honoured. No digits and no `href` → no call-us block. |
| `image` | `{ src?, alt? }?` | none | No `src` = a neutral "Portrait photo" placeholder. `alt` defaults to `""`. |
| `imageSide` | `"left" \| "right"?` | `"left"` | Mirrors the layout and the appear directions. |
| `background` | `string?` | `#fff` | Section surface. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` to skip. |
| `class`, `id` | `string?` | | On the root. |

## Hover (measured)

| Element | Rest | Hover |
| :--- | :--- | :--- |
| Pill button | `#fd5523` | `#020e28`, arrow `rotate(45deg)`, 0.4s |
| Phone link | orange | navy, 0.4s `cubic-bezier(.44,0,.56,1)` (from the dump) |

Focus-visible rings on both. The arrow turn is off under reduced motion; the
colour changes stay.

## Layout

- **Desktop:** 120px padding; photo 537 × 644 at 30px radius, text column 597
  wide, vertically centred; heading 48/57.6; reasons grid 2 × 2 with 24px
  gaps; 50px to the action row; button 56px tall, 30px side padding; call-us
  icon 56px navy.
- **Tablet:** same split, heading 40px.
- **Phone:** photo on top (35:32, 20px radius), text below, one-column list,
  button and call-us block stacked.

## Motion

Appear on scroll, once, 0.9s: the photo from `translateX(-40px)` and the text
from `translateX(40px)` (swapped with `imageSide="right"`). Hidden only after
the script sets `data-trs-ready`; no-JS and reduced motion render at rest.

## Accessibility

- Reasons are a list; the "»" markers are `aria-hidden` SVG.
- The phone link has a 44px hit area (negative margin keeps the measured line
  spacing); the button is 48–56px tall.

## Script

One `IntersectionObserver` per instance, aborted on `astro:before-swap`,
idempotent, initialised on load and `astro:page-load`. Never scrolls or moves
focus.

## Images and credits

| File | Size | Source |
| :--- | :--- | :--- |
| `ship-loading.webp` | 1074 × 1288 | Unsplash `Dzh-udFZXrA`, Unsplash License (free commercial use, no attribution required) |

## Approximations

- The pill's padding on a phone (48px tall, 24px sides) is estimated from the
  390px screenshot.
- The "»" marker is redrawn as a double chevron at the measured 18 × 14.
- Tablet layout is inferred; there was no tablet capture.

===== END src/components/TrustReasonsSplit.md =====

===== FILE (required): public/trust-reasons-split/ship-loading.webp =====
[binary asset — download https://astro.baysixmedia.com/trust-reasons-split/ship-loading.webp and save it to public/trust-reasons-split/ship-loading.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
“Trust reasons split” from my Astro Component Library:
https://astro.baysixmedia.com/components/trust-reasons-split/

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/TrustReasonsSplit.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/trust-reasons-split/ship-loading.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * TrustReasonsSplit
 *
 * A white split section: a tall photo in a 30px-rounded frame on one side;
 * on the other an orange eyebrow with a trailing line, a big navy heading, a
 * paragraph, a two-by-two list of reasons with orange "»" markers, and a row
 * holding an orange pill button and a "call us" block (navy round phone
 * icon, a label and an orange phone link).
 *
 * Hover, as measured: the pill turns navy and its arrow rotates 45deg; the
 * phone link turns from orange to navy (0.4s, cubic-bezier(.44,0,.56,1)).
 *
 * Appear on scroll: the photo slides in from -40px, the text from +40px
 * (mirrored with `imageSide="right"`). Reads --tg-* design tokens from any
 * ancestor, with fallbacks.
 */

export interface TrustReasonsLink {
	label: string;
	href: string;
}

export interface TrustReasonsContact {
	/** The small line above the number. */
	label?: string;
	/** The number as displayed. */
	phone: string;
	/** Defaults to a tel: link built from `phone`. */
	href?: string;
}

export interface TrustReasonsSplitProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	text?: string;
	/** The "»" list. Two columns from 810px. */
	reasons?: string[];
	/** The orange pill. `null` (the default) drops it. */
	cta?: TrustReasonsLink | null;
	/** The call-us block. `null` (the default) drops it; a phone with no digits and no `href` drops it too. */
	contact?: TrustReasonsContact | null;
	/** No `src` renders the labelled placeholder. */
	image?: { src?: string; alt?: string };
	/** Which side the photo sits on from 810px. */
	imageSide?: "left" | "right";
	/** The section surface. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends TrustReasonsSplitProps {}

/*
 * Every piece of copy, the reasons, the button, the phone and the photo are
 * the caller's: nothing about a business is filled in here. The catalog demo
 * passes its sample content through the registry's `props`.
 */
const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	text = "",
	reasons = [],
	cta = null,
	contact = null,
	image: imageProp,
	imageSide = "left",
	background,
	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 image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };

const Heading = headingLevel;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
	const trimmed = number.trim();
	const digits = trimmed.replace(/\D/g, "");
	return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};

/* A call link only with somewhere to dial: words in `phone` are not a number. */
const phoneNumber = typeof contact?.phone === "string" ? contact.phone.trim() : "";
const passedHref = contact?.href ? safeHref(contact.href) : "#";
const callHref = phoneNumber ? (passedHref !== "#" ? passedHref : telHref(phoneNumber)) : "";
const call = callHref ? { label: contact?.label ?? "", phone: phoneNumber, href: callHref } : null;

const reasonList = (reasons ?? []).filter((r) => typeof r === "string" && r.trim());

/* Nothing to say → nothing rendered: an eyebrow or a photo alone is not a section. */
const hasContent = Boolean(heading || text || reasonList.length > 0 || cta || call);

/* Measured slot: 537 × 644 at desktop (~0.83:1); the file is 1074 × 1288. */
const IMAGE_SPEC = "~5:6 · 1074 × 1288 px";
---

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section
	class:list={["trs", className]}
	id={id}
	style={background ? `--trs-bg: ${background};` : undefined}
	data-trs-root
	data-image-side={imageSide}
>
	<div class="trs-inner">
		<div class="trs-media" data-trs-media>
			{
				image.src ? (
					<img src={image.src} alt={image.alt} width="1074" height="1288" loading="lazy" decoding="async" />
				) : (
					<div class="trs-ph" role="img" aria-label="Image placeholder: portrait photo">
						<div class="trs-ph-text" aria-hidden="true">
							<span class="trs-ph-label">Image</span>
							<span class="trs-ph-hint">Portrait photo</span>
							<span class="trs-ph-spec">{IMAGE_SPEC}</span>
						</div>
					</div>
				)
			}
		</div>

		<div class="trs-copy" data-trs-copy>
			{(eyebrow || heading) && (
			<div class="trs-title">
				{
					eyebrow && (
						<p class="trs-eyebrow">
							{eyebrow}
							<span class="trs-eyebrow-line" aria-hidden="true" />
						</p>
					)
				}
				{heading && <Heading class="trs-heading">{heading}</Heading>}
			</div>
			)}
			{text && <p class="trs-text">{text}</p>}
			{
				(reasonList.length > 0 || cta || call) && (
					<div class="trs-items">
						{reasonList.length > 0 && (
							<ul class="trs-reasons" role="list">
								{reasonList.map((reason) => (
									<li class="trs-reason">
										<svg class="trs-mark" viewBox="0 0 18 14" width="18" height="14" aria-hidden="true" focusable="false">
											<path d="M1 1.5 6.5 7 1 12.5M9 1.5 14.5 7 9 12.5" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" />
										</svg>
										<span>{reason}</span>
									</li>
								))}
							</ul>
						)}
						{(cta || call) && (
							<div class="trs-actions">
								{cta && (
									<a class="trs-btn" href={safeHref(cta.href)}>
										{cta.label}
										<svg class="trs-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
											<path d="M2 10 10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
										</svg>
									</a>
								)}
								{call && (
									<div class="trs-contact">
										<span class="trs-contact-icon" aria-hidden="true">
											<svg viewBox="0 0 24 24" width="22" height="22">
												<path d="M6.6 10.8a15.2 15.2 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11.4 11.4 0 0 0 3.6.57 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1z" fill="currentColor" />
											</svg>
										</span>
										<p class="trs-contact-text">
											{call.label && <span class="trs-contact-label">{call.label}</span>}
											<a class="trs-phone" href={call.href}>{call.phone}</a>
										</p>
									</div>
								)}
							</div>
						)}
					</div>
				)
			}
		</div>
	</div>
</section>
)}

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

		box-sizing: border-box;
		padding: 80px 0;
		background: var(--trs-bg, #fff);
		color: var(--trs-navy);
		font-family: var(--trs-body);
		overflow-x: clip;
	}

	.trs[data-image-side="right"] {
		--trs-from-media: 40px;
		--trs-from-copy: -40px;
	}

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

	.trs-inner {
		display: flex;
		flex-direction: column;
		gap: 32px;
		max-width: 1234px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.trs-media {
		position: relative;
		aspect-ratio: 35 / 32;
		border-radius: 20px;
		overflow: hidden;
		background: #e6e9ee;
	}

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

	.trs-copy {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		min-width: 0;
	}

	.trs-title {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
	}

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

	.trs-eyebrow-line {
		display: none;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.trs .trs-heading {
		margin: 0;
		font: 600 32px/1.2 var(--trs-display);
		color: var(--trs-navy);
		overflow-wrap: break-word;
	}

	.trs .trs-text {
		margin: 0;
		font: 400 16px/21.04px var(--trs-body);
		color: var(--trs-muted);
	}

	.trs-items {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 32px;
		width: 100%;
	}

	.trs .trs-reasons {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 10px 24px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.trs-reason {
		display: flex;
		align-items: flex-start;
		gap: 12px;
		font: 500 16px/26px var(--trs-display);
		color: var(--trs-navy);
	}

	.trs-mark {
		flex: none;
		width: 18px;
		height: 14px;
		margin-top: 6px;
		color: var(--trs-orange);
	}

	.trs-actions {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 20px 34px;
	}

	.trs-btn {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		min-height: 48px;
		padding: 0 24px;
		border-radius: 100px;
		background: var(--trs-orange);
		color: #fff;
		font: 600 14px/1 var(--trs-body);
		text-decoration: none;
		white-space: nowrap;
		transition: background-color 0.4s var(--trs-ease);
	}

	.trs-btn-arrow {
		flex: none;
		transition: transform 0.4s var(--trs-ease);
	}

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

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

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

	.trs-contact {
		display: flex;
		align-items: center;
		gap: 20px;
		min-width: 0;
	}

	.trs-contact-icon {
		display: grid;
		flex: none;
		place-items: center;
		width: 56px;
		height: 56px;
		border-radius: 50%;
		background: var(--trs-navy);
		color: #fff;
	}

	.trs .trs-contact-text {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 4px;
		margin: 0;
		font: 600 16px/1.3 var(--trs-display);
		color: var(--trs-navy);
	}

	.trs-phone {
		display: inline-flex;
		align-items: center;
		/* 44px hit area without changing the measured line spacing. */
		min-height: 44px;
		margin: -10px 0;
		color: var(--trs-orange);
		text-decoration: none;
		transition: color 0.4s cubic-bezier(0.44, 0, 0.56, 1);
	}

	.trs-phone:hover {
		color: var(--trs-navy);
	}

	.trs-phone:focus-visible {
		outline: 2px solid var(--trs-orange);
		outline-offset: 3px;
		border-radius: 2px;
	}

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

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

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

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

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

	/* ---- appear on scroll ---- */
	.trs-media,
	.trs-copy {
		transition:
			opacity 0.9s var(--trs-ease),
			transform 0.9s var(--trs-ease);
	}

	.trs[data-trs-ready]:not([data-in]) .trs-media {
		opacity: 0;
		transform: translateX(var(--trs-from-media));
	}

	.trs[data-trs-ready]:not([data-in]) .trs-copy {
		opacity: 0;
		transform: translateX(var(--trs-from-copy));
	}

	/* ---- tablet and up: side by side ---- */
	@media (min-width: 810px) {
		.trs {
			padding: 100px 0;
		}

		.trs-inner {
			display: grid;
			grid-template-columns: minmax(0, 537fr) minmax(0, 597fr);
			align-items: center;
			gap: 40px;
		}

		.trs[data-image-side="right"] .trs-media {
			order: 2;
		}

		.trs-media {
			aspect-ratio: 537 / 644;
			border-radius: 30px;
		}

		.trs-copy {
			gap: 40px;
		}

		.trs-eyebrow-line {
			display: block;
		}

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

		.trs-items {
			gap: 50px;
		}

		.trs .trs-reasons {
			grid-template-columns: repeat(2, minmax(0, max-content));
			gap: 24px;
		}

		.trs-actions {
			flex-direction: row;
			flex-wrap: wrap;
			align-items: center;
		}

		.trs-btn {
			min-height: 56px;
			padding: 0 30px;
		}
	}

	@media (min-width: 1200px) {
		.trs {
			padding: 120px 0;
		}

		/* Step 39.2: --tg-grid-max / --tg-grid-gutter put the content on a site grid at desktop; the fallbacks are the original 1234 / 20. */
		.trs-inner {
			column-gap: 60px;
			max-width: var(--tg-grid-max, 1234px);
			padding-inline: var(--tg-grid-gutter, 20px);
		}

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

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

	@media (prefers-reduced-motion: reduce) {
		.trs-media,
		.trs-copy,
		.trs-btn-arrow {
			transition: none;
		}

		.trs-btn:hover .trs-btn-arrow {
			transform: none;
		}

		.trs[data-trs-ready]:not([data-in]) .trs-media,
		.trs[data-trs-ready]:not([data-in]) .trs-copy {
			opacity: 1;
			transform: none;
		}
	}

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

		.trs-contact-icon {
			border: 1px solid CanvasText;
		}
	}
</style>

<script>
	/*
	 * Appear-on-scroll. One IntersectionObserver per instance, scoped to its
	 * root, torn down on astro:before-swap. It sets `data-in` once and stops
	 * watching. Under reduced motion it marks the root in straight away.
	 */
	function initTrustReasonsSplit(root: HTMLElement) {
		if (root.dataset.trsReady === "" || root.dataset.in === "") return;

		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
			return;
		}

		root.dataset.trsReady = "";

		const observer = new IntersectionObserver(
			(entries) => {
				if (entries.some((entry) => entry.isIntersecting)) {
					root.dataset.in = "";
					observer.disconnect();
				}
			},
			{ threshold: 0.15 },
		);
		observer.observe(root);
		signal.addEventListener("abort", () => observer.disconnect());

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

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

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

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