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.