The 26 files and nothing else: the page file and every component, each fenced with its path, followed by each component's verification status. For placing it yourself.
Read it first
Page template kit: "Tideway services page"
Library page: https://astro.baysixmedia.com/templates/tideway-services/
Page file: src/pages/tideway-services.astro
Files: 26 required
Write each block below to the path named in its header, relative to the project root. The page file imports every component by relative path, so keep the destinations as given or update its imports to match. A block marked (required) is one the page does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/pages/tideway-services.astro =====
---
/**
* Tideway services page — a page template from the Astro Component Library.
* https://astro.baysixmedia.com/templates/tideway-services/
*
* Every section is a library component with its demo content passed in as
* props. Replace the props with this site's own content, and move the page
* inside this project's own layout if it has one.
*/
import ScrollPillHeader from "../components/ScrollPillHeader.astro";
import TagTitleBanner from "../components/TagTitleBanner.astro";
import BadgeImageIntro from "../components/BadgeImageIntro.astro";
import AlternatingServiceRows from "../components/AlternatingServiceRows.astro";
import ChamferStepCards from "../components/ChamferStepCards.astro";
import FreightPricingPlans from "../components/FreightPricingPlans.astro";
import PortraitRatingTestimonials from "../components/PortraitRatingTestimonials.astro";
import NumberedFaqPhoto from "../components/NumberedFaqPhoto.astro";
import ShipCtaFooter from "../components/ShipCtaFooter.astro";
const scrollPillHeaderProps = {
"items": [
{
"label": "Home",
"href": "/",
"children": [
{
"label": "Homepage 1",
"href": "/"
},
{
"label": "Homepage 2",
"href": "/home-2"
}
]
},
{
"label": "About",
"href": "/about"
},
{
"label": "Services",
"href": "/services"
},
{
"label": "Contact",
"href": "/contact"
}
],
"brandName": "Tideway",
"cta": {
"label": "Get a Quote",
"href": "/contact"
},
"currentPath": "/services"
};
const tagTitleBannerProps = {
"title": "Our",
"tag": "Services",
"breadcrumbs": [
{
"label": "Home",
"href": "/"
},
{
"label": "Services"
}
],
"background": {
"src": "/tag-title-banner/port.webp"
}
};
const badgeImageIntroProps = {
"badgeText": "Shipping · Cargo · Freight · Transport · ",
"heading": "Reliable *freight* services for growing businesses",
"text": "Road, sea and air freight planned around your deadlines, with careful handling and one team keeping you informed until the load is signed for.",
"cards": [
{
"title": "Freight transportation",
"icon": "plane",
"points": [
"Domestic freight",
"International shipping",
"Secure cargo delivery"
]
},
{
"title": "Supply chain management",
"icon": "cube",
"points": [
"End-to-end logistics",
"Warehouse coordination",
"Inventory planning"
]
}
],
"image": {
"src": "/badge-image-intro/trailer.webp",
"alt": "A white box trailer parked against a blue sky"
}
};
const alternatingServiceRowsProps = {
"heading": "Reliable freight & *logistics* services",
"services": [
{
"title": "Ocean freight solutions",
"tag": "Freight",
"points": [
"Secure international container shipping",
"Cost-effective ocean transport"
],
"href": "/services/ocean",
"image": {
"src": "/alternating-service-rows/ocean-freight.webp",
"alt": "A container ship loaded with colourful boxes at sea"
}
},
{
"title": "Road freight forwarding",
"tag": "Transport",
"points": [
"Nationwide lane coordination",
"Fast customs & documentation support"
],
"href": "/services/road",
"image": {
"src": "/alternating-service-rows/road-freight.webp",
"alt": "A semi truck with its headlights on at dusk"
}
},
{
"title": "Express cargo delivery",
"tag": "Express",
"points": [
"Fast, time-critical deliveries",
"Safe, reliable & on-time shipping"
],
"href": "/services/express",
"image": {
"src": "/alternating-service-rows/express.webp",
"alt": "A blue truck approaching on a highway"
}
}
]
};
const chamferStepCardsProps = {
"heading": "Easy steps to move your *freight*",
"intro": "Book in minutes, ship with one coordinator, and follow every milestone until the load is signed for.",
"steps": [
{
"title": "Get an instant quote",
"text": "Share the lane, the cargo and the date, and get a clear price back the same day.",
"image": {
"src": "/chamfer-step-cards/containers.webp",
"alt": "Stacks of shipping containers in a port yard"
}
},
{
"title": "Secure cargo transit",
"text": "We book the carrier, handle the paperwork and keep every handoff documented.",
"image": {
"src": "/chamfer-step-cards/train.webp",
"alt": "A freight train carrying stacked containers"
}
},
{
"title": "Live shipment tracking",
"text": "Follow the load in real time until it reaches the final destination.",
"image": {
"src": "/chamfer-step-cards/courier.webp",
"alt": "Courier in a red shirt carrying two parcels"
}
}
]
};
const freightPricingPlansProps = {
"heading": "Flexible pricing plans for *every shipper*",
"image": {
"src": "/freight-pricing-plans/forklift.webp",
"alt": "A forklift lifting a shipping container in a port yard"
},
"plans": [
{
"name": "Basic",
"pitch": "For small businesses shipping weekly",
"price": "$199",
"period": "/ Monthly",
"cta": {
"label": "Select Plan",
"href": "/contact?plan=basic"
},
"features": [
"Standard local delivery",
"Basic cargo insurance",
"Email support",
"Real-time tracking",
"Monthly report"
]
},
{
"name": "Standard",
"pitch": "For teams shipping every day",
"price": "$499",
"period": "/ Monthly",
"badge": "Popular",
"cta": {
"label": "Select Plan",
"href": "/contact?plan=standard"
},
"features": [
"Priority domestic shipping",
"Comprehensive cargo insurance",
"24/7 dedicated support",
"Advanced real-time tracking",
"Multi-location logistics"
]
}
]
};
const portraitRatingTestimonialsProps = {
"testimonials": [
{
"quote": "They took over our weekly store deliveries and the missed-appointment calls simply stopped. Every load shows up in the tracker before we think to ask.",
"name": "Marcus Hale",
"role": "Operations Manager",
"score": "5.0",
"avatar": {
"src": "/portrait-rating-testimonials/avatar-1.webp",
"alt": ""
}
},
{
"quote": "Customs paperwork used to cost us a week per container. Now it is cleared before the ship docks, and our retail branches stopped running short.",
"name": "Leila Moreno",
"role": "Retail Logistics Lead",
"score": "5.0",
"avatar": {
"src": "/portrait-rating-testimonials/avatar-2.webp",
"alt": ""
}
},
{
"quote": "Their cold-chain loads arrive documented, on temperature and on time. From pickup confirmation to unloading, nothing gets lost in a handoff.",
"name": "Owen Castillo",
"role": "Distribution Coordinator",
"score": "5.0",
"avatar": {
"src": "/portrait-rating-testimonials/avatar-3.webp",
"alt": ""
}
},
{
"quote": "Building materials now reach our job sites on the day we plan for. The drivers call ahead and the site crews are never left waiting.",
"name": "Hannah Brooks",
"role": "Procurement Head",
"score": "5.0",
"avatar": {
"src": "/portrait-rating-testimonials/avatar-4.webp",
"alt": ""
}
}
],
"portrait": {
"src": "/portrait-rating-testimonials/portrait.webp",
"alt": "A smiling site worker in a hard hat and high-visibility vest"
},
"rating": {
"score": "4.9/5.0",
"count": "(2K Reviews)"
},
"background": "#ffffff",
"cardBackground": "#f0f8fa"
};
const numberedFaqPhotoProps = {
"heading": "Freight *questions* answered",
"items": [
{
"question": "How long does freight delivery take?",
"answer": "It depends on the lane, the cargo and the customs route. We confirm a realistic transit time before pickup and update you if anything on the road changes it."
},
{
"question": "Can I track my shipment in real time?",
"answer": "Yes. Every active shipment has a live tracking link with the current position, the next milestone and the latest estimated arrival."
},
{
"question": "Do you handle customs and documentation?",
"answer": "We prepare and file the commercial invoice, packing list and customs entries, and we flag anything missing before the cargo leaves the origin."
},
{
"question": "What types of cargo can you move?",
"answer": "General freight, palletised goods, temperature-controlled loads and oversized project cargo. Hazardous goods are quoted case by case."
}
],
"image": {
"src": "/numbered-faq-photo/white-semi.webp",
"alt": "Two semi trucks travelling a highway under a hazy sky"
}
};
const shipCtaFooterProps = {
"heading": "Explore our *real world* freight services",
"cta": {
"label": "Get a Quote",
"href": "/contact"
},
"brandName": "Tideway",
"tagline": "A freight partner that plans every load and answers the phone when it matters.",
"newsletter": {
"action": "/subscribe",
"label": "Email address",
"placeholder": "you@company.com",
"button": "Submit"
},
"columns": [
{
"heading": "Services",
"links": [
{
"label": "Freight Services",
"href": "/services/freight"
},
{
"label": "Cargo Transportation",
"href": "/services/cargo"
},
{
"label": "Road Transport",
"href": "/services/road"
},
{
"label": "Global Shipping",
"href": "/services/global"
}
]
},
{
"heading": "Company",
"links": [
{
"label": "About Us",
"href": "/about"
},
{
"label": "Contact",
"href": "/contact"
},
{
"label": "Careers",
"href": "/careers"
}
]
}
],
"contact": [
{
"label": "(555) 014-2290",
"href": "tel:+15550142290"
},
{
"label": "hello@tideway.example",
"href": "mailto:hello@tideway.example"
},
{
"label": "1200 Harbor Way, Oakland, CA",
"href": "https://maps.google.com/?q=1200+Harbor+Way+Oakland+CA"
}
],
"legalLinks": [
{
"label": "Terms & Conditions",
"href": "/terms"
},
{
"label": "Privacy Policy",
"href": "/privacy"
}
],
"social": [
{
"network": "facebook",
"href": "https://facebook.com"
},
{
"network": "x",
"href": "https://x.com"
},
{
"network": "instagram",
"href": "https://instagram.com"
}
],
"background": {
"src": "/ship-cta-footer/ship-at-sea.webp"
}
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freight Services — [Business name]</title>
<meta name="description" content="[One-sentence summary of the freight services offered and the area covered.]" />
<style is:global>
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: #ffffff;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
color: #111;
}
.skip-link {
position: absolute;
left: 1rem;
top: -100px;
z-index: 1000;
padding: 0.6rem 1rem;
background: #111;
color: #fff;
border-radius: 6px;
}
.skip-link:focus {
top: 1rem;
}
</style>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<!-- Scroll pill header -->
<ScrollPillHeader {...scrollPillHeaderProps} />
<main id="main">
<!-- Tag title banner -->
<TagTitleBanner {...tagTitleBannerProps} />
<!-- Badge image intro -->
<BadgeImageIntro {...badgeImageIntroProps} />
<!-- Alternating service rows -->
<AlternatingServiceRows {...alternatingServiceRowsProps} />
<!-- Chamfer step cards -->
<ChamferStepCards {...chamferStepCardsProps} />
<!-- Freight pricing plans -->
<FreightPricingPlans {...freightPricingPlansProps} />
<!-- Portrait rating testimonials -->
<PortraitRatingTestimonials {...portraitRatingTestimonialsProps} />
<!-- Numbered FAQ photo -->
<NumberedFaqPhoto {...numberedFaqPhotoProps} />
</main>
<!-- Ship CTA footer -->
<ShipCtaFooter {...shipCtaFooterProps} />
</body>
</html>
===== END src/pages/tideway-services.astro =====
===== FILE (required): src/components/ScrollPillHeader.astro =====
---
/**
* ScrollPillHeader
*
* A 1350px pill bar fixed 20px from the top of the viewport. Over the hero it
* is a faint white wash with white type; once the page scrolls past
* `threshold` it turns solid white with navy type. Logo on the left, links in
* the middle (an item with `children` opens a small dropdown card), an orange
* "Get a Quote" pill on the right. Below 1000px it folds to the logo and a
* round menu button that opens a white card under the bar.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface ScrollPillHeaderLink {
label: string;
href: string;
}
export interface ScrollPillHeaderItem {
label: string;
/** Required for a plain link; ignored on a dropdown trigger. */
href?: string;
/** Turns the item into a dropdown trigger. */
children?: ScrollPillHeaderLink[];
/**
* Mega-menu shaped data from other headers in this library. The links of
* every group are flattened into one dropdown list; headings are dropped.
*/
groups?: { heading?: string; links: ScrollPillHeaderLink[] }[];
}
export interface ScrollPillHeaderProps {
items?: ScrollPillHeaderItem[];
/** Pass Astro.url.pathname. Matching links get aria-current="page". */
currentPath?: string;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark. Give a light and a dark version. */
logo?: { src: string; srcOnDark?: string; alt: string; width?: number; height?: number };
/** The orange pill. `null` drops it. */
cta?: ScrollPillHeaderLink | null;
/** Always solid white, for pages whose top is not a dark hero. */
solid?: boolean;
/** Scroll distance in px after which the bar turns solid. */
threshold?: number;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends ScrollPillHeaderProps {}
/* No invented site: links, brand and CTA are all the caller's. Each block
renders only when its data is supplied; with none, nothing renders. */
const {
items: rawItems = [],
currentPath,
brandName,
brandHref = "/",
logo,
cta = null,
solid = false,
threshold = 8,
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
id,
} = Astro.props;
const items = rawItems.map((item) => ({
label: item.label,
href: item.href ?? "#",
children: item.children ?? item.groups?.flatMap((g) => g.links),
}));
const seed = id ? id : `sph-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href?: string) => (href && SAFE.test(href.trim()) ? href.trim() : "#");
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) => Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: { children?: ScrollPillHeaderLink[] }) => (item.children ?? []).some((c) => isCurrent(c.href));
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasMenu = hasItems || Boolean(cta);
const hasContent = hasBrand || hasMenu;
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
class="sph"
id={id}
data-header-root
data-sph-root
data-sph-threshold={threshold}
data-solid={solid ? "" : undefined}
>
<div class="sph-bar">
{hasBrand && (
<a class="sph-brand" href={safeHref(brandHref)} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<>
<img class="sph-logo sph-logo--light" src={logo.src} alt={logo.alt} width={logo.width ?? 167} height={logo.height ?? 43} />
{logo.srcOnDark && (
<img class="sph-logo sph-logo--dark" src={logo.srcOnDark} alt="" width={logo.width ?? 167} height={logo.height ?? 43} />
)}
</>
) : (
<>
<svg class="sph-mark" viewBox="0 0 40 40" aria-hidden="true">
<path d="M6 21a14 14 0 0 1 28 0h-6a8 8 0 0 0-16 0Z" fill="currentColor" />
<path d="M20 17 11 30h6v7h6v-7h6Z" fill="currentColor" />
</svg>
<span class="sph-word">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="sph-nav" aria-label={navLabel}>
<ul class="sph-list">
{
items.map((item, i) =>
item.children?.length ? (
<li class="sph-item sph-item--drop" data-sph-drop>
<button
type="button"
class="sph-link sph-trigger"
aria-expanded="false"
aria-controls={`${seed}-drop-${i}`}
data-sph-trigger
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="sph-chev" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2 4.25 6 8l4-3.75" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<ul class="sph-drop" id={`${seed}-drop-${i}`} data-sph-panel>
{item.children.map((child) => (
<li>
<a href={safeHref(child.href)} aria-current={isCurrent(child.href) ? "page" : undefined}>
{child.label}
</a>
</li>
))}
</ul>
</li>
) : (
<li class="sph-item">
<a class="sph-link" href={safeHref(item.href)} aria-current={isCurrent(item.href) ? "page" : undefined}>
{item.label}
</a>
</li>
),
)
}
</ul>
</nav>
)}
{
cta && (
<a class="sph-cta" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="sph-cta-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
{hasMenu && (
<button type="button" class="sph-burger" aria-expanded="false" aria-controls={menuId} data-sph-burger>
<span class="sph-sr">{menuLabel}</span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9.25" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M12 7.5v9M7.5 12h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
)}
</div>
{hasMenu && (
<div class="sph-menu" id={menuId} data-sph-menu>
{hasItems && (
<ul>
{
items.flatMap((item) =>
item.children?.length
? item.children.map((child) => (
<li>
<a href={safeHref(child.href)} aria-current={isCurrent(child.href) ? "page" : undefined}>
{child.label}
</a>
</li>
))
: [
<li>
<a href={safeHref(item.href)} aria-current={isCurrent(item.href) ? "page" : undefined}>
{item.label}
</a>
</li>,
],
)
}
</ul>
)}
{
cta && (
<a class="sph-cta sph-cta--menu" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="sph-cta-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
)}
</header>
)}
<style>
.sph {
--sph-navy: var(--tw-navy, #0b4654);
--sph-deep: var(--tw-navy-deep, #052931);
--sph-accent: var(--tw-accent, #ff6d45);
--sph-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--sph-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--sph-ease: cubic-bezier(0.44, 0, 0.56, 1);
--sph-ink: #fff;
position: fixed;
top: 12px;
left: 50%;
z-index: 40;
width: min(1350px, calc(100% - 24px));
transform: translateX(-50%);
font-family: var(--sph-body);
}
.sph *,
.sph *::before,
.sph *::after {
box-sizing: border-box;
}
.sph[data-scrolled],
.sph[data-solid],
.sph[data-sph-open] {
--sph-ink: var(--sph-navy);
}
.sph-bar {
position: relative;
display: flex;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 12px 0 16px;
border-radius: 999px;
background: rgb(255 255 255 / 0.1);
color: var(--sph-ink);
/* No colour transition here: the links carry their own, and a second
one on the parent would chain behind it. */
transition:
background-color 0.4s var(--sph-ease),
box-shadow 0.4s var(--sph-ease);
}
.sph[data-scrolled] .sph-bar,
.sph[data-solid] .sph-bar,
.sph[data-sph-open] .sph-bar {
background: #fff;
box-shadow: 0 0 0 1px rgb(11 70 84 / 0.08);
}
.sph-brand {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: auto;
color: inherit;
text-decoration: none;
border-radius: 8px;
}
.sph-mark {
width: 32px;
height: 32px;
color: var(--sph-accent);
}
.sph-word {
font: 500 26px/1 var(--sph-display);
letter-spacing: -0.5px;
}
.sph-logo {
display: block;
width: auto;
height: 34px;
}
.sph-logo--dark {
display: none;
}
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--dark {
display: block;
}
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--dark ~ .sph-logo--light,
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--light:has(+ .sph-logo--dark) {
display: none;
}
.sph-nav,
.sph > .sph-bar > .sph-cta {
display: none;
}
.sph-list {
display: flex;
align-items: center;
gap: 35px;
margin: 0;
padding: 0;
list-style: none;
}
.sph-item {
position: relative;
}
.sph-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 0;
border: 0;
background: none;
color: inherit;
font: 500 16px/25.6px var(--sph-body);
letter-spacing: -0.32px;
text-decoration: none;
cursor: pointer;
transition: color 0.3s var(--sph-ease);
}
.sph-link:hover,
.sph-item--drop[data-open] .sph-trigger,
.sph-link[aria-current="page"],
.sph-trigger[data-current] {
color: var(--sph-accent);
}
.sph-chev {
width: 14px;
height: 14px;
transition: transform 0.3s var(--sph-ease);
}
.sph-item--drop[data-open] .sph-chev {
transform: rotate(180deg);
}
.sph-drop {
position: absolute;
top: calc(100% + 16px);
left: 0;
min-width: 290px;
margin: 0;
padding: 6px 10px;
list-style: none;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(5 41 49 / 0.14);
opacity: 0;
visibility: hidden;
transform: translateY(6px);
transition:
opacity 0.25s var(--sph-ease),
transform 0.25s var(--sph-ease),
visibility 0s linear 0.25s;
}
/* A bridge over the gap, so moving the pointer from the trigger to the
card does not cross empty space and close it. */
.sph-drop::before {
content: "";
position: absolute;
inset: -18px 0 100% 0;
}
.sph-item--drop[data-open] .sph-drop {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0s;
}
.sph-drop li + li {
border-top: 1px solid rgb(11 70 84 / 0.1);
}
.sph-drop a {
display: block;
padding: 10px 0;
color: var(--sph-navy);
font: 500 16px/1.4 var(--sph-body);
text-transform: uppercase;
text-decoration: none;
transition: color 0.3s var(--sph-ease);
}
.sph-drop a:hover,
.sph-drop a[aria-current="page"] {
color: var(--sph-accent);
}
.sph-cta {
display: inline-flex;
align-items: center;
gap: 12px;
height: 50px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--sph-accent);
color: #fff;
font: 400 16px/25.6px var(--sph-body);
letter-spacing: -0.32px;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.3s var(--sph-ease);
}
.sph-cta:hover {
background: var(--sph-deep);
}
.sph-cta-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--sph-accent);
transition: transform 0.3s var(--sph-ease);
}
.sph-cta-ic svg {
width: 18px;
height: 18px;
}
.sph-cta:hover .sph-cta-ic {
transform: rotate(-45deg);
}
.sph-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--sph-accent);
cursor: pointer;
}
.sph-burger svg {
width: 26px;
height: 26px;
transition: transform 0.3s var(--sph-ease);
}
.sph[data-sph-open] .sph-burger svg {
transform: rotate(45deg);
}
.sph-menu {
position: absolute;
top: calc(100% + 10px);
left: 0;
right: 0;
display: none;
padding: 12px 20px 20px;
border-radius: 20px;
background: #fff;
box-shadow: 0 16px 40px rgb(5 41 49 / 0.18);
}
.sph[data-sph-open] .sph-menu {
display: block;
}
.sph-menu ul {
margin: 0 0 16px;
padding: 0;
list-style: none;
}
.sph-menu li + li {
border-top: 1px solid rgb(11 70 84 / 0.1);
}
.sph-menu a:not(.sph-cta) {
display: block;
padding: 12px 0;
color: var(--sph-navy);
font: 500 17px/1.4 var(--sph-body);
text-decoration: none;
}
.sph-menu a:not(.sph-cta):hover,
.sph-menu a[aria-current="page"] {
color: var(--sph-accent);
}
.sph-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.sph a:focus-visible,
.sph button:focus-visible {
outline: 2px solid var(--sph-accent);
outline-offset: 3px;
}
/* No JavaScript: no menu button, and the phone card is shown under the bar
as a plain list so every link stays reachable. */
.sph:not([data-sph-ready]) .sph-burger {
display: none;
}
.sph:not([data-sph-ready]) .sph-menu {
display: block;
}
.sph:not([data-sph-ready]) .sph-bar {
background: #fff;
--sph-ink: var(--sph-navy);
color: var(--sph-navy);
}
.sph:not([data-sph-ready]) .sph-item--drop:focus-within .sph-drop,
.sph:not([data-sph-ready]) .sph-item--drop:hover .sph-drop {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0s;
}
@media (min-width: 810px) {
.sph {
top: 20px;
width: min(1350px, calc(100% - 60px));
}
.sph-bar {
height: 80px;
padding: 0 16px 0 22px;
}
}
@media (min-width: 1000px) {
.sph {
width: min(1350px, calc(100% - 90px));
}
.sph-bar {
height: 90px;
padding: 0 20px 0 22px;
}
.sph-nav {
display: block;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.sph > .sph-bar > .sph-cta {
display: inline-flex;
}
.sph-burger,
.sph:not([data-sph-ready]) .sph-menu,
.sph .sph-menu {
display: none !important;
}
.sph-mark {
width: 38px;
height: 38px;
}
.sph-word {
font-size: 32px;
}
.sph-logo {
height: 43px;
}
}
@media (prefers-reduced-motion: reduce) {
.sph-bar,
.sph-link,
.sph-chev,
.sph-drop,
.sph-cta,
.sph-cta-ic,
.sph-burger svg {
transition: none;
}
.sph-cta:hover .sph-cta-ic {
transform: none;
}
}
@media (forced-colors: active) {
.sph-bar,
.sph-drop,
.sph-menu {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* ScrollPillHeader controller: the scroll swap, the desktop dropdowns and
* the phone menu. One closure per instance, every lookup scoped to its
* root, every listener on one AbortController aborted before a client-side
* navigation swaps the document. No body scroll lock.
*/
function initScrollPillHeader(root: HTMLElement) {
if (root.dataset.sphReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-sph-burger]");
const menu = root.querySelector<HTMLElement>("[data-sph-menu]");
root.dataset.sphReady = "";
const controller = new AbortController();
const { signal } = controller;
const threshold = Number(root.dataset.sphThreshold) || 8;
const desktop = window.matchMedia("(min-width: 1000px)");
const hoverable = window.matchMedia("(hover: hover)");
/* ---- scroll swap ---- */
let ticking = false;
const applyScroll = () => {
ticking = false;
root.toggleAttribute("data-scrolled", window.scrollY > threshold);
};
applyScroll();
window.addEventListener(
"scroll",
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(applyScroll);
},
{ passive: true, signal },
);
/* ---- dropdowns ---- */
const drops = [...root.querySelectorAll<HTMLElement>("[data-sph-drop]")];
const setDrop = (drop: HTMLElement, open: boolean, returnFocus = false) => {
const trigger = drop.querySelector<HTMLButtonElement>("[data-sph-trigger]");
if (!trigger || drop.hasAttribute("data-open") === open) return;
drop.toggleAttribute("data-open", open);
trigger.setAttribute("aria-expanded", String(open));
if (!open && returnFocus) trigger.focus({ preventScroll: true });
};
const closeDrops = (except?: HTMLElement) => drops.forEach((d) => d !== except && setDrop(d, false));
for (const drop of drops) {
const trigger = drop.querySelector<HTMLButtonElement>("[data-sph-trigger]");
if (!trigger) continue;
trigger.addEventListener(
"click",
() => {
const next = !drop.hasAttribute("data-open");
closeDrops(drop);
setDrop(drop, next);
},
{ signal },
);
drop.addEventListener(
"pointerenter",
(e) => {
if (e.pointerType === "mouse" && hoverable.matches) {
closeDrops(drop);
setDrop(drop, true);
}
},
{ signal },
);
drop.addEventListener(
"pointerleave",
(e) => {
if (e.pointerType === "mouse" && hoverable.matches) setDrop(drop, false);
},
{ signal },
);
drop.addEventListener(
"focusout",
(e) => {
const next = e.relatedTarget as Node | null;
if (next && !drop.contains(next)) setDrop(drop, false);
},
{ signal },
);
}
/* ---- phone menu ---- */
let open = false;
/* No links and no CTA means no menu button and no card: the scroll
swap and dropdowns above still run. */
const setMenu = (next: boolean, returnFocus = false) => {
if (!burger || !menu || open === next) return;
open = next;
root.toggleAttribute("data-sph-open", next);
burger.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger.focus({ preventScroll: true });
};
burger?.addEventListener("click", () => setMenu(!open), { signal });
menu?.addEventListener(
"click",
(e) => {
if ((e.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
root.addEventListener(
"focusout",
(e) => {
const next = e.relatedTarget as Node | null;
if (open && next && !root.contains(next)) setMenu(false);
},
{ signal },
);
/* Escape and outside click on the document: after a client-side
navigation focus can sit outside the header. */
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") return;
const openDrop = drops.find((d) => d.hasAttribute("data-open"));
if (openDrop) {
e.preventDefault();
setDrop(openDrop, false, openDrop.contains(document.activeElement));
} else if (open) {
e.preventDefault();
setMenu(false, true);
}
},
{ signal },
);
document.addEventListener(
"pointerdown",
(e) => {
if (root.contains(e.target as Node)) return;
closeDrops();
setMenu(false);
},
{ signal },
);
desktop.addEventListener(
"change",
() => {
setMenu(false);
closeDrops();
},
{ signal },
);
document.addEventListener(
"astro:before-swap",
() => {
setMenu(false);
closeDrops();
controller.abort();
delete root.dataset.sphReady;
},
{ once: true, signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-sph-root]").forEach(initScrollPillHeader);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ScrollPillHeader.astro =====
===== FILE (required): src/components/TagTitleBanner.astro =====
---
/**
* TagTitleBanner
*
* An inner-page banner built as a split: the breadcrumb and a heavy
* uppercase title with a stamped tag on a navy field, and — when a photo is
* given — a full-height photo panel meeting it at an accent seam. Below
* 700px of its own width the photo becomes a strip that closes the banner.
* Leaves room at the top for a fixed pill header.
*
* Sizes follow the component's own width (container queries), so it holds
* the same composition in a sidebar layout or a narrow column. The title
* rises in on load; the breadcrumb follows. Both end at rest and are off
* under reduced motion.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface TagTitleCrumb {
label: string;
/** Omit on the last crumb, the current page. */
href?: string;
}
export interface TagTitleBannerProps {
title?: string;
/** The orange box after the title. `""` drops it. */
tag?: string;
breadcrumbs?: TagTitleCrumb[];
/** Background photo. `src: ""` renders the navy band alone. */
background?: { src?: string };
animate?: boolean;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends TagTitleBannerProps {}
const {
title = "",
tag = "",
breadcrumbs: rawCrumbs = [],
background: bgProp,
animate = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* No photo by default: the navy band renders alone. No title, no banner. */
const background = { src: "", ...(bgProp ?? {}) };
const breadcrumbs = rawCrumbs.filter((c) => c?.label);
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
---
{title && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["ttb", className]} id={id} data-animate={animate ? "" : undefined} data-media={background.src ? "" : undefined}>
<div class="ttb-grid">
<div class="ttb-inner">
{
breadcrumbs.length > 0 && (
<nav class="ttb-crumbs" aria-label="Breadcrumb">
<ol>
{breadcrumbs.map((c, i) => (
<li>
{c.href && i < breadcrumbs.length - 1 ? (
<a href={safeHref(c.href)}>{c.label}</a>
) : (
<span aria-current={i === breadcrumbs.length - 1 ? "page" : undefined}>{c.label}</span>
)}
</li>
))}
</ol>
</nav>
)
}
<h1 class="ttb-title">
<span class="ttb-word">{title}</span>
{tag && <span class="ttb-tag">{tag}</span>}
</h1>
</div>
{
background.src && (
<div class="ttb-media" aria-hidden="true">
<img src={background.src} alt="" width="1920" height="600" fetchpriority="high" decoding="async" />
</div>
)
}
</div>
</section>
</>
)}
<style>
.ttb {
--ttb-navy: var(--tw-navy, #0b4654);
--ttb-accent: var(--tw-accent, #ff6d45);
--ttb-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--ttb-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--ttb-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ttb-seam: 6px;
container-type: inline-size;
position: relative;
isolation: isolate;
overflow: hidden;
background: var(--ttb-navy);
color: #fff;
font-family: var(--ttb-body);
/* The closing edge: the banner ends on a line, never on open colour. */
border-bottom: var(--ttb-seam) solid var(--ttb-accent);
}
.ttb *,
.ttb *::before,
.ttb *::after {
box-sizing: border-box;
}
.ttb-grid {
display: grid;
}
/* Step 39.2: --tw-grid-max / --tw-grid-gutter put the title on a site grid; the fallbacks are the original 1440 / 64. */
.ttb-inner {
--ttb-max: var(--tw-grid-max, 1440px);
--ttb-gutter: var(--tw-grid-gutter, 64px);
}
.ttb-inner {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 18px;
min-width: 0;
padding: 124px 20px 40px;
}
/* ---- breadcrumb: wayfinding first, above the title ---- */
.ttb-crumbs ol {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 2px;
margin: 0;
padding: 0;
list-style: none;
font: 500 15px/1.4 var(--ttb-body);
}
.ttb-crumbs li {
display: flex;
align-items: center;
min-width: 0;
}
.ttb-crumbs li + li::before {
content: "/";
margin: 0 8px 0 6px;
color: rgb(255 255 255 / 0.4);
}
.ttb-crumbs a,
.ttb-crumbs span {
display: inline-block;
padding: 5px 0;
overflow-wrap: anywhere;
}
.ttb-crumbs a {
color: rgb(255 255 255 / 0.74);
text-decoration: underline;
text-decoration-color: rgb(255 255 255 / 0);
text-underline-offset: 4px;
transition: color 0.2s var(--ttb-ease), text-decoration-color 0.2s var(--ttb-ease);
}
.ttb-crumbs a:hover {
color: #fff;
text-decoration-color: rgb(255 255 255 / 0.6);
}
.ttb-crumbs a:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
border-radius: 2px;
}
.ttb-crumbs [aria-current] {
color: #fff;
font-weight: 600;
}
/* ---- title: one lockup, the tag stamped onto the end of it ---- */
.ttb-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.14em 0.2em;
margin: 0;
max-width: 16ch;
font: 700 clamp(40px, 11.5cqi, 60px) / 0.98 var(--ttb-display);
letter-spacing: -0.035em;
text-transform: uppercase;
text-wrap: balance;
overflow-wrap: anywhere;
}
.ttb-tag {
display: inline-block;
padding: 0.1em 0.26em 0.06em;
border: 2px solid transparent;
border-radius: 4px;
background: var(--ttb-accent);
color: var(--ttb-navy);
font-size: 0.62em;
line-height: 1;
letter-spacing: -0.02em;
white-space: nowrap;
}
/* ---- photo ---- */
.ttb-media {
position: relative;
min-height: 168px;
border-top: var(--ttb-seam) solid var(--ttb-accent);
}
.ttb-media img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 70% center;
}
/* A light navy grade so a busy photo sits in the palette instead of fighting it. */
.ttb-media::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgb(11 70 84 / 0.45), rgb(11 70 84 / 0.12) 55%, rgb(11 70 84 / 0.05));
}
/* ---- tablet and up: side by side, the seam runs top to bottom ---- */
@container (min-width: 700px) {
.ttb[data-media] .ttb-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.72fr);
}
.ttb-inner {
gap: 22px;
padding: 150px 40px 56px;
}
.ttb-title {
max-width: 14ch;
font-size: clamp(52px, 8.4cqi, 104px);
}
.ttb:not([data-media]) .ttb-title {
max-width: 20ch;
}
.ttb-media {
min-height: 0;
border-top: 0;
border-left: var(--ttb-seam) solid var(--ttb-accent);
}
}
@container (min-width: 1100px) {
.ttb-inner {
padding: 196px max(var(--ttb-gutter), calc((100cqi - var(--ttb-max)) / 2 + var(--ttb-gutter))) 72px;
}
.ttb[data-media] .ttb-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.78fr);
}
}
/* ---- motion: rises in, ends at rest ---- */
@keyframes ttb-rise {
from {
opacity: 0;
transform: translateY(var(--ttb-from, 18px));
}
}
.ttb[data-animate] .ttb-title {
animation: ttb-rise 0.7s var(--ttb-ease) both;
}
.ttb[data-animate] .ttb-crumbs {
--ttb-from: 10px;
animation: ttb-rise 0.6s var(--ttb-ease) 0.12s both;
}
@media (prefers-reduced-motion: reduce) {
.ttb[data-animate] .ttb-title,
.ttb[data-animate] .ttb-crumbs {
animation: none;
}
.ttb-crumbs a {
transition: none;
}
}
@media (forced-colors: active) {
.ttb {
border-bottom-color: CanvasText;
}
.ttb-media,
.ttb-tag {
border-color: CanvasText;
}
}
</style>
===== END src/components/TagTitleBanner.astro =====
===== FILE (required): public/tag-title-banner/port.webp =====
[binary asset — download https://astro.baysixmedia.com/tag-title-banner/port.webp and save it to public/tag-title-banner/port.webp]
===== FILE (required): src/components/BadgeImageIntro.astro =====
---
/**
* BadgeImageIntro
*
* An intro section: a large rounded photo with a round bite taken out of its
* top-right edge, where a spinning orange badge (circular text around an
* icon) sits; beside it an uppercase heading with orange words, a paragraph
* and two pale cards, each with a round orange icon, a title, a hairline and a
* short checklist.
*
* The badge turns slowly and forever (stopped under reduced motion); heading
* words rise in and the cards lift in the first time they scroll into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export type BadgeIntroIcon = "plane" | "cube" | "truck" | "shield" | "route" | "clock";
export interface BadgeIntroCard {
title: string;
points: string[];
icon?: BadgeIntroIcon;
}
export interface BadgeImageIntroProps {
badgeText?: string;
heading?: string;
headingLevel?: "h2" | "h3";
text?: string;
cards?: BadgeIntroCard[];
/** `src: ""` renders a labelled placeholder. */
image?: { src?: string; alt?: string };
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends BadgeImageIntroProps {}
/* No built-in services, copy or photo: everything shown comes from the caller. */
const {
badgeText = "",
heading = "",
headingLevel = "h2",
text = "",
cards = [],
image: imageProp,
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* Never merged with a demo photo: a caller's image is used exactly as given. */
const image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const hasCopy = words.length > 0 || Boolean(text) || cards.length > 0;
const hasContent = hasCopy || Boolean(image.src);
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const pathId = `${id ?? "bii"}-arc-${Math.random().toString(36).slice(2, 7)}`;
const ICONS: Record<BadgeIntroIcon, string> = {
plane: "M2 13l20-8-6 16-4-7Z M12 14l4-4 M3 19h8",
cube: "M12 3 20 7v10l-8 4-8-4V7Z M4 7l8 4 8-4 M12 11v10 M8 5l8 4",
truck: "M2 7h12v9H2Z M14 10h4l3 3v3h-7 M6 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z M17 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z",
shield: "M12 3 19 6v5c0 5-3 8.5-7 10-4-1.5-7-5-7-10V6Z M9 12l2 2 4-4",
route: "M6 20a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z M18 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z M6 16V9a3 3 0 0 1 3-3h3 M18 8v7a3 3 0 0 1-3 3h-3",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z M12 7v5l3 2",
};
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section class:list={["bii", className]} id={id} style={`--bii-band:${background}`} data-bii-root>
<div class="bii-inner">
<div class="bii-visual">
<div class:list={["bii-media", { "bii-media--bite": badgeText }]}>
{
image.src ? (
<img src={image.src} alt={image.alt} width="1244" height="1100" loading="lazy" decoding="async" />
) : (
<div class="bii-ph" role="img" aria-label="Image placeholder">
<span>Image · 1244 × 1100</span>
</div>
)
}
</div>
{
badgeText && (
<div class="bii-badge" aria-hidden="true">
<svg class="bii-ring" viewBox="0 0 136 136">
<defs>
<path id={pathId} d="M68 68m-50 0a50 50 0 1 1 100 0a50 50 0 1 1-100 0" />
</defs>
<text>
<textPath href={`#${pathId}`} textLength="312">
{badgeText.toUpperCase()}
</textPath>
</text>
</svg>
<span class="bii-core">
<svg viewBox="0 0 24 24">
<path d="M12 3 21 12 12 21 3 12Z" fill="currentColor" />
<path d="M9.5 14.5 14.5 9.5M11 9.5h3.5V13" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</div>
)
}
</div>
{hasCopy && (
<div class="bii-copy">
{words.length > 0 && (
<Heading class="bii-heading" data-bii-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["bii-w", { "bii-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
{text && <p class="bii-text">{text}</p>}
{cards.length > 0 && (
<ul class="bii-cards">
{
cards.map((c, i) => (
<li class="bii-card" data-bii-reveal="up" style={`--d:${i * 0.12}s`}>
<div class="bii-card-head">
<span class="bii-ic" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d={ICONS[c.icon ?? "truck"]} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<Title class="bii-title">{c.title}</Title>
</div>
<ul class="bii-points">
{c.points.map((p) => (
<li>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{p}
</li>
))}
</ul>
</li>
))
}
</ul>
)}
</div>
)}
</div>
</section>
)}
<style>
.bii {
--bii-navy: var(--tw-navy, #0b4654);
--bii-accent: var(--tw-accent, #ff6d45);
--bii-surface: var(--tw-surface, #f0f8fa);
--bii-muted: var(--tw-muted, #616161);
--bii-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--bii-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
--bii-ease: cubic-bezier(0.44, 0, 0.56, 1);
--bii-badge: 112px;
background: var(--bii-band);
color: var(--bii-navy);
font-family: var(--bii-body);
}
.bii *,
.bii *::before,
.bii *::after {
box-sizing: border-box;
}
.bii-inner {
display: grid;
gap: 40px;
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.bii-visual {
position: relative;
aspect-ratio: 622 / 550;
}
.bii-media {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 30px;
}
/* The bite the badge sits in: a circle centred on the badge, a little
larger than it. */
.bii-media--bite {
-webkit-mask-image: radial-gradient(circle calc(var(--bii-badge) / 2 + 22px) at calc(100% - var(--bii-badge) / 2 - 36px) calc(var(--bii-badge) / 2), transparent 98%, #000 100%);
mask-image: radial-gradient(circle calc(var(--bii-badge) / 2 + 22px) at calc(100% - var(--bii-badge) / 2 - 36px) calc(var(--bii-badge) / 2), transparent 98%, #000 100%);
}
.bii-media img,
.bii-ph {
width: 100%;
height: 100%;
object-fit: cover;
}
.bii-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #e3eef1 0 12px, #edf5f7 12px 24px);
font: 500 13px/1.4 var(--bii-body);
}
.bii-badge {
position: absolute;
top: 0;
right: 36px;
display: grid;
place-items: center;
width: var(--bii-badge);
height: var(--bii-badge);
border-radius: 50%;
background: var(--bii-accent);
}
.bii-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
fill: #fff;
font: 500 14px/1 var(--bii-card-font);
letter-spacing: 1px;
animation: bii-spin 14s linear infinite;
}
@keyframes bii-spin {
to {
transform: rotate(360deg);
}
}
.bii-core {
display: grid;
place-items: center;
width: 44%;
height: 44%;
border-radius: 50%;
background: #fff;
color: var(--bii-accent);
}
.bii-core svg {
width: 58%;
height: 58%;
}
.bii-copy {
min-width: 0;
}
.bii-heading {
margin: 0;
font: 700 26px/1.4 var(--bii-card-font);
letter-spacing: -1.04px;
text-transform: uppercase;
}
.bii-w {
display: inline-block;
}
.bii-accent {
color: var(--bii-accent);
}
.bii-text {
max-width: 572px;
margin: 24px 0 0;
color: var(--bii-muted);
font: 400 15px/24px var(--bii-body);
letter-spacing: -0.3px;
}
.bii-cards {
display: grid;
gap: 20px;
margin: 40px 0 0;
padding: 0;
list-style: none;
}
.bii-card {
padding: 30px;
border-radius: 20px;
background: var(--bii-surface);
}
.bii-card-head {
display: flex;
align-items: center;
gap: 15px;
padding-bottom: 22px;
border-bottom: 1px solid rgb(11 70 84 / 0.12);
}
.bii-ic {
display: grid;
flex: none;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--bii-accent);
color: #fff;
}
.bii-ic svg {
width: 26px;
height: 26px;
}
.bii-title {
margin: 0;
font: 600 22px/1.2 var(--bii-card-font);
letter-spacing: -0.88px;
}
.bii-points {
display: grid;
gap: 9px;
margin: 22px 0 0;
padding: 0;
list-style: none;
}
.bii-points li {
display: flex;
align-items: center;
gap: 12px;
color: var(--bii-muted);
font: 400 16px/25.6px var(--bii-body);
letter-spacing: -0.32px;
}
.bii-points svg {
flex: none;
width: 20px;
height: 20px;
color: var(--bii-accent);
}
[data-bii-reveal="up"] {
transition:
opacity 0.8s var(--bii-ease) var(--d, 0s),
transform 0.8s var(--bii-ease) var(--d, 0s);
}
.bii[data-bii-ready] [data-bii-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.bii-w {
transition:
opacity 0.6s var(--bii-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--bii-ease) calc(var(--i) * 0.06s);
}
.bii[data-bii-ready] [data-bii-reveal="words"]:not([data-in]) .bii-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.bii {
--bii-badge: 136px;
}
.bii-inner {
padding: 90px 40px;
}
.bii-heading {
font-size: 32px;
}
.bii-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1200px) {
.bii-inner {
grid-template-columns: 622px minmax(0, 1fr);
gap: 76px;
align-items: start;
padding: 100px 60px 110px;
}
.bii-copy {
padding-top: 25px;
}
.bii-heading {
font-size: 36px;
line-height: 50.4px;
letter-spacing: -1.44px;
}
.bii-title {
font-size: 24px;
line-height: 28.8px;
letter-spacing: -0.96px;
}
}
@media (prefers-reduced-motion: reduce) {
.bii-ring {
animation: none;
}
[data-bii-reveal="up"],
.bii-w {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initBadgeImageIntro(root: HTMLElement) {
if (root.dataset.biiReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.biiReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-bii-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.biiReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-bii-root]").forEach(initBadgeImageIntro);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/BadgeImageIntro.astro =====
===== FILE (required): public/badge-image-intro/trailer.webp =====
[binary asset — download https://astro.baysixmedia.com/badge-image-intro/trailer.webp and save it to public/badge-image-intro/trailer.webp]
===== FILE (required): src/components/AlternatingServiceRows.astro =====
---
/**
* AlternatingServiceRows
*
* A services section on a pale band: centred eyebrow and big uppercase
* heading, then rows that pair a photo with a navy panel, swapping sides
* each row. The inner top corners of both halves are chamfered and a round
* orange arrow link sits in a white collar on the seam between them. Each
* navy panel has an orange pill, an uppercase title, a huge faded index
* number, a hairline and two check-marked points.
*
* The arrow link turns navy and tilts on hover; rows lift in the first time
* they scroll into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface AlternatingService {
title: string;
tag?: string;
points?: string[];
href?: string;
/** `src: ""` renders a labelled placeholder. */
image?: { src?: string; alt?: string };
}
export interface AlternatingServiceRowsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
services?: AlternatingService[];
/** Show the faded "01", "02"… in each panel. */
numbered?: boolean;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends AlternatingServiceRowsProps {}
const {
eyebrow = "Core Services",
heading = "",
headingLevel = "h2",
services = [],
numbered = true,
background = "#f0f8fa",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
/* A row needs a title, or it is dropped rather than drawn half-empty. No rows,
no section: the eyebrow and heading alone never render. */
const list = services
.filter((s) => s?.title)
.map((s) => ({ ...s, points: s.points?.filter(Boolean) }));
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const pad = (n: number) => String(n).padStart(2, "0");
---
{list.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section class:list={["asr", className]} id={id} style={`--asr-band:${background}`} data-asr-root>
<div class="asr-inner">
<div class="asr-head">
{eyebrow && <p class="asr-eyebrow">{eyebrow}</p>}
{words.length > 0 && (
<Heading class="asr-heading" data-asr-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["asr-w", { "asr-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
</div>
<ol class="asr-rows">
{
list.map((s, i) => {
const img = s.image ?? { src: "" };
return (
<li class:list={["asr-row", { "asr-row--flip": i % 2 === 1 }]} data-asr-reveal="up">
<div class="asr-media">
{img.src ? (
<img src={img.src} alt={img.alt ?? ""} width="1290" height="902" loading="lazy" decoding="async" />
) : (
<div class="asr-ph" role="img" aria-label={`Image placeholder: ${s.title}`}>
<span>Image · 1290 × 902</span>
</div>
)}
</div>
<div class="asr-panel">
{s.tag && <span class="asr-tag">{s.tag}</span>}
<Title class="asr-title">{s.title}</Title>
{numbered && (
<span class="asr-num" aria-hidden="true">
{pad(i + 1)}
</span>
)}
{s.points && s.points.length > 0 && (
<ul class="asr-points">
{s.points.map((p) => (
<li>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="currentColor" />
<path d="m6 10.2 2.6 2.6L14 7.5" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{p}
</li>
))}
</ul>
)}
</div>
{s.href && (
<span class="asr-collar">
<a class="asr-link" href={safeHref(s.href)} aria-label={`Learn more: ${s.title}`}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</span>
)}
</li>
);
})
}
</ol>
</div>
</section>
)}
<style>
.asr {
--asr-navy: var(--tw-navy, #0b4654);
--asr-accent: var(--tw-accent, #ff6d45);
--asr-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--asr-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--asr-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
--asr-ease: cubic-bezier(0.44, 0, 0.56, 1);
--asr-cut: 64px;
background: var(--asr-band);
color: var(--asr-navy);
font-family: var(--asr-body);
}
.asr *,
.asr *::before,
.asr *::after {
box-sizing: border-box;
}
/* Step 39.2: --tw-grid-max / --tw-grid-gutter put the content on a site grid; the fallbacks are the original values. */
.asr-inner {
max-width: var(--tw-grid-max, 1440px);
margin: 0 auto;
padding: 64px 16px;
}
.asr-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
text-align: center;
}
.asr-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--asr-accent);
border-radius: 999px;
color: var(--asr-accent);
font: 500 15px/24px var(--asr-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.asr-heading {
max-width: 680px;
margin: 0;
font: 700 28px/1.2 var(--asr-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.asr-w {
display: inline-block;
}
.asr-accent {
color: var(--asr-accent);
}
.asr-rows {
display: grid;
gap: 20px;
margin: 40px 0 0;
padding: 0;
list-style: none;
}
.asr-row {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.asr-media {
aspect-ratio: 645 / 451;
}
.asr-media img,
.asr-ph {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 20px;
}
.asr-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #dbe9ed 0 12px, #e6f0f3 12px 24px);
font: 500 13px/1.4 var(--asr-body);
}
.asr-panel {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 36px 28px 104px;
overflow: hidden;
border-radius: 20px;
background: var(--asr-navy);
color: #fff;
}
.asr-tag {
padding: 4px 12px;
border-radius: 18px;
background: var(--asr-accent);
font: 400 12px/19.2px var(--asr-body);
letter-spacing: -0.24px;
text-transform: uppercase;
}
.asr-title {
position: relative;
z-index: 1;
max-width: 420px;
margin: 30px 0 0;
font: 700 26px/1.4 var(--asr-card-font);
letter-spacing: -1.04px;
text-transform: uppercase;
}
.asr-num {
position: absolute;
top: 44px;
right: 36px;
color: rgb(255 255 255 / 0.1);
font: 600 72px/1 var(--asr-display);
letter-spacing: -2px;
}
.asr-points {
display: grid;
gap: 15px;
width: 100%;
margin: 40px 0 0;
padding: 30px 0 0;
border-top: 1px solid rgb(255 255 255 / 0.12);
list-style: none;
}
.asr-points li {
display: flex;
align-items: center;
gap: 10px;
font: 400 16px/25.6px var(--asr-body);
letter-spacing: -0.32px;
}
.asr-points svg {
flex: none;
width: 20px;
height: 20px;
color: var(--asr-accent);
}
.asr-collar {
position: absolute;
top: calc(50% - 40px);
left: calc(50% - 40px);
z-index: 2;
display: none;
place-items: center;
width: 80px;
height: 80px;
border-radius: 50%;
background: #fff;
}
.asr-link {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--asr-accent);
color: #fff;
transition: background-color 0.3s var(--asr-ease);
}
.asr-link svg {
width: 22px;
height: 22px;
transition: transform 0.3s var(--asr-ease);
}
.asr-link:hover {
background: var(--asr-navy);
}
.asr-link:hover svg {
transform: rotate(-45deg);
}
.asr-link:focus-visible {
outline: 2px solid var(--asr-navy);
outline-offset: 3px;
}
/* Phone: the link sits at the panel's foot instead of on a seam. */
.asr-row .asr-collar {
display: grid;
top: auto;
bottom: 20px;
left: auto;
right: 20px;
width: 66px;
height: 66px;
}
[data-asr-reveal="up"] {
transition:
opacity 0.8s var(--asr-ease),
transform 0.8s var(--asr-ease);
}
.asr[data-asr-ready] [data-asr-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.asr-w {
transition:
opacity 0.6s var(--asr-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--asr-ease) calc(var(--i) * 0.06s);
}
.asr[data-asr-ready] [data-asr-reveal="words"]:not([data-in]) .asr-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.asr-inner {
padding: 90px 40px;
}
.asr-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.asr-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.asr-row--flip .asr-media {
order: 2;
}
.asr-media {
aspect-ratio: auto;
min-height: 380px;
}
/* Chamfer the inner top corner of each half. */
.asr-row:not(.asr-row--flip) .asr-media img,
.asr-row:not(.asr-row--flip) .asr-ph,
.asr-row--flip .asr-panel {
clip-path: polygon(0 0, calc(100% - var(--asr-cut)) 0, 100% var(--asr-cut), 100% 100%, 0 100%);
}
.asr-row:not(.asr-row--flip) .asr-panel,
.asr-row--flip .asr-media img,
.asr-row--flip .asr-ph {
clip-path: polygon(var(--asr-cut) 0, 100% 0, 100% 100%, 0 100%, 0 var(--asr-cut));
}
.asr-row .asr-collar {
top: calc(50% - 40px);
bottom: auto;
left: calc(50% - 40px);
right: auto;
width: 80px;
height: 80px;
}
.asr-panel {
padding: 56px 40px 50px 56px;
}
.asr-row--flip .asr-panel {
padding: 56px 56px 50px 56px;
}
}
@media (min-width: 1200px) {
.asr-inner {
padding: 100px 0;
padding-inline: var(--tw-grid-gutter, 65px);
}
.asr-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.asr-rows {
gap: 15px;
margin-top: 55px;
}
.asr-media {
min-height: 451px;
}
.asr-panel {
padding: 60px 40px 50px 60px;
}
.asr-title {
margin-top: 28px;
font-size: 36px;
line-height: 50.4px;
letter-spacing: -1.44px;
}
.asr-num {
top: 80px;
right: 40px;
font-size: 100px;
line-height: 110px;
}
.asr-points {
margin-top: auto;
}
}
@media (prefers-reduced-motion: reduce) {
[data-asr-reveal="up"],
.asr-w,
.asr-link,
.asr-link svg {
transition: none;
}
.asr-link:hover svg {
transform: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initAlternatingServiceRows(root: HTMLElement) {
if (root.dataset.asrReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.asrReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-asr-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.asrReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-asr-root]").forEach(initAlternatingServiceRows);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/AlternatingServiceRows.astro =====
===== FILE (required): public/alternating-service-rows/ocean-freight.webp =====
[binary asset — download https://astro.baysixmedia.com/alternating-service-rows/ocean-freight.webp and save it to public/alternating-service-rows/ocean-freight.webp]
===== FILE (required): public/alternating-service-rows/road-freight.webp =====
[binary asset — download https://astro.baysixmedia.com/alternating-service-rows/road-freight.webp and save it to public/alternating-service-rows/road-freight.webp]
===== FILE (required): public/alternating-service-rows/express.webp =====
[binary asset — download https://astro.baysixmedia.com/alternating-service-rows/express.webp and save it to public/alternating-service-rows/express.webp]
===== FILE (required): src/components/ChamferStepCards.astro =====
---
/**
* ChamferStepCards
*
* A "how it works" section: outlined eyebrow, a big uppercase heading and an
* intro paragraph across the top, then three step cards. Each card is a photo
* with both top corners chamfered and a pale caption panel overlapping its
* lower edge, carrying an orange "Step 01" pill, an uppercase title and a
* sentence.
*
* Heading words rise in and the cards lift in, staggered, the first time they
* scroll into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface ChamferStep {
title: string;
text: string;
/** Pill text. Defaults to "Step 01", "Step 02"... */
label?: string;
/** `src: ""` renders a labelled placeholder. */
image?: { src?: string; alt?: string };
}
export interface ChamferStepCardsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
intro?: string;
steps?: ChamferStep[];
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ChamferStepCardsProps {}
const {
eyebrow = "How It Works",
heading = "",
headingLevel = "h2",
intro = "",
steps = [],
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const pad = (n: number) => String(n).padStart(2, "0");
/* No demo content: steps, heading and intro are props. A step with no title
is dropped; with no steps left the section renders nothing, and the
generic eyebrow never makes it render on its own. */
const items = (steps ?? []).filter((s) => s?.title);
---
{items.length > 0 && (
<>{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["csc", className]} id={id} style={`--csc-band:${background}`} data-csc-root>
<div class="csc-inner">
<div class="csc-head">
{eyebrow && <p class="csc-eyebrow">{eyebrow}</p>}
{words.length > 0 && (
<Heading class="csc-heading" data-csc-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["csc-w", { "csc-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
{intro && <p class="csc-intro">{intro}</p>}
</div>
<ol class="csc-grid">
{
items.map((s, i) => {
const img = s.image ?? { src: "" };
return (
<li class="csc-card" data-csc-reveal="up" style={`--d:${i * 0.12}s`}>
<div class="csc-media">
{img.src ? (
<img src={img.src} alt={img.alt ?? ""} width="848" height="584" loading="lazy" decoding="async" />
) : (
<div class="csc-ph" role="img" aria-label={`Image placeholder: ${s.title}`}>
<span>Image · 848 × 584</span>
</div>
)}
</div>
<div class="csc-panel">
<span class="csc-pill">{s.label ?? `Step ${pad(i + 1)}`}</span>
<Title class="csc-title">{s.title}</Title>
{s.text && <p class="csc-text">{s.text}</p>}
</div>
</li>
);
})
}
</ol>
</div>
</section></>
)}
<style>
.csc {
--csc-navy: var(--tw-navy, #0b4654);
--csc-accent: var(--tw-accent, #ff6d45);
--csc-surface: var(--tw-surface, #f0f8fa);
--csc-muted: var(--tw-muted, #616161);
--csc-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--csc-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--csc-ease: cubic-bezier(0.44, 0, 0.56, 1);
--csc-cut: 44px;
background: var(--csc-band);
color: var(--csc-navy);
font-family: var(--csc-body);
}
.csc *,
.csc *::before,
.csc *::after {
box-sizing: border-box;
}
.csc-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.csc-head {
display: grid;
gap: 18px;
justify-items: start;
}
.csc-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--csc-accent);
border-radius: 999px;
color: var(--csc-accent);
font: 500 15px/24px var(--csc-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.csc-heading {
margin: 0;
font: 700 28px/1.2 var(--csc-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.csc-w {
display: inline-block;
}
.csc-accent {
color: var(--csc-accent);
}
.csc-intro {
max-width: 400px;
margin: 0;
font: 400 16px/25.6px var(--csc-body);
letter-spacing: -0.32px;
}
.csc-grid {
display: grid;
gap: 24px;
margin: 40px 0 0;
padding: 0;
list-style: none;
}
.csc-card {
display: flex;
flex-direction: column;
}
.csc-media {
aspect-ratio: 424 / 292;
}
.csc-media img,
.csc-ph {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 14px;
clip-path: polygon(var(--csc-cut) 0, calc(100% - var(--csc-cut)) 0, 100% var(--csc-cut), 100% 100%, 0 100%, 0 var(--csc-cut));
}
.csc-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #e3eef1 0 12px, #edf5f7 12px 24px);
font: 500 13px/1.4 var(--csc-body);
}
.csc-panel {
position: relative;
margin: -52px 20px 0;
padding: 16px 20px 20px;
border-radius: 10px;
background: var(--csc-surface);
}
.csc-pill {
display: inline-block;
padding: 4px 12px;
border-radius: 18px;
background: var(--csc-accent);
color: #fff;
font: 600 14px/22.4px var(--csc-body);
letter-spacing: -0.28px;
text-transform: uppercase;
}
.csc-title {
margin: 12px 0 0;
font: 600 20px/32px var(--csc-body);
letter-spacing: -0.4px;
text-transform: uppercase;
}
.csc-text {
margin: 2px 0 0;
color: var(--csc-muted);
font: 400 16px/25.6px var(--csc-body);
letter-spacing: -0.32px;
}
[data-csc-reveal="up"] {
transition:
opacity 0.8s var(--csc-ease) var(--d, 0s),
transform 0.8s var(--csc-ease) var(--d, 0s);
}
.csc[data-csc-ready] [data-csc-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.csc-w {
transition:
opacity 0.6s var(--csc-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--csc-ease) calc(var(--i) * 0.06s);
}
.csc[data-csc-ready] [data-csc-reveal="words"]:not([data-in]) .csc-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.csc-inner {
padding: 90px 40px;
}
.csc-head {
grid-template-columns: auto minmax(0, 1fr);
column-gap: 40px;
}
.csc-eyebrow {
margin-top: 40px;
}
.csc-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.csc-intro {
grid-column: 2;
}
.csc-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
row-gap: 36px;
}
}
@media (min-width: 1200px) {
.csc-inner {
padding: 100px 65px;
}
.csc-head {
grid-template-columns: 245px 640px minmax(0, 1fr);
column-gap: 0;
align-items: start;
}
.csc-eyebrow {
margin: 48px 0 0 15px;
}
.csc-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.csc-intro {
grid-column: 3;
justify-self: end;
margin-top: 22px;
}
.csc-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
margin-top: 60px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-csc-reveal="up"],
.csc-w {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initChamferStepCards(root: HTMLElement) {
if (root.dataset.cscReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.cscReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-csc-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.cscReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-csc-root]").forEach(initChamferStepCards);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ChamferStepCards.astro =====
===== FILE (required): public/chamfer-step-cards/containers.webp =====
[binary asset — download https://astro.baysixmedia.com/chamfer-step-cards/containers.webp and save it to public/chamfer-step-cards/containers.webp]
===== FILE (required): public/chamfer-step-cards/train.webp =====
[binary asset — download https://astro.baysixmedia.com/chamfer-step-cards/train.webp and save it to public/chamfer-step-cards/train.webp]
===== FILE (required): public/chamfer-step-cards/courier.webp =====
[binary asset — download https://astro.baysixmedia.com/chamfer-step-cards/courier.webp and save it to public/chamfer-step-cards/courier.webp]
===== FILE (required): src/components/FreightPricingPlans.astro =====
---
/**
* FreightPricingPlans
*
* A pale pricing band: outlined eyebrow, a big three-line uppercase heading
* with an orange last line and a photo underneath on the left; two white plan
* cards on the right. Each card has an orange asterisk, an optional outlined
* badge, the plan name, a one-line pitch, a big price with its period, a
* full-width orange pill and a checklist.
*
* Heading words rise in and the cards lift in, staggered, the first time they
* scroll into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface FreightPlan {
name: string;
pitch?: string;
price: string;
period?: string;
/** Outlined badge in the card corner, e.g. "Popular". */
badge?: string;
cta?: { label: string; href: string } | null;
features: string[];
}
export interface FreightPricingPlansProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** `src: ""` renders a labelled placeholder; `null` drops it. */
image?: { src?: string; alt?: string } | null;
plans?: FreightPlan[];
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends FreightPricingPlansProps {}
const {
eyebrow = "Pricing",
heading = "",
headingLevel = "h2",
image: imageProp,
/* Plans and prices are the caller's; none are defaulted. */
plans = [],
background = "#f0f8fa",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* No default photo: the image is drawn only when passed, and never merged
with a demo one. `src: ""` still draws the neutral labelled placeholder. */
const image = imageProp ? { src: imageProp.src ?? "", alt: imageProp.alt ?? "" } : null;
const filled = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";
/* A plan needs a name and a price; a CTA needs a label and an href; blank
features are skipped. No plans → no section. */
const planList = (plans ?? [])
.filter((plan) => plan && filled(plan.name) && filled(plan.price))
.map((plan) => ({
...plan,
cta: plan.cta && filled(plan.cta.label) && filled(plan.cta.href) ? plan.cta : null,
features: (plan.features ?? []).filter(filled),
}));
const words = (heading ?? "")
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
---
{planList.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{planList.length > 0 && (
<section class:list={["fpp", className]} id={id} style={`--fpp-band:${background}`} data-fpp-root>
<div class="fpp-inner">
<div class="fpp-side">
{eyebrow && <p class="fpp-eyebrow">{eyebrow}</p>}
{words.length > 0 && (
<Heading class="fpp-heading" data-fpp-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["fpp-w", { "fpp-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
{
image && (
<div class="fpp-media">
{image.src ? (
<img src={image.src} alt={image.alt} width="748" height="580" loading="lazy" decoding="async" />
) : (
<div class="fpp-ph" role="img" aria-label="Image placeholder">
<span>Image · 748 × 580</span>
</div>
)}
</div>
)
}
</div>
<ul class="fpp-plans">
{
planList.map((plan, i) => (
<li class="fpp-card" data-fpp-reveal="up" style={`--d:${i * 0.12}s`}>
<div class="fpp-card-top">
<svg class="fpp-star" viewBox="0 0 30 30" aria-hidden="true">
<path d="M12.6 1h4.8l-.9 9.6 7.9-5.6 2.4 4.2-8.8 4 8.8 4-2.4 4.2-7.9-5.6.9 9.6h-4.8l.9-9.6-7.9 5.6-2.4-4.2 8.8-4-8.8-4 2.4-4.2 7.9 5.6Z" fill="currentColor" />
</svg>
{plan.badge && <span class="fpp-badge">{plan.badge}</span>}
</div>
<Title class="fpp-name">{plan.name}</Title>
{plan.pitch && <p class="fpp-pitch">{plan.pitch}</p>}
<p class="fpp-price">
<span class="fpp-amount">{plan.price}</span>
{plan.period && <span class="fpp-period">{plan.period}</span>}
</p>
{plan.cta && (
<a class="fpp-btn" href={safeHref(plan.cta.href)}>
<span>
{plan.cta.label}
<span class="fpp-sr">: {plan.name}</span>
</span>
<span class="fpp-btn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)}
{plan.features.length > 0 && (
<ul class="fpp-list">
{plan.features.map((f) => (
<li>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="currentColor" />
<path d="m6 10.2 2.6 2.6L14 7.5" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{f}
</li>
))}
</ul>
)}
</li>
))
}
</ul>
</div>
</section>
)}
<style>
.fpp {
--fpp-navy: var(--tw-navy, #0b4654);
--fpp-navy-2: var(--tw-navy-2, #15505e);
--fpp-deep: var(--tw-navy-deep, #052931);
--fpp-accent: var(--tw-accent, #ff6d45);
--fpp-muted: var(--tw-muted, #616161);
--fpp-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--fpp-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--fpp-ease: cubic-bezier(0.44, 0, 0.56, 1);
background: var(--fpp-band);
color: var(--fpp-navy);
font-family: var(--fpp-body);
}
.fpp *,
.fpp *::before,
.fpp *::after {
box-sizing: border-box;
}
.fpp-inner {
display: grid;
gap: 40px;
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.fpp-side {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.fpp-eyebrow {
margin: 0 0 22px;
padding: 4px 14px;
border: 1px solid var(--fpp-accent);
border-radius: 999px;
color: var(--fpp-accent);
font: 500 15px/24px var(--fpp-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.fpp-heading {
max-width: 440px;
margin: 0;
font: 700 28px/1.2 var(--fpp-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.fpp-w {
display: inline-block;
}
.fpp-accent {
color: var(--fpp-accent);
}
.fpp-media {
width: min(374px, 100%);
margin-top: 40px;
aspect-ratio: 374 / 290;
}
.fpp-media img,
.fpp-ph {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 20px;
}
.fpp-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #dbe9ed 0 12px, #e6f0f3 12px 24px);
font: 500 13px/1.4 var(--fpp-body);
}
.fpp-plans {
display: grid;
gap: 20px;
margin: 0;
padding: 0;
list-style: none;
}
.fpp-card {
display: flex;
flex-direction: column;
padding: 30px;
border-radius: 20px;
background: #fff;
}
.fpp-card-top {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
margin-bottom: 22px;
}
.fpp-star {
width: 30px;
height: 30px;
color: var(--fpp-accent);
}
.fpp-badge {
padding: 3px 14px;
border: 1px solid var(--fpp-accent);
border-radius: 999px;
color: var(--fpp-accent);
font: 500 15px/24px var(--fpp-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.fpp-name {
margin: 0;
color: var(--fpp-navy-2);
font: 600 30px/42px var(--fpp-display);
letter-spacing: -0.6px;
text-transform: uppercase;
}
.fpp-pitch {
margin: 4px 0 0;
color: var(--fpp-muted);
font: 400 16px/25.6px var(--fpp-body);
letter-spacing: -0.32px;
}
.fpp-price {
display: flex;
align-items: baseline;
gap: 10px;
margin: 16px 0 0;
}
.fpp-amount {
font: 700 50px/60px var(--fpp-display);
letter-spacing: -1px;
}
.fpp-period {
font: 400 16px/25.6px var(--fpp-body);
letter-spacing: -0.32px;
}
.fpp-btn {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-top: 20px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--fpp-accent);
color: #fff;
font: 400 16px/25.6px var(--fpp-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: background-color 0.3s var(--fpp-ease);
}
.fpp-btn:hover {
background: var(--fpp-deep);
}
.fpp-btn-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--fpp-accent);
transition: transform 0.3s var(--fpp-ease);
}
.fpp-btn-ic svg {
width: 18px;
height: 18px;
}
.fpp-btn:hover .fpp-btn-ic {
transform: rotate(-45deg);
}
.fpp-btn:focus-visible {
outline: 2px solid var(--fpp-navy);
outline-offset: 3px;
}
.fpp-list {
display: grid;
gap: 14px;
margin: 44px 0 0;
padding: 0;
list-style: none;
}
.fpp-list li {
display: flex;
align-items: center;
gap: 12px;
font: 400 16px/25.6px var(--fpp-body);
letter-spacing: -0.32px;
}
.fpp-list svg {
flex: none;
width: 18px;
height: 18px;
color: var(--fpp-accent);
}
.fpp-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
[data-fpp-reveal="up"] {
transition:
opacity 0.8s var(--fpp-ease) var(--d, 0s),
transform 0.8s var(--fpp-ease) var(--d, 0s);
}
.fpp[data-fpp-ready] [data-fpp-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.fpp-w {
transition:
opacity 0.6s var(--fpp-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--fpp-ease) calc(var(--i) * 0.06s);
}
.fpp[data-fpp-ready] [data-fpp-reveal="words"]:not([data-in]) .fpp-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.fpp-inner {
padding: 90px 40px;
}
.fpp-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.fpp-plans {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1200px) {
.fpp-inner {
grid-template-columns: 487px minmax(0, 1fr);
align-items: start;
padding: 100px 65px;
}
.fpp-side {
align-self: stretch;
}
.fpp-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.fpp-media {
margin-top: auto;
}
}
@media (prefers-reduced-motion: reduce) {
[data-fpp-reveal="up"],
.fpp-w,
.fpp-btn,
.fpp-btn-ic {
transition: none;
}
.fpp-btn:hover .fpp-btn-ic {
transform: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initFreightPricingPlans(root: HTMLElement) {
if (root.dataset.fppReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.fppReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-fpp-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.fppReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-fpp-root]").forEach(initFreightPricingPlans);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/FreightPricingPlans.astro =====
===== FILE (required): public/freight-pricing-plans/forklift.webp =====
[binary asset — download https://astro.baysixmedia.com/freight-pricing-plans/forklift.webp and save it to public/freight-pricing-plans/forklift.webp]
===== FILE (required): src/components/PortraitRatingTestimonials.astro =====
---
/**
* PortraitRatingTestimonials
*
* A centred testimonial section: outlined eyebrow and a big uppercase
* heading over three columns. The outer columns each hold two quote cards
* (orange quote mark, quote, avatar, name, role and a small star score); the
* middle column is a tall portrait photo fading to navy at the foot, carrying
* an overall rating with five stars and a review count.
*
* Heading words rise in and the cards lift in, staggered, the first time they
* scroll into view.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface PortraitTestimonial {
quote: string;
name: string;
role?: string;
/** Score shown in the pill, e.g. "5.0". Omit it (or `""`) and there is no pill. */
score?: string;
/** `src: ""` renders initials instead. */
avatar?: { src?: string; alt?: string };
}
export interface PortraitRatingTestimonialsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** Four quotes: two left, two right. Extra ones are ignored. */
testimonials?: PortraitTestimonial[];
/** The centre photo. `src: ""` renders a labelled placeholder. */
portrait?: { src?: string; alt?: string };
/**
* The overall rating, from a real review source. Omit it (or pass `null`)
* and there is none: an aggregate rating and a review count are never
* supplied by the component. The stars follow the score's leading number.
*/
rating?: { score: string; count: string } | null;
background?: string;
/** Quote card fill. White on a pale band; pale on a white band. */
cardBackground?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends PortraitRatingTestimonialsProps {}
const {
eyebrow = "Testimonials",
heading = "What our *clients* say",
headingLevel = "h2",
/* No quotes of its own: they come from the caller or not at all. */
testimonials = [],
portrait: portraitProp,
rating = null,
background = "#f0f8fa",
cardBackground = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* Whole stars for a score like "4.9/5.0": its leading number, rounded, 0–5. */
const starsFor = (score: string) => {
const n = Number.parseFloat(score);
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), 0), 5) : 0;
};
/* No photo of its own: unset (or `src: ""`) renders the labelled placeholder. */
const portrait = { src: "", alt: "", ...(portraitProp ?? {}) };
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const left = testimonials.slice(0, 2);
const right = testimonials.slice(2, 4);
const initials = (name: string) =>
name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
---
{testimonials.length > 0 && (<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
class:list={["prt", className]}
id={id}
style={`--prt-band:${background};--prt-card-bg:${cardBackground}`}
data-prt-root
>
<div class="prt-inner">
<div class="prt-head">
{eyebrow && <p class="prt-eyebrow">{eyebrow}</p>}
<Heading class="prt-heading" data-prt-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["prt-w", { "prt-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
</div>
<div class="prt-grid">
{
[left, right].map((col, c) => (
<ul class:list={["prt-col", c === 0 ? "prt-col--left" : "prt-col--right"]}>
{col.map((t, i) => (
<li class="prt-card" data-prt-reveal="up" style={`--d:${(c * 2 + i) * 0.1}s`}>
<svg class="prt-mark" viewBox="0 0 40 40" aria-hidden="true">
<path d="M4 34V22C4 12 8 7 17 5l1 4c-5 2-7 5-7 9h6v16Zm19 0V22c0-10 4-15 13-17l1 4c-5 2-7 5-7 9h6v16Z" fill="currentColor" />
</svg>
<blockquote class="prt-quote">
<p>{t.quote}</p>
</blockquote>
<div class="prt-person">
{t.avatar?.src ? (
<img class="prt-avatar" src={t.avatar.src} alt={t.avatar.alt ?? ""} width="40" height="40" loading="lazy" decoding="async" />
) : (
<span class="prt-avatar prt-avatar--initials" aria-hidden="true">
{initials(t.name)}
</span>
)}
<div class="prt-who">
<p class="prt-name">{t.name}</p>
{t.role && <p class="prt-role">{t.role}</p>}
</div>
{t.score && (
<p class="prt-score">
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="m10 1.8 2.5 5.3 5.8.7-4.3 4 1.1 5.7L10 14.7l-5.1 2.8L6 11.8l-4.3-4 5.8-.7Z" fill="currentColor" />
</svg>
<span class="prt-sr">Rated </span>
{t.score}
</p>
)}
</div>
</li>
))}
</ul>
))
}
<figure class="prt-portrait" data-prt-reveal="up" style="--d:0.05s">
{
portrait.src ? (
<img src={portrait.src} alt={portrait.alt} width="854" height="1254" loading="lazy" decoding="async" />
) : (
<div class="prt-ph" role="img" aria-label="Image placeholder: portrait of a site worker">
<span>Image · portrait · 854 × 1254</span>
</div>
)
}
{
rating && (
<figcaption class="prt-rating">
<span class="prt-rating-badge" aria-hidden="true">
<svg viewBox="0 0 20 20"><path d="m10 1.8 2.5 5.3 5.8.7-4.3 4 1.1 5.7L10 14.7l-5.1 2.8L6 11.8l-4.3-4 5.8-.7Z" fill="currentColor" /></svg>
</span>
<span>
<span class="prt-rating-score">{rating.score}</span>
<span class="prt-rating-line">
{starsFor(rating.score) > 0 && (
<span class="prt-stars" aria-hidden="true">{"★".repeat(starsFor(rating.score))}</span>
)}
<span class="prt-rating-count">{rating.count}</span>
</span>
</span>
</figcaption>
)
}
</figure>
</div>
</div>
</section>
</>)}
<style>
.prt {
--prt-navy: var(--tw-navy, #0b4654);
--prt-accent: var(--tw-accent, #ff6d45);
--prt-muted: var(--tw-muted, #616161);
--prt-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--prt-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--prt-ease: cubic-bezier(0.44, 0, 0.56, 1);
background: var(--prt-band);
color: var(--prt-navy);
font-family: var(--prt-body);
}
.prt *,
.prt *::before,
.prt *::after {
box-sizing: border-box;
}
.prt-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.prt-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
text-align: center;
}
.prt-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--prt-accent);
border-radius: 999px;
color: var(--prt-accent);
font: 500 15px/24px var(--prt-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.prt-heading {
margin: 0;
font: 700 28px/1.2 var(--prt-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.prt-w {
display: inline-block;
}
.prt-accent {
color: var(--prt-accent);
}
.prt-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 15px;
margin-top: 40px;
}
.prt-col {
display: grid;
gap: 15px;
margin: 0;
padding: 0;
list-style: none;
}
.prt-card {
display: flex;
flex-direction: column;
min-width: 0;
padding: 25px;
border-radius: 20px;
background: var(--prt-card-bg);
}
.prt-mark {
width: 36px;
height: 36px;
color: var(--prt-accent);
}
.prt-quote {
margin: 16px 0 0;
}
.prt-quote p {
margin: 0;
font: 500 16px/25.6px var(--prt-body);
letter-spacing: -0.32px;
/* A long unbroken word in a real review wraps rather than pushing the page sideways. */
overflow-wrap: anywhere;
}
.prt-person {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 24px;
}
.prt-avatar {
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.prt-avatar--initials {
display: grid;
place-items: center;
background: var(--prt-navy);
color: #fff;
font: 600 14px/1 var(--prt-body);
}
.prt-who {
min-width: 0;
margin-right: auto;
}
.prt-name {
margin: 0;
overflow: hidden;
font: 500 18px/25.2px var(--prt-body);
letter-spacing: -0.36px;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.prt-role {
margin: 0;
color: var(--prt-muted);
font: 400 15px/24px var(--prt-body);
letter-spacing: -0.3px;
overflow-wrap: anywhere;
}
.prt-score {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
margin: 0;
padding: 2px 10px;
border: 1px solid rgb(11 70 84 / 0.12);
border-radius: 999px;
color: var(--prt-muted);
font: 600 14px/22.4px var(--prt-body);
}
.prt-score svg {
width: 13px;
height: 13px;
color: var(--prt-accent);
}
.prt-portrait {
position: relative;
min-height: 420px;
margin: 0;
overflow: hidden;
border-radius: 20px;
background: var(--prt-accent);
}
.prt-portrait img,
.prt-ph {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
}
.prt-portrait::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgb(255 255 255 / 0) 40%, var(--prt-navy) 80%, #000 100%);
}
.prt-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #ff8a67 0 12px, #ff7a53 12px 24px);
color: #fff;
font: 500 13px/1.4 var(--prt-body);
}
.prt-rating {
position: absolute;
left: 25px;
bottom: 30px;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
color: #fff;
}
.prt-rating-badge {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 50%;
background: #fff;
color: var(--prt-accent);
}
.prt-rating-badge svg {
width: 26px;
height: 26px;
}
.prt-rating-score {
display: block;
font: 600 30px/42px var(--prt-display);
letter-spacing: -0.6px;
text-transform: uppercase;
}
.prt-rating-line {
display: flex;
align-items: center;
gap: 8px;
}
.prt-stars {
color: var(--prt-accent);
font-size: 16px;
letter-spacing: 2px;
}
.prt-rating-count {
font: 600 14px/22.4px var(--prt-body);
letter-spacing: -0.28px;
text-transform: uppercase;
}
.prt-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
[data-prt-reveal="up"] {
transition:
opacity 0.8s var(--prt-ease) var(--d, 0s),
transform 0.8s var(--prt-ease) var(--d, 0s);
}
.prt[data-prt-ready] [data-prt-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.prt-w {
transition:
opacity 0.6s var(--prt-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--prt-ease) calc(var(--i) * 0.06s);
}
.prt[data-prt-ready] [data-prt-reveal="words"]:not([data-in]) .prt-w {
opacity: 0;
transform: translateY(10px);
}
/* Phone: left pair, portrait, right pair. */
.prt-col--left {
order: 1;
}
.prt-portrait {
order: 2;
}
.prt-col--right {
order: 3;
}
@media (min-width: 810px) {
.prt-inner {
padding: 90px 40px;
}
.prt-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.prt-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.prt-portrait {
grid-column: 1 / -1;
min-height: 520px;
}
}
@media (min-width: 1200px) {
.prt-inner {
padding: 100px 65px;
}
.prt-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.prt-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
margin-top: 50px;
}
.prt-col--left {
order: 1;
}
.prt-portrait {
order: 2;
grid-column: auto;
align-self: stretch;
min-height: 602px;
}
.prt-col--right {
order: 3;
padding-top: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-prt-reveal="up"],
.prt-w {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initPortraitRatingTestimonials(root: HTMLElement) {
if (root.dataset.prtReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.prtReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-prt-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.prtReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-prt-root]").forEach(initPortraitRatingTestimonials);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/PortraitRatingTestimonials.astro =====
===== FILE (required): public/portrait-rating-testimonials/portrait.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/portrait.webp and save it to public/portrait-rating-testimonials/portrait.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-1.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-1.webp and save it to public/portrait-rating-testimonials/avatar-1.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-2.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-2.webp and save it to public/portrait-rating-testimonials/avatar-2.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-3.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-3.webp and save it to public/portrait-rating-testimonials/avatar-3.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-4.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-4.webp and save it to public/portrait-rating-testimonials/avatar-4.webp]
===== FILE (required): src/components/NumberedFaqPhoto.astro =====
---
/**
* NumberedFaqPhoto
*
* A centred FAQ: outlined eyebrow and a big uppercase heading, then a pale
* panel of numbered question cards beside a large photo. Each question is a
* native <details> in an exclusive group, so opening one closes the others;
* its round orange "+" turns into an "×" when open. Where the browser supports
* it, the answer's height animates open and closed.
*
* No script except the appear-on-scroll observer; the accordion works without
* JavaScript.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface NumberedFaqItem {
question: string;
answer: string;
}
export interface NumberedFaqPhotoProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
items?: NumberedFaqItem[];
/** Prefix each question with "01.", "02."... */
numbered?: boolean;
/** 1-based index of the question open on load. 0 opens none. */
initialOpen?: number;
/** `src: ""` renders a labelled placeholder; `null` drops it and the panel spans the row. */
image?: { src?: string; alt?: string } | null;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends NumberedFaqPhotoProps {}
const {
eyebrow = "FAQ",
heading = "",
headingLevel = "h2",
items = [],
numbered = true,
initialOpen = 1,
image: imageProp,
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* No photo unless one is passed: omitted or `null` lets the panel span the
row. `{ src: "" }` still asks for the labelled placeholder. */
const image = imageProp ? { src: "", alt: "", ...imageProp } : null;
if (imageProp && imageProp.src !== undefined && imageProp.alt === undefined && image) image.alt = "";
/* A card needs a question and an answer; anything short of that is dropped.
No cards, no section — the eyebrow and heading alone say nothing. */
const list = items.filter(
(item) =>
typeof item?.question === "string" &&
item.question.trim() !== "" &&
typeof item.answer === "string" &&
item.answer.trim() !== "",
);
const group = `${id ?? "nfp"}-${Math.random().toString(36).slice(2, 8)}`;
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const pad = (n: number) => String(n).padStart(2, "0");
---
{list.length > 0 && (<>{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["nfp", className]} id={id} style={`--nfp-band:${background}`} data-nfp-root>
<div class="nfp-inner">
{(eyebrow || words.length > 0) && (
<div class="nfp-head">
{eyebrow && <p class="nfp-eyebrow">{eyebrow}</p>}
{words.length > 0 && (
<Heading class="nfp-heading" data-nfp-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["nfp-w", { "nfp-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
</div>
)}
<div class:list={["nfp-body", { "nfp-body--solo": !image }]}>
<div class="nfp-panel" data-nfp-reveal="up">
{
list.map((item, i) => (
<details class="nfp-item" name={group} open={initialOpen === i + 1}>
<summary class="nfp-q">
<span>
{numbered && `${pad(i + 1)}. `}
{item.question}
</span>
<span class="nfp-ic" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M12 6v12M6 12h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</span>
</summary>
<div class="nfp-a">
<p>{item.answer}</p>
</div>
</details>
))
}
</div>
{
image && (
<div class="nfp-media" data-nfp-reveal="up" style="--d:0.12s">
{image.src ? (
<img src={image.src} alt={image.alt} width="1260" height="1056" loading="lazy" decoding="async" />
) : (
<div class="nfp-ph" role="img" aria-label="Image placeholder">
<span>Image · 1260 × 1056</span>
</div>
)}
</div>
)
}
</div>
</div>
</section></>)}
<style>
.nfp {
--nfp-navy: var(--tw-navy, #0b4654);
--nfp-accent: var(--tw-accent, #ff6d45);
--nfp-surface: var(--tw-surface, #f0f8fa);
--nfp-muted: var(--tw-muted, #616161);
--nfp-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--nfp-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--nfp-ease: cubic-bezier(0.44, 0, 0.56, 1);
background: var(--nfp-band);
color: var(--nfp-navy);
font-family: var(--nfp-body);
}
.nfp *,
.nfp *::before,
.nfp *::after {
box-sizing: border-box;
}
.nfp-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.nfp-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
text-align: center;
}
.nfp-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--nfp-accent);
border-radius: 999px;
color: var(--nfp-accent);
font: 500 15px/24px var(--nfp-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.nfp-heading {
max-width: 620px;
margin: 0;
font: 700 28px/1.2 var(--nfp-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.nfp-w {
display: inline-block;
}
.nfp-accent {
color: var(--nfp-accent);
}
.nfp-body {
display: grid;
gap: 24px;
margin-top: 40px;
}
.nfp-panel {
display: grid;
align-content: start;
gap: 16px;
padding: 16px;
border-radius: 10px;
background: var(--nfp-surface);
}
.nfp-item {
border-radius: 10px;
background: #fff;
}
.nfp-q {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px;
list-style: none;
cursor: pointer;
font: 600 18px/1.5 var(--nfp-body);
letter-spacing: -0.36px;
}
.nfp-q::-webkit-details-marker {
display: none;
}
.nfp-q:focus-visible {
outline: 2px solid var(--nfp-accent);
outline-offset: 2px;
border-radius: 10px;
}
.nfp-ic {
display: grid;
flex: none;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--nfp-accent);
color: #fff;
transition: transform 0.35s var(--nfp-ease);
}
.nfp-ic svg {
width: 20px;
height: 20px;
}
.nfp-item[open] .nfp-ic {
transform: rotate(45deg);
}
.nfp-a p {
margin: 0;
padding: 0 20px 24px;
color: var(--nfp-muted);
font: 400 16px/25.6px var(--nfp-body);
letter-spacing: -0.32px;
}
/* Height animation where the browser can animate to auto. */
@supports selector(::details-content) {
.nfp-item {
interpolate-size: allow-keywords;
}
.nfp-item::details-content {
height: 0;
overflow: clip;
transition:
height 0.4s var(--nfp-ease),
content-visibility 0.4s allow-discrete;
}
.nfp-item[open]::details-content {
height: auto;
}
}
.nfp-media {
min-height: 300px;
overflow: hidden;
border-radius: 15px;
}
.nfp-media img,
.nfp-ph {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.nfp-ph {
display: grid;
place-items: center;
min-height: 300px;
background: repeating-linear-gradient(135deg, #e3eef1 0 12px, #edf5f7 12px 24px);
font: 500 13px/1.4 var(--nfp-body);
}
[data-nfp-reveal="up"] {
transition:
opacity 0.8s var(--nfp-ease) var(--d, 0s),
transform 0.8s var(--nfp-ease) var(--d, 0s);
}
.nfp[data-nfp-ready] [data-nfp-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.nfp-w {
transition:
opacity 0.6s var(--nfp-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--nfp-ease) calc(var(--i) * 0.06s);
}
.nfp[data-nfp-ready] [data-nfp-reveal="words"]:not([data-in]) .nfp-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.nfp-inner {
padding: 90px 40px;
}
.nfp-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.nfp-panel {
gap: 20px;
padding: 20px;
}
.nfp-q {
padding: 26px 25px;
font-size: 20px;
line-height: 32px;
letter-spacing: -0.4px;
}
.nfp-a p {
padding: 0 25px 26px;
}
.nfp-media {
min-height: 420px;
}
}
@media (min-width: 1200px) {
.nfp-inner {
padding: 100px 65px;
}
.nfp-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.nfp-body {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 50px;
margin-top: 60px;
}
.nfp-body--solo {
grid-template-columns: minmax(0, 1fr);
}
.nfp-media {
min-height: 528px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-nfp-reveal="up"],
.nfp-w,
.nfp-ic,
.nfp-item::details-content {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll only; the accordion is native <details>. */
function initNumberedFaqPhoto(root: HTMLElement) {
if (root.dataset.nfpReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.nfpReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-nfp-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.nfpReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-nfp-root]").forEach(initNumberedFaqPhoto);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/NumberedFaqPhoto.astro =====
===== FILE (required): public/numbered-faq-photo/white-semi.webp =====
[binary asset — download https://astro.baysixmedia.com/numbered-faq-photo/white-semi.webp and save it to public/numbered-faq-photo/white-semi.webp]
===== FILE (required): src/components/ShipCtaFooter.astro =====
---
/**
* ShipCtaFooter
*
* A tall dark footer over a photo washed navy-to-black. On top, a big
* uppercase call to action with orange words and an orange pill; under a
* hairline, the brand, a tagline and an email sign-up on the left, and
* Services, Company and Contact columns split by hairlines on the right; at
* the foot, copyright, two legal links and round social buttons.
*
* The sign-up is a real <form> posting to `newsletter.action`; the button
* dims until the email field is valid, but it is never disabled.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Nothing about the business is assumed: no name, heading, pill, link,
* contact line, sign-up form, copyright or photo is shown unless it is
* passed, a block with nothing in it is not drawn, and with no content at
* all nothing renders.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export type ShipFooterSocial = "facebook" | "x" | "instagram" | "linkedin" | "youtube";
export interface ShipFooterLink {
label: string;
href: string;
}
export interface ShipCtaFooterProps {
heading?: string;
/** Heading level for the call to action. */
headingLevel?: "h2" | "p";
cta?: ShipFooterLink | null;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark. Use a light version. */
logo?: { src: string; alt: string; width?: number; height?: number };
tagline?: string;
/** The sign-up form. Off by default; pass your handler as `action`. */
newsletter?: { action: string; label?: string; placeholder?: string; button?: string } | null;
columns?: { heading: string; links: ShipFooterLink[] }[];
contactHeading?: string;
contact?: ShipFooterLink[];
copyright?: string;
legalLinks?: ShipFooterLink[];
social?: { network: ShipFooterSocial; href: string; label?: string }[];
/** Background photo. None by default: the navy gradient alone. */
background?: { src?: string };
navLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ShipCtaFooterProps {}
const {
heading = "",
headingLevel = "h2",
cta = null,
brandName = "",
brandHref = "/",
logo,
tagline = "",
newsletter = null,
columns = [],
contactHeading = "Contact",
contact = [],
copyright,
legalLinks = [],
social = [],
background: bgProp,
navLabel = "Footer",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const background = { src: "", ...(bgProp ?? {}) };
const year = new Date().getFullYear();
/* No name, no copyright line: never "© 2026 . All rights reserved." */
const copy = copyright ?? (brandName ? `© ${year} ${brandName}. All rights reserved.` : "");
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const hasCta = words.length > 0 || Boolean(cta);
const hasMark = Boolean(logo || brandName);
const hasCols = columns.length > 0 || contact.length > 0;
const hasMain = hasMark || Boolean(tagline) || Boolean(newsletter) || hasCols;
const hasBottom = Boolean(copy) || legalLinks.length > 0 || social.length > 0;
const hasContent = hasCta || hasMain || hasBottom;
const Heading = headingLevel;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const external = (href: string) => /^https?:/i.test(href);
const inputId = `${id ?? "scf"}-email-${Math.random().toString(36).slice(2, 7)}`;
const SOCIAL: Record<ShipFooterSocial, { label: string; path: string }> = {
facebook: { label: "Facebook", path: "M14 8h3V4h-3a4 4 0 0 0-4 4v2H8v4h2v6h4v-6h3l1-4h-4V8Z" },
x: { label: "X", path: "M5 4h4l10 16h-4Z M19 4l-6 7 M5 20l6-7" },
instagram: { label: "Instagram", path: "M8 4h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z M17 7h.01" },
linkedin: { label: "LinkedIn", path: "M5 9v10 M5 5v.01 M10 19v-6a3 3 0 0 1 6 0v6 M10 9v10" },
youtube: { label: "YouTube", path: "M3 12s0-4 1-5 8-1 8-1 7 0 8 1 1 5 1 5 0 4-1 5-8 1-8 1-7 0-8-1-1-5-1-5Z M10 9l5 3-5 3Z" },
};
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<footer class:list={["scf", className]} id={id} data-scf-root>
{background.src && <img class="scf-bg" src={background.src} alt="" width="1600" height="960" loading="lazy" decoding="async" />}
<div class="scf-wash" aria-hidden="true"></div>
<div class="scf-inner">
{hasCta && (
<div class="scf-cta">
{words.length > 0 && (
<Heading class="scf-heading" data-scf-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["scf-w", { "scf-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
{
cta && (
<a class="scf-btn" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="scf-btn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
)}
{hasMain && (
<div class="scf-main" data-scf-reveal="up">
<div class="scf-brand">
{hasMark && (
<a class="scf-logo" href={safeHref(brandHref)} aria-label={logo ? undefined : `${brandName} home`}>
{
logo ? (
<img src={logo.src} alt={logo.alt} width={logo.width ?? 124} height={logo.height ?? 32} />
) : (
<>
<svg viewBox="0 0 40 40" aria-hidden="true">
<path d="M6 21a14 14 0 0 1 28 0h-6a8 8 0 0 0-16 0Z" fill="currentColor" />
<path d="M20 17 11 30h6v7h6v-7h6Z" fill="currentColor" />
</svg>
<span>{brandName}</span>
</>
)
}
</a>
)}
{tagline && <p class="scf-tagline">{tagline}</p>}
{
newsletter && (
<form class="scf-form" action={safeHref(newsletter.action)} method="post">
<label class="scf-sr" for={inputId}>
{newsletter.label ?? "Email address"}
</label>
<input id={inputId} type="email" name="email" required autocomplete="email" placeholder={newsletter.placeholder ?? "you@company.com"} />
<button type="submit">{newsletter.button ?? "Submit"}</button>
</form>
)
}
</div>
{hasCols && (
<nav class="scf-cols" aria-label={navLabel}>
{
columns.map((col) => (
<div class="scf-col">
<h2 class="scf-col-title">{col.heading}</h2>
<ul>
{col.links.map((l) => (
<li>
<a href={safeHref(l.href)}>{l.label}</a>
</li>
))}
</ul>
</div>
))
}
{
contact.length > 0 && (
<div class="scf-col scf-col--contact">
<h2 class="scf-col-title">{contactHeading}</h2>
<ul>
{contact.map((l) => (
<li>
<a href={safeHref(l.href)} {...(external(l.href) ? { rel: "noopener" } : {})}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 2l12 6-12 6 3-6Z" fill="currentColor" />
</svg>
{l.label}
</a>
</li>
))}
</ul>
</div>
)
}
</nav>
)}
</div>
)}
{hasBottom && (
<div class="scf-bottom">
{copy && <p class="scf-copy">{copy}</p>}
{
legalLinks.length > 0 && (
<ul class="scf-legal">
{legalLinks.map((l) => (
<li>
<a href={safeHref(l.href)}>{l.label}</a>
</li>
))}
</ul>
)
}
{
social.length > 0 && (
<ul class="scf-social">
{social.map((s) => (
<li>
<a href={safeHref(s.href)} rel="noopener" aria-label={s.label ?? SOCIAL[s.network].label}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d={SOCIAL[s.network].path} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</li>
))}
</ul>
)
}
</div>
)}
</div>
</footer>
)}
<style>
.scf {
--scf-navy: var(--tw-navy, #0b4654);
--scf-accent: var(--tw-accent, #ff6d45);
--scf-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--scf-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--scf-ease: cubic-bezier(0.44, 0, 0.56, 1);
--scf-line: rgb(255 255 255 / 0.1);
position: relative;
isolation: isolate;
overflow: hidden;
background: #07252c;
color: #fff;
font-family: var(--scf-body);
}
.scf *,
.scf *::before,
.scf *::after {
box-sizing: border-box;
}
.scf-bg,
.scf-wash {
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
}
.scf-bg {
object-fit: cover;
}
.scf-wash {
background: linear-gradient(180deg, rgb(11 70 84 / 0.88) 0%, rgb(8 45 54 / 0.9) 55%, rgb(2 10 12 / 0.97) 100%);
}
.scf-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px 32px;
}
.scf-cta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 28px;
padding-bottom: 48px;
border-bottom: 1px solid var(--scf-line);
}
.scf-heading {
max-width: 680px;
margin: 0;
font: 900 30px/1.2 var(--scf-display);
letter-spacing: -0.6px;
text-transform: uppercase;
}
.scf-w {
display: inline-block;
}
.scf-accent {
color: var(--scf-accent);
}
.scf-btn {
display: inline-flex;
flex: none;
align-items: center;
gap: 12px;
height: 50px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--scf-accent);
color: #fff;
font: 400 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: background-color 0.3s var(--scf-ease);
}
.scf-btn:hover {
background: #052931;
}
.scf-btn-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--scf-accent);
transition: transform 0.3s var(--scf-ease);
}
.scf-btn-ic svg {
width: 18px;
height: 18px;
}
.scf-btn:hover .scf-btn-ic {
transform: rotate(-45deg);
}
.scf-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 48px;
padding: 56px 0;
}
.scf-logo {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
font: 500 26px/1 var(--scf-display);
letter-spacing: -0.5px;
text-decoration: none;
}
.scf-logo svg {
width: 30px;
height: 30px;
color: var(--scf-accent);
}
.scf-tagline {
max-width: 300px;
margin: 36px 0 0;
font: 400 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
}
.scf-form {
display: flex;
gap: 10px;
max-width: 400px;
margin-top: 40px;
}
.scf-form input {
flex: 1 1 auto;
min-width: 0;
height: 54px;
padding: 0 14px;
border: 0;
border-radius: 10px;
background: #fff;
color: var(--scf-navy);
font: 400 15px/1 var(--scf-body);
}
.scf-form button {
flex: none;
width: 115px;
height: 54px;
border: 0;
border-radius: 10px;
background: var(--scf-accent);
color: #fff;
font: 500 16px/1 var(--scf-body);
cursor: pointer;
transition:
opacity 0.3s var(--scf-ease),
background-color 0.3s var(--scf-ease);
}
.scf-form:has(input:invalid) button {
opacity: 0.5;
}
.scf-form button:hover {
background: #052931;
opacity: 1;
}
.scf-cols {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 36px 24px;
}
.scf-col--contact {
grid-column: 1 / -1;
}
.scf-col-title {
margin: 0 0 14px;
color: rgb(255 255 255 / 0.7);
font: 600 20px/32px var(--scf-body);
letter-spacing: -0.4px;
text-transform: uppercase;
}
.scf-col ul,
.scf-legal,
.scf-social {
margin: 0;
padding: 0;
list-style: none;
}
.scf-col li + li {
margin-top: 15px;
}
.scf-col a,
.scf-legal a {
display: inline-flex;
align-items: center;
gap: 10px;
color: #fff;
font: 500 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: color 0.3s var(--scf-ease);
}
.scf-col a svg {
flex: none;
width: 16px;
height: 16px;
color: var(--scf-accent);
}
.scf-col a:hover,
.scf-legal a:hover {
color: var(--scf-accent);
}
.scf-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px 24px;
padding-top: 28px;
border-top: 1px solid var(--scf-line);
}
.scf-copy {
margin: 0;
color: rgb(255 255 255 / 0.7);
font: 400 15px/24px var(--scf-body);
}
.scf-legal {
display: flex;
flex-wrap: wrap;
gap: 12px 40px;
}
.scf-legal a {
color: rgb(255 255 255 / 0.7);
}
.scf-social {
display: flex;
gap: 10px;
}
.scf-social a {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--scf-navy);
color: #fff;
transition: background-color 0.3s var(--scf-ease);
}
.scf-social a:hover {
background: var(--scf-accent);
}
.scf-social svg {
width: 18px;
height: 18px;
}
.scf a:focus-visible,
.scf button:focus-visible,
.scf input:focus-visible {
outline: 2px solid var(--scf-accent);
outline-offset: 3px;
}
.scf-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
[data-scf-reveal="up"] {
transition:
opacity 0.8s var(--scf-ease),
transform 0.8s var(--scf-ease);
}
.scf[data-scf-ready] [data-scf-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.scf-w {
transition:
opacity 0.6s var(--scf-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--scf-ease) calc(var(--i) * 0.06s);
}
.scf[data-scf-ready] [data-scf-reveal="words"]:not([data-in]) .scf-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.scf-inner {
padding: 90px 40px 36px;
}
.scf-cta {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-bottom: 56px;
}
.scf-heading {
font-size: 40px;
letter-spacing: -0.8px;
}
.scf-cols {
grid-template-columns: repeat(3, auto);
justify-content: start;
gap: 0;
}
.scf-col {
padding: 0 40px;
border-left: 1px solid var(--scf-line);
}
.scf-col:first-child {
padding-left: 0;
border-left: 0;
}
.scf-col--contact {
grid-column: auto;
}
}
@media (min-width: 1200px) {
.scf-inner {
padding: 100px 65px 36px;
}
.scf-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.scf-main {
grid-template-columns: 485px minmax(0, 1fr);
gap: 0;
padding: 100px 0 90px;
}
.scf-cols {
justify-content: space-between;
padding-top: 20px;
}
.scf-col {
padding: 0 50px;
}
.scf-col:first-child {
padding-left: 50px;
border-left: 1px solid var(--scf-line);
}
.scf-col--contact {
padding-right: 0;
}
.scf-social a {
width: 35px;
height: 35px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-scf-reveal="up"],
.scf-w,
.scf-btn,
.scf-btn-ic,
.scf-col a,
.scf-legal a,
.scf-social a,
.scf-form button {
transition: none;
}
.scf-btn:hover .scf-btn-ic {
transform: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initShipCtaFooter(root: HTMLElement) {
if (root.dataset.scfReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.scfReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-scf-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.scfReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-scf-root]").forEach(initShipCtaFooter);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ShipCtaFooter.astro =====
===== FILE (required): public/ship-cta-footer/ship-at-sea.webp =====
[binary asset — download https://astro.baysixmedia.com/ship-cta-footer/ship-at-sea.webp and save it to public/ship-cta-footer/ship-at-sea.webp]
--- SECTIONS ---
- Scroll pill header (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/scroll-pill-header/
- Tag title banner (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/tag-title-banner/
- Badge image intro (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/badge-image-intro/
- Alternating service rows (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/alternating-service-rows/
- Chamfer step cards (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/chamfer-step-cards/
- Freight pricing plans (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/freight-pricing-plans/
- Portrait rating testimonials (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/portrait-rating-testimonials/
- Numbered FAQ photo (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/numbered-faq-photo/
- Ship CTA footer (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/ship-cta-footer/
Each component's README — its full prop reference — is on its library page above, and is not repeated here. The library verified each component on its own. Nobody has verified this page in your project: test the assembled page yourself.