The 9 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: "Transport site — Contact"
Library page: https://astro.baysixmedia.com/templates/transport-contact/
Page file: src/pages/transport-contact.astro
Files: 9 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/transport-contact.astro =====
---
/**
* Transport site — Contact — a page template from the Astro Component Library.
* https://astro.baysixmedia.com/templates/transport-contact/
*
* 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 ContactBarOverlayHeader from "../components/ContactBarOverlayHeader.astro";
import BreadcrumbPageBanner from "../components/BreadcrumbPageBanner.astro";
import ContactInfoCards from "../components/ContactInfoCards.astro";
import MapContactForm from "../components/MapContactForm.astro";
import CargoLogoMarquee from "../components/CargoLogoMarquee.astro";
import NewsletterPanelFooter from "../components/NewsletterPanelFooter.astro";
const contactBarOverlayHeaderProps = {
"items": [
{
"label": "Home",
"children": [
{
"label": "Freight home",
"href": "/"
},
{
"label": "Trucking home",
"href": "/trucking"
},
{
"label": "Ocean home",
"href": "/ocean"
}
]
},
{
"label": "About Us",
"href": "/about"
},
{
"label": "Services",
"children": [
{
"label": "Ocean freight",
"href": "/services/ocean-freight"
},
{
"label": "Road haulage",
"href": "/services/road-haulage"
},
{
"label": "Rail freight",
"href": "/services/rail-freight"
},
{
"label": "Warehousing",
"href": "/services/warehousing"
}
]
},
{
"label": "Pages",
"children": [
{
"label": "Our team",
"href": "/team"
},
{
"label": "Pricing",
"href": "/pricing"
},
{
"label": "Request a quote",
"href": "/request-quote"
},
{
"label": "Shipping FAQ",
"href": "/faq"
}
]
},
{
"label": "News",
"children": [
{
"label": "Latest news",
"href": "/news"
},
{
"label": "Lane reports",
"href": "/news/lane-reports"
}
]
},
{
"label": "Contact",
"href": "/contact"
}
],
"brandName": "Northpass",
"cta": {
"label": "Free Quote!",
"href": "/request-quote"
},
"contacts": [
{
"icon": "phone",
"label": "Phone: +1 (503) 555-0148",
"href": "tel:+15035550148"
},
{
"icon": "email",
"label": "Email: dispatch@northpass.example",
"href": "mailto:dispatch@northpass.example"
},
{
"icon": "address",
"label": "1180 Harbor Way, Portland OR",
"href": "https://maps.google.com/?q=1180+Harbor+Way+Portland+OR"
}
],
"social": [
{
"network": "facebook",
"href": "https://facebook.com",
"label": "Northpass on Facebook"
},
{
"network": "x",
"href": "https://x.com",
"label": "Northpass on X"
},
{
"network": "instagram",
"href": "https://instagram.com",
"label": "Northpass on Instagram"
},
{
"network": "linkedin",
"href": "https://linkedin.com",
"label": "Northpass on LinkedIn"
}
],
"currentPath": "/contact"
};
const breadcrumbPageBannerProps = {
"image": {
"src": "/breadcrumb-page-banner/ship-at-sea.webp",
"alt": ""
},
"title": "Contact Us"
};
const contactInfoCardsProps = {
"items": [
{
"icon": "pin",
"title": "Our Address",
"lines": [
{
"text": "2250 Harbor Way, Oakland, CA 94607",
"href": "https://www.google.com/maps/search/?api=1&query=Oakland%2C%20CA"
}
]
},
{
"icon": "book",
"title": "Contact Number",
"lines": [
{
"text": "Phone: +1 (510) 555-0148",
"href": "tel:+15105550148"
},
{
"text": "Email: ops@northpass.example",
"href": "mailto:ops@northpass.example"
}
]
},
{
"icon": "clock",
"title": "Opening Hours",
"lines": [
{
"text": "Monday – Saturday: 7:00 – 19:00"
},
{
"text": "Sunday: Closed"
}
]
}
]
};
const mapContactFormProps = {
"heading": "Talk To A Dispatcher Today",
"mapEmbedUrl": "https://www.google.com/maps?q=Oakland%2C%20CA&output=embed",
"mapTitle": "Map of Oakland, California"
};
const cargoLogoMarqueeProps = {
"logos": [
{
"name": "Harbor",
"accent": "Line",
"tagline": "Ocean Carriers",
"glyph": "ship"
},
{
"name": "Tide",
"accent": "well",
"tagline": "Port Services",
"glyph": "crane"
},
{
"name": "Sky",
"accent": "run",
"tagline": "Air Freight",
"glyph": "plane"
},
{
"name": "Iron",
"accent": "vale",
"tagline": "Rail Logistics",
"glyph": "rail"
},
{
"name": "Long",
"accent": "haul",
"tagline": "Motor Freight",
"glyph": "truck"
},
{
"name": "Blue",
"accent": "keel",
"tagline": "Global Shipping",
"glyph": "globe"
}
],
"image": {
"src": "/cargo-logo-marquee/port-aerial.webp",
"alt": ""
},
"heading": "",
"tone": "light"
};
const newsletterPanelFooterProps = {
"heading": "Subscribe Our Newsletter",
"newsletter": {},
"brandName": "Northpass",
"blurb": "Freight moved on time, by people who answer the phone. Ocean, road, rail and storage, run from one dispatch desk since 2009.",
"social": [
{
"network": "facebook",
"href": "https://facebook.com",
"label": "Northpass on Facebook"
},
{
"network": "x",
"href": "https://x.com",
"label": "Northpass on X"
},
{
"network": "instagram",
"href": "https://instagram.com",
"label": "Northpass on Instagram"
},
{
"network": "linkedin",
"href": "https://linkedin.com",
"label": "Northpass on LinkedIn"
}
],
"columns": [
{
"heading": "Quick Links",
"links": [
{
"label": "Freight",
"href": "/freight"
},
{
"label": "Shipping",
"href": "/shipping"
},
{
"label": "About Northpass",
"href": "/about"
},
{
"label": "Our Team",
"href": "/team"
},
{
"label": "Lane Reports",
"href": "/news"
},
{
"label": "Pricing",
"href": "/pricing"
},
{
"label": "Contact Us",
"href": "/contact"
},
{
"label": "Client Stories",
"href": "/stories"
},
{
"label": "Case Studies",
"href": "/case-studies"
}
]
},
{
"heading": "Our Services",
"links": [
{
"label": "Ocean Freight",
"href": "/services/ocean-freight"
},
{
"label": "Road Haulage",
"href": "/services/road-haulage"
},
{
"label": "Rail Freight",
"href": "/services/rail-freight"
},
{
"label": "Cross-Border Trucking",
"href": "/services/cross-border"
},
{
"label": "Less-Than-Truckload",
"href": "/services/ltl"
},
{
"label": "Warehousing",
"href": "/services/warehousing"
},
{
"label": "Cold-Chain Transport",
"href": "/services/cold-chain"
},
{
"label": "Project Cargo",
"href": "/services/project-cargo"
},
{
"label": "Customs Brokerage",
"href": "/services/customs"
}
]
}
],
"contacts": [
{
"icon": "email",
"label": "dispatch@northpass.example",
"href": "mailto:dispatch@northpass.example"
},
{
"icon": "phone",
"label": "+1 (503) 555-0148",
"href": "tel:+15035550148"
}
],
"copyright": "Copyright © {year} Northpass Logistics. All Rights Reserved."
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Contact [Business name]</title>
<meta name="description" content="[One sentence with how to reach the dispatch desk and its hours.]" />
<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>
<!-- Contact bar overlay header -->
<ContactBarOverlayHeader {...contactBarOverlayHeaderProps} />
<main id="main">
<!-- Breadcrumb page banner -->
<BreadcrumbPageBanner {...breadcrumbPageBannerProps} />
<!-- Contact info cards -->
<ContactInfoCards {...contactInfoCardsProps} />
<!-- Map contact form -->
<MapContactForm {...mapContactFormProps} />
<!-- Cargo logo marquee -->
<CargoLogoMarquee {...cargoLogoMarqueeProps} />
</main>
<!-- Newsletter panel footer -->
<NewsletterPanelFooter {...newsletterPanelFooterProps} />
</body>
</html>
===== END src/pages/transport-contact.astro =====
===== FILE (required): src/components/ContactBarOverlayHeader.astro =====
---
/**
* ContactBarOverlayHeader
*
* A two-tier header laid over a page banner: a 76px contact bar (phone,
* email and address links with orange icons, "Follow Us:" and four social
* circles), and under it a rounded, dark translucent nav bar inset from the
* edges — logo, the same links and dropdowns as SolidBarHeader in white, and
* an orange "Free Quote!" pill. Below 1200px the links fold into a menu
* button and a navy card; below 810px the contact bar is hidden.
*
* `overlay` (default true) positions the root absolutely at the top of its
* containing block, so the banner runs underneath it and it scrolls away
* with the page. `overlay={false}` puts it in the flow on a navy band.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface ContactBarOverlayHeaderLink {
label: string;
href: string;
}
export interface ContactBarOverlayHeaderItem {
label: string;
/** Ignored when `children` is present: the item becomes a dropdown trigger. */
href?: string;
/** Renders the item as a real <button> that opens a single-column panel. */
children?: ContactBarOverlayHeaderLink[];
}
export interface ContactBarOverlayHeaderContact {
icon: "phone" | "email" | "address";
label: string;
href: string;
}
export type ContactBarOverlayHeaderNetwork = "facebook" | "x" | "instagram" | "linkedin" | "youtube" | "link";
export interface ContactBarOverlayHeaderSocial {
network: ContactBarOverlayHeaderNetwork;
href: string;
/** Accessible name, e.g. "Your Business on LinkedIn". */
label: string;
}
export interface ContactBarOverlayHeaderProps {
items?: ContactBarOverlayHeaderItem[];
/** Pass Astro.url.pathname. Matching links get aria-current="page". */
currentPath?: string;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark with an image (use a light logo). */
logo?: { src: string; alt: string; width?: number; height?: number };
cta?: { label: string; href: string } | null;
/** The contact bar's links. `[]` with `social: []` drops the bar. */
contacts?: ContactBarOverlayHeaderContact[];
socialLabel?: string;
social?: ContactBarOverlayHeaderSocial[];
/** Absolute over the banner (true) or in the flow on a navy band (false). */
overlay?: boolean;
navLabel?: string;
contactLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends ContactBarOverlayHeaderProps {}
/* No invented business: links, brand, CTA, contact details and social
profiles are all the caller's. Each block renders only when supplied;
with none of them, nothing renders. */
const {
items = [],
currentPath,
brandName,
brandHref = "/",
logo,
cta = null,
contacts = [],
socialLabel = "Follow Us :",
social = [],
overlay = true,
navLabel = "Primary",
contactLabel = "Contact details",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
id,
} = Astro.props;
const seed = id ? id : `cbo-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;
const hasTopBar = contacts.length > 0 || social.length > 0;
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
/* The phone menu carries the links, the CTA and the contact details. */
const hasMenu = hasItems || Boolean(cta) || contacts.length > 0;
const hasBar = hasBrand || hasMenu;
const hasContent = hasTopBar || hasBar;
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) =>
Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: ContactBarOverlayHeaderItem) =>
Boolean(item.children?.some((c) => isCurrent(c.href)));
const isExternal = (href: string) => /^https?:\/\//.test(href);
/* Contact-bar icons, drawn on a 16px grid. */
const CONTACT_ICONS: Record<ContactBarOverlayHeaderContact["icon"], string> = {
phone:
'<path d="M4.2 1.5 6.3 4a1 1 0 0 1-.1 1.3L5 6.5a8.4 8.4 0 0 0 4.5 4.5l1.2-1.2a1 1 0 0 1 1.3-.1l2.5 2.1a1 1 0 0 1 .1 1.4l-1 1.1c-.7.7-1.7.9-2.6.6A14 14 0 0 1 1.1 4.9c-.3-.9-.1-1.9.6-2.6l1-1a1 1 0 0 1 1.5.2z" fill="currentColor"/>',
email:
'<path d="M1 4.2A1.7 1.7 0 0 1 2.7 2.5h10.6A1.7 1.7 0 0 1 15 4.2v.3L8 9 1 4.5z" fill="currentColor"/><path d="M1 6.3l6.5 4.2a1 1 0 0 0 1 0L15 6.3v5.5a1.7 1.7 0 0 1-1.7 1.7H2.7A1.7 1.7 0 0 1 1 11.8z" fill="currentColor"/>',
address:
'<path d="M8 .8a5.6 5.6 0 0 0-5.6 5.6c0 4.1 5 8.5 5.2 8.7a.6.6 0 0 0 .8 0c.2-.2 5.2-4.6 5.2-8.7A5.6 5.6 0 0 0 8 .8zm0 7.9a2.3 2.3 0 1 1 0-4.6 2.3 2.3 0 0 1 0 4.6z" fill="currentColor"/>',
};
/* Generic social glyphs on a 24px grid. */
const GLYPHS: Record<ContactBarOverlayHeaderNetwork, string> = {
facebook:
'<path d="M13.6 21v-7.6h2.6l.4-3.1h-3V8.4c0-.9.3-1.5 1.5-1.5h1.6V4.1c-.3 0-1.2-.1-2.3-.1-2.3 0-3.9 1.4-3.9 4v2.3H7.9v3.1h2.6V21z" fill="currentColor"/>',
x: '<path d="M4 4h4.6l11.4 16h-4.6z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M19.4 4l-6.2 7M10.8 13l-6.2 7" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
instagram:
'<rect x="4" y="4" width="16" height="16" rx="4.6" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="3.7" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="16.6" cy="7.4" r="1.1" fill="currentColor"/>',
linkedin:
'<rect x="4.2" y="9.2" width="3.3" height="10.6" fill="currentColor"/><circle cx="5.85" cy="5.9" r="1.95" fill="currentColor"/><path d="M10 9.2h3.2v1.5c.5-.9 1.7-1.8 3.5-1.8 3.4 0 4 2.2 4 5.1v5.8h-3.3v-5.1c0-1.3 0-2.9-1.8-2.9s-2.1 1.4-2.1 2.8v5.2H10z" fill="currentColor"/>',
youtube:
'<rect x="3" y="6" width="18" height="12.5" rx="3.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.4 9.4v5.7l4.9-2.85z" fill="currentColor"/>',
link: '<path d="M10 14l4-4M8.5 11.5 6.6 13.4a3.2 3.2 0 0 0 4.5 4.5l1.9-1.9M15.5 12.5l1.9-1.9a3.2 3.2 0 0 0-4.5-4.5L11 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>',
};
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
class:list={["cbo", overlay ? "cbo--overlay" : "cbo--flow", !hasTopBar && "cbo--no-top"]}
id={id}
data-header-root
data-cbo-root
>
{
hasTopBar && (
<div class="cbo-top">
{contacts.length > 0 && (
<ul class="cbo-contacts" role="list" aria-label={contactLabel}>
{contacts.map((c) => (
<li class={`cbo-contact cbo-contact--${c.icon}`}>
<a
class="cbo-contact-link"
href={c.href}
target={isExternal(c.href) ? "_blank" : undefined}
rel={isExternal(c.href) ? "noopener noreferrer" : undefined}
>
<svg class="cbo-contact-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false" set:html={CONTACT_ICONS[c.icon]} />
<span>{c.label}</span>
</a>
</li>
))}
</ul>
)}
{social.length > 0 && (
<div class="cbo-follow">
{socialLabel && <span class="cbo-follow-label" aria-hidden="true">{socialLabel}</span>}
<ul class="cbo-social" role="list">
{social.map((s) => (
<li>
<a
class="cbo-social-link"
href={s.href}
aria-label={s.label}
target={isExternal(s.href) ? "_blank" : undefined}
rel={isExternal(s.href) ? "noopener noreferrer" : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" set:html={GLYPHS[s.network] ?? GLYPHS.link} />
</a>
</li>
))}
</ul>
</div>
)}
</div>
)
}
{hasBar && (
<div class="cbo-barwrap">
<div class="cbo-bar">
{hasBrand && (
<a class="cbo-brand" href={brandHref} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<img
class="cbo-logo-img"
src={logo.src}
alt={logo.alt}
width={logo.width ?? 157}
height={logo.height ?? 30}
/>
) : (
<>
<svg class="cbo-mark" viewBox="0 0 46 30" aria-hidden="true" focusable="false">
<path d="M2 9h11M5 14h9M1 19h13" fill="none" stroke="var(--cbo-orange)" stroke-width="2.6" stroke-linecap="round" />
<rect x="14" y="4" width="17" height="15" rx="2" fill="currentColor" />
<path d="M32 8h6.2l5.3 5.6V19H32z" fill="currentColor" />
<path d="M34.4 10.4h3.2l3 3.2h-6.2z" fill="var(--cbo-orange)" />
<circle cx="20" cy="21.5" r="3.4" fill="currentColor" stroke="var(--cbo-navy)" stroke-width="1.6" />
<circle cx="37.5" cy="21.5" r="3.4" fill="currentColor" stroke="var(--cbo-navy)" stroke-width="1.6" />
<path d="M4 27.5h40" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span class="cbo-wordmark" aria-hidden="true">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="cbo-nav" aria-label={navLabel}>
<ul class="cbo-list" role="list">
{
items.map((item, i) => {
if (item.children && item.children.length > 0) {
const panelId = `${seed}-panel-${i}`;
return (
<li class="cbo-item cbo-item--dd" data-cbo-dd>
<button
type="button"
class="cbo-link cbo-trigger"
aria-expanded="false"
aria-controls={panelId}
data-cbo-trigger
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="cbo-plus" viewBox="0 0 11 11" aria-hidden="true" focusable="false">
<path d="M5.5 1v9M1 5.5h9" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<div class="cbo-panel" id={panelId} data-cbo-panel>
<ul role="list">
{item.children.map((child) => (
<li>
<a
class="cbo-panel-link"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</div>
</li>
);
}
return (
<li class="cbo-item">
<a
class="cbo-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
);
})
}
</ul>
</nav>
)}
{
cta && (
<a class="cbo-cta" href={cta.href}>
<span>{cta.label}</span>
<svg class="cbo-arrow" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
<path d="M2 10L10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
)
}
{hasMenu && (
<button
type="button"
class="cbo-burger"
aria-expanded="false"
aria-controls={menuId}
data-cbo-burger
>
<span class="cbo-sr">{menuLabel}</span>
<span class="cbo-burger-lines" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
)}
</div>
{hasMenu && (
<nav class="cbo-menu" id={menuId} aria-label={navLabel} data-cbo-menu>
{hasItems && (
<ul class="cbo-menu-list" role="list">
{
items.map((item, i) =>
item.children && item.children.length > 0 ? (
<li class="cbo-menu-group" data-cbo-sub>
<button
type="button"
class="cbo-menu-link cbo-menu-toggle"
aria-expanded="true"
aria-controls={`${seed}-sub-${i}`}
data-cbo-subtoggle
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="cbo-plus" viewBox="0 0 11 11" aria-hidden="true" focusable="false">
<path d="M5.5 1v9M1 5.5h9" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<ul class="cbo-sublist" id={`${seed}-sub-${i}`} role="list" data-cbo-sublist>
{item.children.map((child) => (
<li>
<a
class="cbo-menu-link cbo-menu-sublink"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</li>
) : (
<li>
<a
class="cbo-menu-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
),
)
}
</ul>
)}
{
cta && (
<a class="cbo-cta cbo-cta--menu" href={cta.href}>
<span>{cta.label}</span>
<svg class="cbo-arrow" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
<path d="M2 10L10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
)
}
{
contacts.length > 0 && (
<ul class="cbo-menu-contacts" role="list" aria-label={contactLabel}>
{contacts.map((c) => (
<li>
<a class="cbo-contact-link" href={c.href}>
<svg class="cbo-contact-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false" set:html={CONTACT_ICONS[c.icon]} />
<span>{c.label}</span>
</a>
</li>
))}
</ul>
)
}
</nav>
)}
</div>
)}
</header>
)}
<style>
.cbo {
--cbo-navy: var(--tg-navy, #020e28);
--cbo-orange: var(--tg-orange, #fd5523);
--cbo-muted: var(--tg-muted, #61697d);
--cbo-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--cbo-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
--cbo-dd-hover: #ff6060;
--cbo-dur: 0.3s;
--cbo-ease: cubic-bezier(0.44, 0, 0.56, 1);
z-index: 30;
box-sizing: border-box;
width: 100%;
font-family: var(--cbo-body);
color: #fff;
}
.cbo *,
.cbo *::before,
.cbo *::after {
box-sizing: border-box;
}
.cbo ul {
margin: 0;
padding: 0;
list-style: none;
}
.cbo--overlay {
position: absolute;
inset: 0 0 auto 0;
background: transparent;
}
.cbo--flow {
position: relative;
background: var(--cbo-navy);
padding-bottom: 24px;
}
/* ---- contact bar ---- */
.cbo-top {
display: none;
}
.cbo .cbo-contacts {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.cbo-contact-link {
display: inline-flex;
align-items: center;
gap: 15px;
min-height: 44px;
color: #fff;
font: 400 16px/21px var(--cbo-body);
text-decoration: none;
white-space: nowrap;
border-radius: 4px;
transition: color var(--cbo-dur) var(--cbo-ease);
}
.cbo-contact-link:hover {
color: var(--cbo-orange);
}
.cbo-contact-icon {
width: 16px;
height: 16px;
flex: none;
color: var(--cbo-orange);
}
.cbo-follow {
display: flex;
align-items: center;
gap: 16px;
flex: none;
}
.cbo-follow-label {
font: 700 16px/19px var(--cbo-display);
white-space: nowrap;
}
.cbo .cbo-social {
display: flex;
align-items: center;
gap: 16px;
}
.cbo-social-link {
position: relative;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 100px;
background: rgb(255 255 255 / 0.15);
color: #fff;
transition: background-color var(--cbo-dur) var(--cbo-ease);
}
/* 36px circle as measured, 44px target. */
.cbo-social-link::after {
content: "";
position: absolute;
inset: -4px;
border-radius: inherit;
}
.cbo-social-link svg {
width: 15px;
height: 15px;
}
.cbo-social-link:hover {
background: var(--cbo-orange);
}
/* ---- the glass nav bar ---- */
.cbo-barwrap {
position: relative;
padding: 24px 20px 0;
}
.cbo-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 76px;
padding: 0 20px 0 24px;
border-radius: 1000px;
/* rgba(2,14,40,.8) at opacity .7, as measured, flattened into one fill
so the bar's contents stay fully opaque. */
background: rgb(2 14 40 / 0.56);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.cbo-brand {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
color: #fff;
text-decoration: none;
border-radius: 6px;
flex: none;
}
.cbo-mark {
width: 46px;
height: 30px;
flex: none;
}
.cbo-wordmark {
font: 600 24px/1 var(--cbo-display);
letter-spacing: -0.01em;
}
.cbo-logo-img {
display: block;
height: 30px;
width: auto;
}
.cbo-nav {
display: none;
}
.cbo .cbo-list {
display: flex;
align-items: center;
gap: clamp(28px, calc(5vw - 30px), 42px);
padding: 9px 20px;
}
.cbo-item {
position: relative;
}
.cbo-link {
display: inline-flex;
align-items: center;
gap: 1px;
padding: 9px 6px;
margin: -9px -6px;
border: 0;
border-radius: 6px;
background: none;
color: #fff;
font: 500 16px/26px var(--cbo-display);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: color var(--cbo-dur) var(--cbo-ease);
}
.cbo-link:hover,
.cbo-link[aria-current="page"] {
color: var(--cbo-orange);
}
.cbo-trigger:hover,
.cbo-trigger[aria-expanded="true"],
.cbo-trigger[data-current] {
color: var(--cbo-dd-hover);
}
.cbo-plus {
width: 11px;
height: 11px;
flex: none;
transition: transform var(--cbo-dur) var(--cbo-ease);
}
.cbo-trigger:hover .cbo-plus,
.cbo-trigger[aria-expanded="true"] .cbo-plus {
transform: rotate(180deg);
}
.cbo-panel {
position: absolute;
top: calc(100% + 34px);
left: -22px;
min-width: 220px;
padding: 14px 22px;
border-radius: 10px;
background: #fff;
box-shadow: 0 18px 44px rgb(2 14 40 / 0.22);
border-top: 3px solid var(--cbo-orange);
opacity: 0;
transform: translateY(10px);
visibility: hidden;
pointer-events: none;
transition:
opacity var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease),
visibility 0s linear var(--cbo-dur);
z-index: 2;
}
.cbo-panel::before {
content: "";
position: absolute;
inset: -37px 0 100% 0;
}
.cbo:not([data-cbo-ready]) .cbo-item--dd:hover .cbo-panel,
.cbo:not([data-cbo-ready]) .cbo-item--dd:focus-within .cbo-panel,
.cbo[data-cbo-ready] .cbo-item--dd[data-open] .cbo-panel {
opacity: 1;
transform: none;
visibility: visible;
pointer-events: auto;
transition:
opacity var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease),
visibility 0s;
}
.cbo-panel-link {
display: flex;
align-items: center;
min-height: 40px;
color: var(--cbo-navy);
font: 500 16px/1.3 var(--cbo-display);
text-decoration: none;
white-space: nowrap;
border-radius: 4px;
transition:
color var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease);
}
.cbo-panel-link:hover,
.cbo-panel-link[aria-current="page"] {
color: var(--cbo-orange);
}
.cbo-panel-link:hover {
transform: translateX(4px);
}
/* ---- Free Quote pill ---- */
.cbo-cta {
display: none;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 30px;
border-radius: 100px;
background: var(--cbo-orange);
color: #fff;
font: 600 14px/1 var(--cbo-body);
text-decoration: none;
white-space: nowrap;
flex: none;
transition: background-color var(--cbo-dur) var(--cbo-ease);
}
.cbo-arrow {
width: 12px;
height: 12px;
flex: none;
transition: transform var(--cbo-dur) var(--cbo-ease);
}
.cbo-cta:hover {
background: var(--cbo-muted);
}
.cbo-cta:hover .cbo-arrow {
transform: rotate(45deg);
}
/* ---- burger ---- */
.cbo-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: #fff;
cursor: pointer;
}
.cbo:not([data-cbo-ready]) .cbo-burger {
display: none;
}
.cbo-burger-lines {
position: relative;
display: block;
width: 24px;
height: 18px;
}
.cbo-burger-lines span {
position: absolute;
left: 0;
width: 24px;
height: 2px;
border-radius: 1px;
background: currentColor;
transition:
transform var(--cbo-dur) var(--cbo-ease),
opacity var(--cbo-dur) var(--cbo-ease);
}
.cbo-burger-lines span:nth-child(1) { top: 0; }
.cbo-burger-lines span:nth-child(2) { top: 8px; }
.cbo-burger-lines span:nth-child(3) { top: 16px; }
.cbo[data-cbo-open] .cbo-burger-lines span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
.cbo[data-cbo-open] .cbo-burger-lines span:nth-child(2) { opacity: 0; }
.cbo[data-cbo-open] .cbo-burger-lines span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }
/* ---- small-screen menu card ---- */
.cbo-menu {
margin: 12px 0 0;
padding: 12px 22px 22px;
border-radius: 20px;
background: var(--cbo-navy);
border: 1px solid rgb(255 255 255 / 0.12);
box-shadow: 0 22px 50px rgb(0 0 0 / 0.35);
transition:
opacity var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease),
visibility 0s linear var(--cbo-dur);
}
.cbo[data-cbo-ready] .cbo-menu {
position: absolute;
top: calc(100% + 10px);
left: 20px;
right: 20px;
margin: 0;
max-height: calc(100vh - 140px);
max-height: calc(100dvh - 140px);
overflow-y: auto;
}
.cbo[data-cbo-ready]:not([data-cbo-open]) .cbo-menu {
opacity: 0;
transform: translateY(-8px);
visibility: hidden;
pointer-events: none;
}
.cbo[data-cbo-ready][data-cbo-open] .cbo-menu {
transition:
opacity var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease),
visibility 0s;
}
.cbo-menu-list > li + li {
border-top: 1px solid rgb(255 255 255 / 0.1);
}
.cbo-menu-link {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 48px;
padding: 0;
border: 0;
background: none;
color: #fff;
font: 500 17px/1.2 var(--cbo-display);
text-align: left;
text-decoration: none;
cursor: pointer;
border-radius: 4px;
transition: color var(--cbo-dur) var(--cbo-ease);
}
.cbo-menu-link:hover,
.cbo-menu-link[aria-current="page"] {
color: var(--cbo-orange);
}
.cbo-menu-toggle:hover,
.cbo-menu-toggle[data-current] {
color: var(--cbo-dd-hover);
}
.cbo-menu-toggle .cbo-plus {
width: 13px;
height: 13px;
}
.cbo-menu-toggle[aria-expanded="true"] .cbo-plus {
transform: rotate(45deg);
}
.cbo .cbo-sublist {
padding: 0 0 10px 14px;
margin-left: 2px;
border-left: 2px solid rgb(255 255 255 / 0.14);
}
.cbo-sublist[hidden] {
display: none;
}
.cbo-menu-sublink {
min-height: 44px;
color: #b0b2b8;
font: 400 16px/1.3 var(--cbo-body);
}
.cbo-cta--menu {
display: flex;
justify-content: center;
margin-top: 14px;
}
.cbo .cbo-menu-contacts {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid rgb(255 255 255 / 0.1);
}
.cbo-menu-contacts .cbo-contact-link {
gap: 12px;
font-size: 15px;
white-space: normal;
}
/* ---- focus ---- */
.cbo a:focus-visible,
.cbo button:focus-visible {
outline: 2px solid var(--cbo-orange);
outline-offset: 3px;
}
.cbo-cta:focus-visible {
outline-color: #fff;
}
.cbo-panel a:focus-visible {
outline-color: var(--cbo-orange);
}
.cbo-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- tablet and up: the contact bar appears ---- */
@media (min-width: 810px) {
.cbo-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 76px;
padding: 19px 20px 21px;
}
/* The nav bar tucks 12px up into the contact bar, as measured
(bar at 64px under a 76px contact bar). */
.cbo-barwrap {
padding: 0 24px;
margin-top: -12px;
}
.cbo--no-top .cbo-barwrap {
margin-top: 0;
padding-top: 24px;
}
.cbo-bar {
min-height: 100px;
padding: 22px 24px;
}
.cbo[data-cbo-ready] .cbo-menu {
left: 24px;
right: 24px;
max-width: 480px;
left: auto;
}
}
@media (min-width: 810px) and (max-width: 1099.98px) {
.cbo-contact--address {
display: none;
}
}
@media (min-width: 810px) and (max-width: 959.98px) {
.cbo-follow-label {
display: none;
}
}
/* ---- desktop: links and pill appear, the menu goes ---- */
@media (min-width: 1200px) {
.cbo-brand {
padding-right: clamp(24px, calc(20vw - 208px), 80px);
box-sizing: content-box;
}
.cbo-nav {
display: block;
flex: 1 1 auto;
min-width: 0;
}
.cbo-cta {
display: inline-flex;
}
.cbo-burger,
.cbo .cbo-menu {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cbo {
--cbo-dur: 1ms;
}
.cbo-trigger:hover .cbo-plus,
.cbo-trigger[aria-expanded="true"] .cbo-plus,
.cbo-cta:hover .cbo-arrow,
.cbo-panel-link:hover {
transform: none;
}
}
@media (forced-colors: active) {
.cbo-bar,
.cbo-panel,
.cbo-menu,
.cbo-cta,
.cbo-social-link {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* ContactBarOverlayHeader controller. Same behaviour as SolidBarHeader,
* deliberately duplicated so each header is one self-contained file.
*/
function initContactBarOverlayHeader(root: HTMLElement) {
if (root.dataset.cboReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-cbo-burger]");
const menu = root.querySelector<HTMLElement>("[data-cbo-menu]");
const dropdowns = [...root.querySelectorAll<HTMLElement>("[data-cbo-dd]")];
const subs = [...root.querySelectorAll<HTMLElement>("[data-cbo-sub]")];
root.dataset.cboReady = "";
const controller = new AbortController();
const { signal } = controller;
const desktop = window.matchMedia("(min-width: 1200px)");
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
let openDd: HTMLElement | null = null;
let openedByHover = false;
let hoverTimer = 0;
const triggerOf = (dd: HTMLElement) =>
dd.querySelector<HTMLButtonElement>("[data-cbo-trigger]");
function closeDropdown(returnFocus = false) {
window.clearTimeout(hoverTimer);
if (!openDd) return;
const dd = openDd;
openDd = null;
openedByHover = false;
dd.removeAttribute("data-open");
const trigger = triggerOf(dd);
trigger?.setAttribute("aria-expanded", "false");
if (returnFocus) trigger?.focus({ preventScroll: true });
}
function openDropdown(dd: HTMLElement, byHover = false) {
window.clearTimeout(hoverTimer);
if (openDd === dd) return;
closeDropdown();
openDd = dd;
openedByHover = byHover;
dd.setAttribute("data-open", "");
triggerOf(dd)?.setAttribute("aria-expanded", "true");
}
dropdowns.forEach((dd) => {
const trigger = triggerOf(dd);
trigger?.addEventListener(
"click",
() => {
if (openDd === dd && openedByHover) {
openedByHover = false;
return;
}
if (openDd === dd) closeDropdown();
else openDropdown(dd);
},
{ signal },
);
dd.addEventListener(
"pointerenter",
(event) => {
if (event.pointerType !== "mouse" || !finePointer.matches) return;
if (openDd && openDd !== dd && !openedByHover) return;
openDropdown(dd, true);
},
{ signal },
);
dd.addEventListener(
"pointerleave",
(event) => {
if (event.pointerType !== "mouse" || openDd !== dd || !openedByHover) return;
window.clearTimeout(hoverTimer);
hoverTimer = window.setTimeout(() => {
if (openDd === dd && openedByHover && !dd.contains(document.activeElement)) closeDropdown();
}, 160);
},
{ signal },
);
dd.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (openDd === dd && next && !dd.contains(next)) closeDropdown();
},
{ signal },
);
});
let menuOpen = false;
function setMenu(next: boolean, returnFocus = false) {
if (!burger || !menu || menuOpen === next) return;
menuOpen = next;
root.toggleAttribute("data-cbo-open", next);
burger.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger.focus({ preventScroll: true });
}
subs.forEach((sub) => {
const toggle = sub.querySelector<HTMLButtonElement>("[data-cbo-subtoggle]");
const list = sub.querySelector<HTMLElement>("[data-cbo-sublist]");
if (!toggle || !list) return;
const set = (open: boolean) => {
toggle.setAttribute("aria-expanded", String(open));
list.hidden = !open;
};
set(false);
toggle.addEventListener(
"click",
() => set(toggle.getAttribute("aria-expanded") !== "true"),
{ signal },
);
signal.addEventListener("abort", () => set(true));
});
burger?.addEventListener("click", () => setMenu(!menuOpen), { signal });
menu?.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape") return;
if (openDd) {
event.preventDefault();
const inside = openDd.contains(document.activeElement);
closeDropdown(inside || document.activeElement === document.body);
} else if (menuOpen) {
event.preventDefault();
setMenu(false, true);
}
},
{ signal },
);
document.addEventListener(
"pointerdown",
(event) => {
const target = event.target as Node;
if (openDd && !openDd.contains(target)) closeDropdown();
if (menuOpen && !root.contains(target)) setMenu(false);
},
{ signal },
);
menu?.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (menuOpen && next && !root.contains(next)) setMenu(false);
},
{ signal },
);
desktop.addEventListener(
"change",
() => {
setMenu(false);
closeDropdown();
},
{ signal },
);
function destroy() {
closeDropdown();
setMenu(false);
controller.abort();
delete root.dataset.cboReady;
}
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
}
const boot = () =>
document
.querySelectorAll<HTMLElement>("[data-cbo-root]")
.forEach(initContactBarOverlayHeader);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ContactBarOverlayHeader.astro =====
===== FILE (required): src/components/BreadcrumbPageBanner.astro =====
---
/**
* BreadcrumbPageBanner
*
* A 550px full-bleed page banner for inner pages: a photo under a navy
* wash, a big white page title and a breadcrumb trail ("Home » About Us")
* with orange separators. The top of the banner is left clear for an
* overlay header (ContactBarOverlayHeader is 164px tall at desktop), so the
* title sits in the lower half.
*
* Static: no script. Reads --tg-* design tokens from any ancestor.
*/
export interface BreadcrumbPageBannerCrumb {
label: string;
/** Omit on the last crumb, which is the current page. */
href?: string;
}
export interface BreadcrumbPageBannerProps {
title?: string;
/** The trail. The last crumb is the current page and gets aria-current. Defaults to Home » title. */
crumbs?: BreadcrumbPageBannerCrumb[];
/** `src: ""` renders a labelled placeholder instead of the photo. */
image?: { src?: string; alt?: string };
headingLevel?: "h1" | "h2";
breadcrumbLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends BreadcrumbPageBannerProps {}
/* No photo by default: without one the design's labelled placeholder shows. */
const DEFAULT_IMAGE = {
src: "",
alt: "",
};
const {
title = "",
crumbs,
image = {},
headingLevel = "h1",
breadcrumbLabel = "Breadcrumb",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
id,
} = Astro.props;
const given = (crumbs ?? []).filter((c) => c?.label);
const trail: BreadcrumbPageBannerCrumb[] =
given.length > 0 ? given : [{ label: "Home", href: "/" }, { label: title }];
const photo = { ...DEFAULT_IMAGE, ...image };
const Heading = headingLevel;
---
{title && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class="bpb" id={id} data-bpb-root>
<div class="bpb-bg" aria-hidden={photo.src && photo.alt ? undefined : "true"}>
{
photo.src ? (
<img
class="bpb-img"
src={photo.src}
alt={photo.alt ?? ""}
width="2000"
height="770"
loading="eager"
fetchpriority="high"
decoding="async"
/>
) : (
<div class="bpb-ph">
<span class="bpb-ph-label">Image</span>
<span class="bpb-ph-hint">Wide banner photo</span>
<span class="bpb-ph-spec">2000 × 770 · 26:10</span>
</div>
)
}
<div class="bpb-wash"></div>
</div>
<div class="bpb-inner">
<Heading class="bpb-title">{title}</Heading>
<nav class="bpb-crumbs" aria-label={breadcrumbLabel}>
<ol role="list">
{
trail.map((crumb, i) => {
const last = i === trail.length - 1;
return (
<li>
{i > 0 && (
<svg class="bpb-sep" viewBox="0 0 18 14" aria-hidden="true" focusable="false">
<path d="M2 1.5 7.5 7 2 12.5M9.5 1.5 15 7l-5.5 5.5" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
)}
{last ? (
<span class="bpb-current" aria-current="page">{crumb.label}</span>
) : crumb.href ? (
<a class="bpb-link" href={crumb.href}>{crumb.label}</a>
) : (
<span class="bpb-plain">{crumb.label}</span>
)}
</li>
);
})
}
</ol>
</nav>
</div>
</section>
</>
)}
<style>
.bpb {
--bpb-navy: var(--tg-navy, #020e28);
--bpb-orange: var(--tg-orange, #fd5523);
--bpb-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--bpb-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
position: relative;
isolation: isolate;
box-sizing: border-box;
display: flex;
align-items: flex-end;
width: 100%;
min-height: 255px;
padding: 124px 20px 56px;
overflow: hidden;
background: var(--bpb-navy);
color: #fff;
font-family: var(--bpb-body);
}
.bpb *,
.bpb *::before,
.bpb *::after {
box-sizing: border-box;
}
.bpb-bg {
position: absolute;
inset: 0;
z-index: -1;
}
.bpb-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 60% 50%;
}
/* The measured stack is a navy layer plus a 60% navy layer over the
photo. The photo still reads through in the capture, so the solid layer
is treated as a blend; this gradient reproduces the result — deeper on
the title side, lighter over the ship. */
.bpb-wash {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgb(2 14 40 / 0.72) 0%, rgb(2 14 40 / 0.5) 55%, rgb(2 14 40 / 0.38) 100%),
linear-gradient(180deg, rgb(2 14 40 / 0.25) 0%, rgb(2 14 40 / 0) 40%, rgb(2 14 40 / 0.2) 100%);
mix-blend-mode: normal;
}
.bpb-wash::before {
content: "";
position: absolute;
inset: 0;
background: rgb(8 36 110 / 0.35);
mix-blend-mode: multiply;
}
.bpb-ph {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 100%;
padding: 16px;
text-align: center;
background: repeating-linear-gradient(135deg, #1a2742 0 12px, #213050 12px 24px);
color: rgb(255 255 255 / 0.62);
}
.bpb-ph-label {
font: 500 12px/1 var(--bpb-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.bpb-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--bpb-body);
}
.bpb-ph-spec {
font: 400 12px/1.2 var(--bpb-body);
}
.bpb-inner {
width: 100%;
max-width: 1224px;
margin: 0 auto;
}
.bpb-title {
margin: 0;
color: #fff;
font: 700 40px/1.3 var(--bpb-display);
letter-spacing: normal;
overflow-wrap: anywhere;
}
.bpb-crumbs ol {
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 0;
padding: 0;
list-style: none;
}
.bpb-crumbs li {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: 8px;
font: 500 16px/28px var(--bpb-display);
}
.bpb-sep {
width: 18px;
height: 14px;
flex: none;
color: var(--bpb-orange);
}
.bpb-link {
display: inline-flex;
align-items: center;
min-height: 44px;
margin: -8px 0;
color: #fff;
text-decoration: none;
border-radius: 4px;
transition: color 0.4s cubic-bezier(0.44, 0, 0.56, 1);
}
.bpb-link:hover {
color: var(--bpb-orange);
}
.bpb-link:focus-visible {
outline: 2px solid var(--bpb-orange);
outline-offset: 3px;
}
.bpb-current,
.bpb-plain {
color: #fff;
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.bpb {
min-height: 440px;
padding: 240px 30px 100px;
}
.bpb-title {
font-size: 52px;
line-height: 1.4;
}
.bpb-crumbs li {
font-size: 18px;
}
}
/* ---- desktop: 550px, content from 300px ---- */
@media (min-width: 1200px) {
.bpb {
min-height: 550px;
padding: 300px 20px 138px;
}
/* Step 39.4: --tg-grid-max / --tg-grid-gutter put the title and crumbs on a site grid at desktop; the fallbacks give the original 1224px row. */
.bpb-inner {
--bpb-grid-max: var(--tg-grid-max, 1264px);
--bpb-grid-gutter: var(--tg-grid-gutter, 20px);
max-width: calc(var(--bpb-grid-max) - 2 * var(--bpb-grid-gutter));
}
.bpb-title {
font-size: 60px;
line-height: 84px;
}
}
@media (prefers-reduced-motion: reduce) {
.bpb-link {
transition: none;
}
}
</style>
===== END src/components/BreadcrumbPageBanner.astro =====
===== FILE (required): public/breadcrumb-page-banner/ship-at-sea.webp =====
[binary asset — download https://astro.baysixmedia.com/breadcrumb-page-banner/ship-at-sea.webp and save it to public/breadcrumb-page-banner/ship-at-sea.webp]
===== FILE (required): src/components/ContactInfoCards.astro =====
---
/**
* ContactInfoCards
*
* Three white, hairline-bordered rounded cards in a row: a pale orange icon
* tile (address pin, phone book, clock), an Outfit title and one or two
* lines under it. Lines with an `href` are links (a map, `tel:`, `mailto:`)
* that turn orange on hover.
*
* Appear on scroll: the cards rise 40px and fade in, staggered 0.1s. Without
* JS or under reduced motion they are at rest.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export type ContactInfoIcon = "pin" | "book" | "clock" | "phone" | "mail";
export interface ContactInfoLine {
text: string;
/** Makes the line a link: a map URL, `tel:` or `mailto:`. */
href?: string;
}
export interface ContactInfoItem {
icon?: ContactInfoIcon;
title: string;
lines: ContactInfoLine[];
}
export interface ContactInfoCardsProps {
items?: ContactInfoItem[];
/** Title element for each card. */
titleLevel?: "h2" | "h3" | "p";
/** The band behind the cards. */
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ContactInfoCardsProps {}
const {
/*
* No default. An address, a phone number and opening hours are facts about
* a real business: omitted, nothing is shown — the catalog demo passes its
* record explicitly through the registry.
*/
items = [],
titleLevel = "h3",
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const external = (href: string) => /^https?:/i.test(href.trim());
const Title = titleLevel;
const list = (items ?? []).filter((i) => i && i.title);
const ICONS: Record<ContactInfoIcon, string> = {
pin: "M15 2.5A10.5 10.5 0 0 0 4.5 13c0 7.6 8.8 16.4 9.8 17.4a1 1 0 0 0 1.4 0c1-1 9.8-9.8 9.8-17.4A10.5 10.5 0 0 0 15 2.5Zm0 14.5a4 4 0 1 1 0-8 4 4 0 0 1 0 8Z",
book: "M8 2.5h15a3 3 0 0 1 3 3v21a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3v-21a3 3 0 0 1 3-3Zm7.5 7.5a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4Zm0 8.2c-3 0-5.5 1.5-5.5 3.3V23h11v-1.5c0-1.8-2.5-3.3-5.5-3.3ZM27.5 7H29a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-1.5Zm0 6H29a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-1.5Zm0 6H29a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-1.5Z",
clock: "M16 2.5a13.5 13.5 0 1 0 0 27 13.5 13.5 0 0 0 0-27Zm1.4 13.2 5 5a1.4 1.4 0 0 1-2 2l-5.4-5.4a1.4 1.4 0 0 1-.4-1V8.5a1.4 1.4 0 0 1 2.8 0Z",
phone: "M9 14.3a21 21 0 0 0 8.7 8.7l2.9-2.9a1.3 1.3 0 0 1 1.3-.3 15 15 0 0 0 4.7.75 1.3 1.3 0 0 1 1.3 1.3v4.6a1.3 1.3 0 0 1-1.3 1.3A22.3 22.3 0 0 1 4.3 5.4 1.3 1.3 0 0 1 5.6 4.1h4.6a1.3 1.3 0 0 1 1.3 1.3c0 1.6.26 3.2.75 4.7a1.3 1.3 0 0 1-.33 1.3Z",
mail: "M4 6.5h24a1.5 1.5 0 0 1 1.5 1.5v.7L16 16.6 2.5 8.7V8A1.5 1.5 0 0 1 4 6.5Zm-1.5 5.5L16 19.8 29.5 12v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 24Z",
};
---
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section class:list={["cic", className]} id={id} data-cic-root style={`--cic-band: ${background};`}>
<ul class="cic-grid">
{
list.map((item, i) => (
<li class="cic-card" data-cic-appear style={`--cic-i: ${i}`}>
<span class="cic-tile" aria-hidden="true">
<svg viewBox="0 0 32 32" width="40" height="40">
<path fill="currentColor" d={ICONS[item.icon ?? "pin"] ?? ICONS.pin} />
</svg>
</span>
<div class="cic-body">
<Title class="cic-title">{item.title}</Title>
{(item.lines ?? []).map((line) => (
<p class="cic-line">
{line.href ? (
<a
class="cic-link"
href={safeHref(line.href)}
target={external(line.href) ? "_blank" : undefined}
rel={external(line.href) ? "noopener noreferrer" : undefined}
>
{line.text}
{external(line.href) && <span class="cic-sr"> (opens in a new tab)</span>}
</a>
) : (
line.text
)}
</p>
))}
</div>
</li>
))
}
</ul>
</section>
)}
<style>
.cic {
--cic-navy: var(--tg-navy, #020e28);
--cic-orange: var(--tg-orange, #fd5523);
--cic-muted: var(--tg-muted, #61697d);
--cic-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--cic-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--cic-ease: cubic-bezier(0.22, 1, 0.36, 1);
box-sizing: border-box;
padding: 60px 20px;
background: var(--cic-band);
font-family: var(--cic-body);
}
.cic *,
.cic *::before,
.cic *::after {
box-sizing: border-box;
}
.cic .cic-grid {
display: grid;
gap: 20px;
max-width: 1224px;
margin: 0 auto;
padding: 0;
list-style: none;
}
.cic-card {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
padding: 16px;
border: 1px solid #e3e6ea;
border-radius: 20px;
background: #fff;
}
.cic-tile {
flex: none;
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 12px;
background: #ffe0e0;
color: var(--cic-orange);
}
.cic-tile svg {
width: 28px;
height: 28px;
}
.cic-body {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.cic .cic-title {
margin: 0 0 2px;
font: 600 20px/1.4 var(--cic-display);
color: var(--cic-navy);
}
.cic .cic-line {
margin: 0;
font: 400 16px/21px var(--cic-body);
color: var(--cic-muted);
overflow-wrap: anywhere;
}
.cic-link {
color: inherit;
text-decoration: none;
transition: color 0.4s cubic-bezier(0.44, 0, 0.56, 1);
}
.cic-link:hover {
color: var(--cic-orange);
}
.cic-link:focus-visible {
outline: 2px solid var(--cic-orange);
outline-offset: 2px;
border-radius: 2px;
}
.cic-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- appear ---- */
.cic-card {
transition:
opacity 0.8s var(--cic-ease),
transform 0.8s var(--cic-ease);
transition-delay: calc(var(--cic-i, 0) * 0.1s);
}
.cic[data-cic-anim] .cic-card:not([data-in]) {
opacity: 0;
transform: translateY(40px);
}
/* ---- tablet: as many 300px+ cards as fit ---- */
@media (min-width: 810px) {
.cic {
padding: 90px 20px;
}
.cic .cic-grid {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
}
.cic-card {
gap: 20px;
padding: 22px;
border-radius: 30px;
}
.cic-tile {
width: 70px;
height: 70px;
border-radius: 16px;
}
.cic-tile svg {
width: 40px;
height: 40px;
}
.cic .cic-title {
font-size: 24px;
line-height: 34px;
}
}
/* ---- desktop: three 388px cards ---- */
@media (min-width: 1200px) {
.cic {
padding: 120px 20px;
}
.cic .cic-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.cic-card {
min-height: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.cic-card {
transition: none;
}
}
@media (forced-colors: active) {
.cic-card {
border-color: CanvasText;
}
.cic-tile {
border: 1px solid CanvasText;
}
}
</style>
<script>
/*
* Appear on scroll. One IntersectionObserver per instance; each card gets
* `data-in` once. Released on astro:before-swap.
*/
function initContactInfoCards(root: HTMLElement) {
if (root.dataset.cicReady !== undefined) return;
root.dataset.cicReady = "";
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!("IntersectionObserver" in window)) return;
const controller = new AbortController();
const { signal } = controller;
root.dataset.cicAnim = "";
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
observer.unobserve(entry.target);
}
},
{ threshold: 0.15 },
);
root.querySelectorAll<HTMLElement>("[data-cic-appear]").forEach((el) => observer.observe(el));
signal.addEventListener("abort", () => observer.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.cicReady;
delete root.dataset.cicAnim;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-cic-root]").forEach(initContactInfoCards);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ContactInfoCards.astro =====
===== FILE (required): src/components/MapContactForm.astro =====
---
/**
* MapContactForm
*
* A contact section: a centred orange eyebrow between two short rules and a
* big navy heading, then two columns — an embedded map on the left and a
* light grey rounded form panel on the right (name, email, phone, freight
* type, load, message, and a full-width orange "Submit Request" pill).
*
* The map is a lazy <iframe> of `mapEmbedUrl`. Unset (the default), there is
* no map and the form stands alone, centred; `mapEmbedUrl: ""` renders a
* labelled placeholder instead and makes no third-party request. No heading
* or place is assumed. The form is native: `action` is unset by default,
* every control has a real (visually hidden) label.
*
* Appear on scroll: the title rises 40px; the map slides in from −40px and
* the form from +40px. Without JS or under reduced motion all at rest.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface MapContactLabels {
name?: string;
email?: string;
phone?: string;
freightType?: string;
load?: string;
message?: string;
}
export interface MapContactFormProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** A map embed URL. Unset: no map. `""` renders the labelled placeholder instead. */
mapEmbedUrl?: string;
/** The iframe's title — what the map shows. */
mapTitle?: string;
/** Where the form posts. Unset: the browser submits to the current URL. */
action?: string;
method?: "get" | "post";
formName?: string;
submitLabel?: string;
/** Freight type options. `[]` drops the select. */
freightTypes?: string[];
/** Load options. `[]` drops the select. */
loads?: string[];
/** Accessible labels (visually hidden). */
labels?: MapContactLabels;
/** Placeholders — hints only, never the label. */
placeholders?: MapContactLabels;
background?: string;
fontStylesheet?: string;
class?: string;
/** Root id. Also seeds every field id. */
id?: string;
}
interface Props extends MapContactFormProps {}
const {
eyebrow = "Get In Touch",
heading = "",
headingLevel = "h2",
mapEmbedUrl,
mapTitle = "",
action,
method = "post",
formName,
submitLabel = "Submit Request",
freightTypes = ["Air Freight", "Sea Freight", "Road Freight", "Rail Freight"],
loads = ["Under 1 ton", "1 ton", "2 tons", "5 tons", "Full truckload"],
labels = {},
placeholders = {},
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
const uid = (id ?? `mcf-${Math.random().toString(36).slice(2, 9)}`).replace(/[^A-Za-z0-9_-]/g, "") || "mcf";
const fid = (name: string) => `${uid}-${name}`;
/* Only an http(s) page may be framed. */
const mapSrc = mapEmbedUrl && /^https:\/\//i.test(mapEmbedUrl.trim()) ? mapEmbedUrl.trim() : "";
/* No map at all unless one (or the placeholder, with "") is asked for. */
const showMap = mapEmbedUrl !== undefined;
const mapLabel = mapTitle || "Map";
const L: Required<MapContactLabels> = {
name: "Your name",
email: "Your email",
phone: "Phone number",
freightType: "Freight type",
load: "Load",
message: "Message",
...labels,
};
const P: Required<MapContactLabels> = {
name: "Your Name",
email: "Your Email",
phone: "Phone No:",
freightType: "Freight Type",
load: "Load",
message: "Message",
...placeholders,
};
---
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["mcf", className]} id={id} data-mcf-root style={`--mcf-band: ${background};`}>
<div class="mcf-inner">
{(eyebrow || heading) && (
<div class="mcf-title" data-mcf-appear>
{
eyebrow && (
<p class="mcf-eyebrow">
<span class="mcf-rule" aria-hidden="true" />
<span>{eyebrow}</span>
<span class="mcf-rule" aria-hidden="true" />
</p>
)
}
{heading && <Heading class="mcf-heading">{heading}</Heading>}
</div>
)}
<div class="mcf-body" style={showMap ? undefined : "grid-template-columns: minmax(0, 630px); justify-content: center;"}>
{showMap && (
<div class="mcf-map" data-mcf-appear="left">
{
mapSrc ? (
<iframe
src={mapSrc}
title={mapLabel}
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
allowfullscreen
/>
) : (
<div class="mcf-ph" role="img" aria-label={`Map placeholder: ${mapLabel}`}>
<div class="mcf-ph-text" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28"><path fill="currentColor" d="M12 2a7 7 0 0 0-7 7c0 5 6.1 11.4 6.4 11.7a.8.8 0 0 0 1.2 0C12.9 20.4 19 14 19 9a7 7 0 0 0-7-7Zm0 9.6A2.6 2.6 0 1 1 12 6.4a2.6 2.6 0 0 1 0 5.2Z" /></svg>
<span class="mcf-ph-label">Map</span>
<span class="mcf-ph-hint">{mapLabel}</span>
<span class="mcf-ph-spec">Pass mapEmbedUrl to embed a map</span>
</div>
</div>
)
}
</div>
)}
<form class="mcf-form" action={action} method={method} name={formName} data-mcf-appear="right">
<div class="mcf-field">
<label class="mcf-sr" for={fid("name")}>{L.name}</label>
<input class="mcf-input" type="text" id={fid("name")} name="name" autocomplete="name" placeholder={P.name} required />
<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M6 6a2.6 2.6 0 1 0 0-5.2A2.6 2.6 0 0 0 6 6Zm0 1.1c-2.4 0-4.6 1.2-4.6 2.9v1.2h9.2V10c0-1.7-2.2-2.9-4.6-2.9Z" /></svg>
</div>
<div class="mcf-pair">
<div class="mcf-field">
<label class="mcf-sr" for={fid("email")}>{L.email}</label>
<input class="mcf-input" type="email" id={fid("email")} name="email" autocomplete="email" placeholder={P.email} required />
<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M1 2.5h10a.5.5 0 0 1 .5.5v.4L6 6.6.5 3.4V3a.5.5 0 0 1 .5-.5Zm-.5 2.1L6 7.8l5.5-3.2V9a.5.5 0 0 1-.5.5H1a.5.5 0 0 1-.5-.5Z" /></svg>
</div>
<div class="mcf-field">
<label class="mcf-sr" for={fid("phone")}>{L.phone}</label>
<input class="mcf-input" type="tel" id={fid("phone")} name="phone" autocomplete="tel" placeholder={P.phone} />
<svg class="mcf-icon" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M3.3 5.3a8 8 0 0 0 3.4 3.4l1.1-1.1a.5.5 0 0 1 .5-.13 5.7 5.7 0 0 0 1.8.29.5.5 0 0 1 .5.5V10a.5.5 0 0 1-.5.5A8.5 8.5 0 0 1 1.5 2a.5.5 0 0 1 .5-.5h1.75a.5.5 0 0 1 .5.5c0 .63.1 1.23.29 1.8a.5.5 0 0 1-.13.5Z" /></svg>
</div>
</div>
{(freightTypes.length > 0 || loads.length > 0) && (
<div class="mcf-pair">
{freightTypes.length > 0 && (
<div class="mcf-field">
<label class="mcf-sr" for={fid("freight")}>{L.freightType}</label>
<select class="mcf-input mcf-select" id={fid("freight")} name="freightType">
<option value="" selected>{P.freightType}</option>
{freightTypes.map((o) => <option value={o}>{o}</option>)}
</select>
<svg class="mcf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
</div>
)}
{loads.length > 0 && (
<div class="mcf-field">
<label class="mcf-sr" for={fid("load")}>{L.load}</label>
<select class="mcf-input mcf-select" id={fid("load")} name="load">
<option value="" selected>{P.load}</option>
{loads.map((o) => <option value={o}>{o}</option>)}
</select>
<svg class="mcf-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 6 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
</div>
)}
</div>
)}
<div class="mcf-field">
<label class="mcf-sr" for={fid("message")}>{L.message}</label>
<textarea class="mcf-input mcf-textarea" id={fid("message")} name="message" rows="5" placeholder={P.message} required></textarea>
</div>
<button class="mcf-btn" type="submit">{submitLabel}</button>
</form>
</div>
</div>
</section>
<style>
.mcf {
--mcf-navy: var(--tg-navy, #020e28);
--mcf-orange: var(--tg-orange, #fd5523);
--mcf-muted: var(--tg-muted, #61697d);
--mcf-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--mcf-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--mcf-ease: cubic-bezier(0.22, 1, 0.36, 1);
box-sizing: border-box;
padding: 0 0 60px;
background: var(--mcf-band);
color: var(--mcf-navy);
font-family: var(--mcf-body);
/* The form's entrance starts 40px to the right; until it plays, that
offset must not push the page sideways on a narrow screen. `clip`, not
`hidden`: no scroll container, so nothing about focus or sticky changes. */
overflow-x: clip;
}
.mcf *,
.mcf *::before,
.mcf *::after {
box-sizing: border-box;
}
.mcf-inner {
display: flex;
flex-direction: column;
gap: 40px;
max-width: 1293px;
margin: 0 auto;
padding: 0 20px;
/* Contains the ±40px appear slides at every width. */
overflow: clip;
overflow-clip-margin: 8px;
}
.mcf-title {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
text-align: center;
}
.mcf .mcf-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--mcf-display);
text-transform: uppercase;
color: var(--mcf-orange);
}
.mcf-rule {
display: block;
width: 40px;
height: 1px;
background: currentColor;
}
.mcf-heading {
margin: 0;
max-width: 680px;
font: 600 32px/1.2 var(--mcf-display);
color: var(--mcf-navy);
overflow-wrap: anywhere;
}
.mcf-body {
display: flex;
flex-direction: column;
gap: 10px;
}
/* On a phone the form comes first, as the reference does; the map's one
tab stop (the iframe) then follows the form in visual order too. */
.mcf-map {
order: 1;
position: relative;
height: 320px;
border-radius: 20px;
overflow: hidden;
background: #e9ecef;
}
.mcf-map iframe {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
border: 0;
}
.mcf-map:has(iframe:focus-visible) {
outline: 3px solid var(--mcf-orange);
outline-offset: 3px;
}
.mcf-ph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
text-align: center;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgb(2 14 40 / 0.62);
}
.mcf-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.mcf-ph-text svg {
color: var(--mcf-orange);
}
.mcf-ph-label {
font: 500 12px/1 var(--mcf-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mcf-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--mcf-body);
}
.mcf-ph-spec {
font: 400 12px/1.2 var(--mcf-body);
}
.mcf-form {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
margin: 0;
padding: 16px;
border-radius: 20px;
background: #f4f4f4;
}
.mcf-pair {
display: grid;
gap: 12px;
}
.mcf-field {
position: relative;
min-width: 0;
}
.mcf-input {
display: block;
width: 100%;
min-height: 44px;
margin: 0;
padding: 11px 44px 11px 18px;
border: 0;
border-radius: 100px;
background: #fff;
color: var(--mcf-navy);
font: 400 16px/20px var(--mcf-body);
}
.mcf-input::placeholder {
color: var(--mcf-muted);
opacity: 1;
}
/* An outline, not a shadow: it survives forced colours. */
.mcf-input:focus {
outline: 2px solid var(--mcf-orange);
outline-offset: 2px;
}
.mcf-select {
appearance: none;
-webkit-appearance: none;
cursor: pointer;
}
.mcf-select:has(option[value=""]:checked) {
color: var(--mcf-muted);
}
.mcf-select option {
color: var(--mcf-navy);
}
.mcf-textarea {
min-height: 100px;
padding: 14px 18px;
border-radius: 20px;
resize: vertical;
}
.mcf-icon,
.mcf-chevron {
position: absolute;
top: 50%;
right: 20px;
width: 12px;
height: 12px;
transform: translateY(-50%);
color: var(--mcf-orange);
pointer-events: none;
}
.mcf-chevron {
width: 18px;
height: 18px;
right: 18px;
}
.mcf-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 48px;
padding: 16px 20px;
border: 0;
border-radius: 100px;
background: var(--mcf-orange);
color: #fff;
font: 600 14px/1 var(--mcf-body);
cursor: pointer;
transition: background-color 0.35s ease-out;
}
.mcf-btn:hover {
background: var(--mcf-navy);
}
.mcf-btn:focus-visible {
outline: 2px solid var(--mcf-navy);
outline-offset: 3px;
}
.mcf-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- appear ---- */
[data-mcf-appear] {
transition:
opacity 0.8s var(--mcf-ease),
transform 0.8s var(--mcf-ease);
}
.mcf[data-mcf-anim] [data-mcf-appear]:not([data-in]) {
opacity: 0;
transform: translateY(40px);
}
.mcf[data-mcf-anim] [data-mcf-appear="left"]:not([data-in]) {
transform: translateX(-40px);
}
.mcf[data-mcf-anim] [data-mcf-appear="right"]:not([data-in]) {
transform: translateX(40px);
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.mcf {
padding-bottom: 90px;
}
.mcf-inner {
gap: 50px;
}
.mcf-heading {
font-size: 40px;
}
.mcf-form {
gap: 25px;
padding: 40px;
}
.mcf-pair {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 30px;
}
/* One select left (the other's options were emptied): full width. */
.mcf-pair:has(> .mcf-field:only-child) {
grid-template-columns: minmax(0, 1fr);
}
.mcf-input {
min-height: 56px;
padding: 17px 48px 17px 24px;
}
.mcf-textarea {
min-height: 175px;
padding: 16px 24px;
}
.mcf-icon {
right: 24px;
}
.mcf-chevron {
right: 22px;
}
.mcf-btn {
min-height: 56px;
padding: 21px 20px;
}
.mcf-map {
height: 400px;
}
}
/* ---- desktop: map left, form right ---- */
@media (min-width: 1200px) {
.mcf {
padding-bottom: 120px;
}
.mcf-inner {
gap: 60px;
}
.mcf .mcf-eyebrow {
font-size: 18px;
}
.mcf-heading {
font-size: 48px;
line-height: 57.6px;
}
.mcf-body {
display: grid;
grid-template-columns: minmax(0, 589fr) minmax(0, 630fr);
padding: 0 12px;
}
.mcf-map {
order: 0;
height: auto;
min-height: 579px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-mcf-appear] {
transition: none;
}
}
@media (forced-colors: active) {
.mcf-input,
.mcf-btn {
border: 1px solid ButtonText;
}
.mcf-form,
.mcf-map {
border: 1px solid CanvasText;
}
.mcf-icon,
.mcf-chevron,
.mcf-rule {
color: CanvasText;
}
.mcf-rule {
background: CanvasText;
}
}
</style>
<script>
/*
* Appear on scroll only. The form is native and the map is an iframe;
* neither needs script. One IntersectionObserver per instance; each
* watched element gets `data-in` once. Released on astro:before-swap.
*/
function initMapContactForm(root: HTMLElement) {
if (root.dataset.mcfReady !== undefined) return;
root.dataset.mcfReady = "";
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!("IntersectionObserver" in window)) return;
const controller = new AbortController();
const { signal } = controller;
root.dataset.mcfAnim = "";
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
observer.unobserve(entry.target);
}
},
{ threshold: 0.1 },
);
root.querySelectorAll<HTMLElement>("[data-mcf-appear]").forEach((el) => observer.observe(el));
signal.addEventListener("abort", () => observer.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.mcfReady;
delete root.dataset.mcfAnim;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-mcf-root]").forEach(initMapContactForm);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/MapContactForm.astro =====
===== FILE (required): src/components/CargoLogoMarquee.astro =====
---
/**
* CargoLogoMarquee
*
* A band of partner wordmarks drifting slowly right to left, one full loop
* of the list every ~92s.
*
* - `tone: "photo"` (default): a 200px band over a port photo under a navy
* overlay, with white and orange marks.
* - `tone: "light"`: a light grey band with navy and orange marks, and an
* optional heading line above it set between two hairlines.
*
* Marks come only from `logos` (with no logos it renders nothing); six simple
* ship / crane / plane / rail / truck / globe glyphs are drawn inline. The marquee is
* pure CSS: the list is rendered twice and the track slides by exactly one
* copy, so the loop is seamless; the copy is aria-hidden. It pauses on hover,
* while anything inside has focus, and while its pause toggle is on; it never
* runs under prefers-reduced-motion. No script.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export type CargoGlyph = "ship" | "crane" | "plane" | "rail" | "truck" | "globe";
export interface CargoLogo {
/** First word of the wordmark, in the base colour. */
name: string;
/** Second word, in orange. */
accent?: string;
/** Tiny line under the wordmark. */
tagline?: string;
glyph?: CargoGlyph;
/** Optional link for the mark. */
href?: string;
}
export interface CargoLogoMarqueeProps {
tone?: "photo" | "light";
/** Light tone only: the line above the band. `""` drops it. */
heading?: string;
logos?: CargoLogo[];
/** Photo tone background. `src: ""` = plain navy band. */
image?: { src?: string; alt?: string };
/** Seconds for one full loop of the list. */
duration?: number;
/** A small pause toggle in the band's corner (WCAG 2.2.2). */
pauseToggle?: boolean;
/** Accessible name for the list of marks. */
label?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends CargoLogoMarqueeProps {}
const {
tone = "photo",
heading = "",
logos: rawLogos = [],
image: imageProp,
duration = 92,
pauseToggle = true,
label = "Partners",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
/* No photo by default: the photo tone falls back to its plain navy band. */
const image = { src: "", alt: "", ...(imageProp ?? {}) };
/* A mark without a name is dropped. No marks, no band: the heading alone would be a claim. */
const logos = rawLogos.filter((logo) => logo?.name);
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const uid = id ?? `clm-${Math.random().toString(36).slice(2, 8)}`;
const copies = [false, true];
const seconds = Math.max(5, Number(duration) || 92);
---
{logos.length > 0 && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
class:list={["clm", `clm--${tone}`, className]}
id={id}
style={`--clm-duration: ${seconds}s;`}
aria-label={label}
data-clm-root
>
{
tone === "light" && heading && (
<div class="clm-heading-row">
<span class="clm-hairline" aria-hidden="true" />
<p class="clm-heading">{heading}</p>
<span class="clm-hairline" aria-hidden="true" />
</div>
)
}
<div class="clm-band">
{
tone === "photo" && image.src && (
<img class="clm-bg" src={image.src} alt={image.alt} width="2000" height="280" loading="lazy" decoding="async" />
)
}
{
pauseToggle && (
<>
<input class="clm-toggle" type="checkbox" id={`${uid}-pause`} />
<label class="clm-toggle-label" for={`${uid}-pause`}>
<span class="clm-sr">Pause the moving logos</span>
<svg class="clm-i-pause" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path d="M3 2h2v8H3zM7 2h2v8H7z" fill="currentColor" />
</svg>
<svg class="clm-i-play" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path d="M3 1.5v9l7.5-4.5z" fill="currentColor" />
</svg>
</label>
</>
)
}
<div class="clm-viewport">
<div class="clm-track">
{
copies.map((isCopy) => (
<ul class="clm-group" role="list" aria-hidden={isCopy ? "true" : undefined}>
{logos.map((logo) => {
const inner = (
<>
<svg class="clm-glyph" viewBox="0 0 80 44" width="80" height="44" aria-hidden="true" focusable="false">
{logo.glyph === "ship" && (
<g>
<rect class="a" x="18" y="12" width="10" height="10" rx="1" />
<rect class="a" x="30" y="12" width="10" height="10" rx="1" />
<rect class="a" x="42" y="6" width="10" height="16" rx="1" />
<path class="p" d="M56 6h8v18h-8z" />
<path class="p" d="M4 25h72l-9 13H15z" />
<path class="s" d="M8 41c5 0 5-2 10-2s5 2 10 2 5-2 10-2 5 2 10 2 5-2 10-2 5 2 10 2" />
</g>
)}
{logo.glyph === "crane" && (
<g>
<path class="s" d="M14 42V6h6v36M14 12h58M20 6l10 6M58 12v10" />
<rect class="a" x="48" y="22" width="20" height="10" rx="1" />
<path class="p" d="M4 38h34v4H4z" />
<rect class="a" x="40" y="34" width="14" height="8" rx="1" />
<rect class="p" x="56" y="34" width="16" height="8" rx="1" />
</g>
)}
{logo.glyph === "plane" && (
<g>
<path class="a" d="M8 32 70 12c4-1 7 0 7 2s-3 4-6 5L16 36z" />
<path class="p" d="M36 22 28 4h6l18 14zM40 26l-4 14h6l12-18zM14 32 8 22h4l10 8z" />
</g>
)}
{logo.glyph === "rail" && (
<g>
<path class="a" d="M6 10h50l16 12v12H6z" />
<rect class="p" x="12" y="15" width="8" height="7" rx="1" />
<rect class="p" x="24" y="15" width="8" height="7" rx="1" />
<rect class="p" x="36" y="15" width="8" height="7" rx="1" />
<path class="p" d="M56 15h4l8 7H56z" />
<circle class="p" cx="16" cy="37" r="4" />
<circle class="p" cx="30" cy="37" r="4" />
<circle class="p" cx="58" cy="37" r="4" />
<path class="s" d="M2 42h76" />
</g>
)}
{logo.glyph === "truck" && (
<g>
<rect class="a" x="4" y="8" width="46" height="24" rx="2" />
<path class="p" d="M52 14h14l10 11v7H52z" />
<path class="bg" d="M56 17h8l5 7H56z" />
<circle class="p" cx="16" cy="36" r="5" />
<circle class="p" cx="38" cy="36" r="5" />
<circle class="p" cx="64" cy="36" r="5" />
</g>
)}
{logo.glyph === "globe" && (
<g>
<circle class="s" cx="40" cy="22" r="18" />
<ellipse class="s" cx="40" cy="22" rx="8" ry="18" />
<path class="s" d="M22 22h36" />
<path class="a" d="M6 30c16 10 52 10 68-8l2 3C60 44 20 42 6 30z" />
</g>
)}
</svg>
<span class="clm-name">
{logo.name}
{logo.accent && <span class="clm-accent">{logo.accent}</span>}
</span>
{logo.tagline && <span class="clm-tag">{logo.tagline}</span>}
</>
);
return (
<li class="clm-item">
{logo.href ? (
<a class="clm-logo" href={safeHref(logo.href)} tabindex={isCopy ? "-1" : undefined}>
{inner}
</a>
) : (
<span class="clm-logo">{inner}</span>
)}
</li>
);
})}
</ul>
))
}
</div>
</div>
</div>
</section>
</>
)}
<style>
.clm {
--clm-navy: var(--tg-navy, #020e28);
--clm-orange: var(--tg-orange, #fd5523);
--clm-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--clm-gap: 64px;
box-sizing: border-box;
font-family: var(--clm-display);
overflow-x: clip;
}
.clm *,
.clm *::before,
.clm *::after {
box-sizing: border-box;
}
.clm--photo {
--clm-ink: #fff;
background: var(--clm-navy);
}
.clm--light {
--clm-ink: var(--clm-navy);
display: flex;
flex-direction: column;
gap: 40px;
background: #fff;
}
/* ---- light tone heading line ---- */
.clm-heading-row {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
width: 100%;
max-width: 1293px;
margin: 0 auto;
padding: 0 20px;
}
.clm-hairline {
display: none;
flex: 1 1 0;
max-width: 300px;
height: 1px;
background: #d8dde1;
}
.clm .clm-heading {
margin: 0;
font: 500 18px/1.5 var(--clm-display);
text-align: center;
color: var(--clm-navy);
}
/* ---- band ---- */
.clm-band {
position: relative;
display: flex;
align-items: center;
min-height: 136px;
padding: 28px 0;
isolation: isolate;
}
.clm--light .clm-band {
padding: 40px 0;
background: #f4f4f4;
}
.clm-bg {
position: absolute;
inset: 0;
z-index: -2;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Navy overlay over the photo. */
.clm--photo .clm-band::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: rgb(2 14 40 / 0.86);
}
.clm-viewport {
width: 100%;
overflow: hidden;
container-type: inline-size;
}
.clm-track {
display: flex;
width: max-content;
animation: clm-slide var(--clm-duration, 92s) linear infinite;
}
@keyframes clm-slide {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.clm .clm-group {
display: flex;
flex: none;
justify-content: space-around;
align-items: center;
gap: var(--clm-gap);
min-width: 100cqw;
margin: 0;
padding: 0 var(--clm-gap) 0 0;
list-style: none;
}
.clm-item {
flex: none;
}
.clm-logo {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
height: 80px;
justify-content: center;
color: var(--clm-ink);
text-decoration: none;
white-space: nowrap;
}
a.clm-logo:focus-visible {
outline: 2px solid var(--clm-orange);
outline-offset: 4px;
border-radius: 4px;
}
.clm-glyph {
display: block;
width: 64px;
height: auto;
overflow: visible;
}
.clm-glyph .p {
fill: var(--clm-ink);
}
.clm-glyph .a {
fill: var(--clm-orange);
}
.clm-glyph .s {
fill: none;
stroke: var(--clm-ink);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.clm-glyph .bg {
fill: var(--clm-orange);
opacity: 0.35;
}
.clm-name {
font: 700 15px/1 var(--clm-display);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.clm-accent {
color: var(--clm-orange);
}
.clm-tag {
font: 500 6.5px/1 var(--clm-display);
letter-spacing: 0.24em;
text-transform: uppercase;
opacity: 0.7;
}
/* ---- pause: hover, focus inside, or the toggle ---- */
.clm-band:hover .clm-track,
.clm-band:focus-within .clm-track,
.clm-toggle:checked ~ .clm-viewport .clm-track {
animation-play-state: paused;
}
.clm-toggle {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.clm-toggle-label {
position: absolute;
right: 8px;
bottom: 4px;
z-index: 1;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
color: var(--clm-ink);
opacity: 0.35;
cursor: pointer;
transition: opacity 0.3s ease-out;
}
.clm-toggle-label::before {
content: "";
position: absolute;
inset: 10px;
border: 1px solid currentColor;
border-radius: 50%;
}
.clm-band:hover .clm-toggle-label,
.clm-toggle:focus-visible + .clm-toggle-label {
opacity: 1;
}
.clm-toggle:focus-visible + .clm-toggle-label {
outline: 2px solid var(--clm-orange);
outline-offset: -2px;
}
.clm-i-play,
.clm-toggle:checked + .clm-toggle-label .clm-i-pause {
display: none;
}
.clm-toggle:checked + .clm-toggle-label .clm-i-play {
display: block;
}
.clm-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
@media (min-width: 810px) {
.clm {
--clm-gap: 120px;
}
.clm--light {
gap: 60px;
}
.clm-hairline {
display: block;
}
.clm .clm-heading {
font-size: 20px;
line-height: 30px;
}
.clm-band {
min-height: 200px;
padding: 60px 0;
}
.clm--light .clm-band {
padding: 60px 0;
}
.clm-glyph {
width: 76px;
}
.clm-name {
font-size: 17px;
}
}
@media (min-width: 1200px) {
.clm {
--clm-gap: 171px;
}
}
/* Static under reduced motion; the copy is hidden so nothing repeats. */
@media (prefers-reduced-motion: reduce) {
.clm-track {
animation: none;
width: 100%;
}
.clm .clm-group {
flex-wrap: wrap;
justify-content: center;
row-gap: 24px;
min-width: 0;
width: 100%;
padding: 0 20px;
gap: 24px 48px;
}
.clm .clm-group[aria-hidden="true"],
.clm-toggle,
.clm-toggle-label {
display: none;
}
}
@media (forced-colors: active) {
.clm-glyph .p,
.clm-glyph .a,
.clm-glyph .bg {
fill: CanvasText;
}
.clm-glyph .s {
stroke: CanvasText;
}
}
</style>
===== END src/components/CargoLogoMarquee.astro =====
===== FILE (required): public/cargo-logo-marquee/port-aerial.webp =====
[binary asset — download https://astro.baysixmedia.com/cargo-logo-marquee/port-aerial.webp and save it to public/cargo-logo-marquee/port-aerial.webp]
===== FILE (required): src/components/NewsletterPanelFooter.astro =====
---
/**
* NewsletterPanelFooter
*
* A navy footer in two parts. On top, "Subscribe Our Newsletter" beside an
* email field with an orange Subscribe button (a real form). Below, the
* brand block — logo, a short blurb, four social circles — and three
* columns: Quick Links and Our Services (links with » bullets) and Contact
* Us (mail and phone). Headings carry a short white-and-orange underline.
*
* Two measured layouts:
* - `variant="panel"` (default, the reference's inner pages): the brand and
* columns sit in a rounded panel with a slightly lighter fill and a
* gradient hairline, and a centred copyright line closes the footer.
* - `variant="band"` (the reference's home page): a hairline divides the
* newsletter row from the columns, and the copyright sits on an orange band.
*
* Nothing about the business is assumed: no name, blurb, link, contact,
* social profile, copyright or sign-up form is shown unless it is passed, a
* block with nothing in it is not drawn, and with no content at all nothing
* renders.
*
* The top content rises 40px and fades in once when scrolled into view.
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface NewsletterPanelFooterLink {
label: string;
href: string;
}
export interface NewsletterPanelFooterColumn {
heading: string;
links: NewsletterPanelFooterLink[];
}
export interface NewsletterPanelFooterContact {
icon: "email" | "phone" | "address";
label: string;
href: string;
}
export type NewsletterPanelFooterNetwork = "facebook" | "x" | "instagram" | "linkedin" | "youtube" | "link";
export interface NewsletterPanelFooterSocial {
network: NewsletterPanelFooterNetwork;
href: string;
/** Accessible name, e.g. "Northpass on LinkedIn". */
label: string;
}
export interface NewsletterPanelFooterProps {
variant?: "panel" | "band";
/** The newsletter heading. Unset together with no `newsletter` drops the row. */
heading?: string;
/** The subscribe form. Off by default; `{}` shows it with no `action` (posts to the current URL). */
newsletter?: {
action?: string;
method?: "get" | "post";
/** Field name as submitted. */
name?: string;
label?: string;
placeholder?: string;
buttonLabel?: string;
} | null;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark with an image (use a light logo). */
logo?: { src: string; alt: string; width?: number; height?: number };
blurb?: string;
social?: NewsletterPanelFooterSocial[];
columns?: NewsletterPanelFooterColumn[];
contactHeading?: string;
contacts?: NewsletterPanelFooterContact[];
/** Text of the copyright line. `{year}` is replaced with the build year. Defaults to one naming `brandName`; with neither, no line. */
copyright?: string;
/** Makes the copyright line a link. */
copyrightHref?: string;
year?: number;
/** Scroll-in rise for the top content. */
animate?: boolean;
navLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends NewsletterPanelFooterProps {}
const {
variant = "panel",
heading = "",
newsletter = null,
brandName = "",
brandHref = "/",
logo,
blurb = "",
social = [],
columns = [],
contactHeading = "Contact Us",
contacts = [],
copyright,
copyrightHref,
year = new Date().getFullYear(),
animate = true,
navLabel = "Footer",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
id,
} = Astro.props;
const seed = id ? id : `npf-${Math.random().toString(36).slice(2, 8)}`;
const emailId = `${seed}-email`;
const form = newsletter
? {
method: "post" as const,
name: "email",
label: "Email address",
placeholder: "Email Address",
buttonLabel: "Subscribe",
...newsletter,
}
: null;
const hasTop = Boolean(heading || form);
const hasMark = Boolean(logo || brandName);
const hasBrand = Boolean(hasMark || blurb || social.length > 0);
const hasCols = columns.length > 0 || contacts.length > 0;
const hasPanel = hasBrand || hasCols;
/* No name, no copyright line: never a line naming nobody. */
const copyText = (copyright ?? (brandName ? `Copyright © {year} ${brandName}. All Rights Reserved.` : "")).replace("{year}", String(year));
const hasContent = hasTop || hasPanel || Boolean(copyText);
const isExternal = (href: string) => /^https?:\/\//.test(href);
const CONTACT_ICONS: Record<NewsletterPanelFooterContact["icon"], string> = {
email:
'<path d="M1 4.2A1.7 1.7 0 0 1 2.7 2.5h10.6A1.7 1.7 0 0 1 15 4.2v.3L8 9 1 4.5z" fill="currentColor"/><path d="M1 6.3l6.5 4.2a1 1 0 0 0 1 0L15 6.3v5.5a1.7 1.7 0 0 1-1.7 1.7H2.7A1.7 1.7 0 0 1 1 11.8z" fill="currentColor"/>',
phone:
'<path d="M4.2 1.5 6.3 4a1 1 0 0 1-.1 1.3L5 6.5a8.4 8.4 0 0 0 4.5 4.5l1.2-1.2a1 1 0 0 1 1.3-.1l2.5 2.1a1 1 0 0 1 .1 1.4l-1 1.1c-.7.7-1.7.9-2.6.6A14 14 0 0 1 1.1 4.9c-.3-.9-.1-1.9.6-2.6l1-1a1 1 0 0 1 1.5.2z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M9.6 1.6a5 5 0 0 1 4.8 4.8M9.4 4a2.6 2.6 0 0 1 2.6 2.6" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>',
address:
'<path d="M8 .8a5.6 5.6 0 0 0-5.6 5.6c0 4.1 5 8.5 5.2 8.7a.6.6 0 0 0 .8 0c.2-.2 5.2-4.6 5.2-8.7A5.6 5.6 0 0 0 8 .8zm0 7.9a2.3 2.3 0 1 1 0-4.6 2.3 2.3 0 0 1 0 4.6z" fill="currentColor"/>',
};
const GLYPHS: Record<NewsletterPanelFooterNetwork, string> = {
facebook:
'<path d="M13.6 21v-7.6h2.6l.4-3.1h-3V8.4c0-.9.3-1.5 1.5-1.5h1.6V4.1c-.3 0-1.2-.1-2.3-.1-2.3 0-3.9 1.4-3.9 4v2.3H7.9v3.1h2.6V21z" fill="currentColor"/>',
x: '<path d="M4 4h4.6l11.4 16h-4.6z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M19.4 4l-6.2 7M10.8 13l-6.2 7" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
instagram:
'<rect x="4" y="4" width="16" height="16" rx="4.6" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="3.7" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="16.6" cy="7.4" r="1.1" fill="currentColor"/>',
linkedin:
'<rect x="4.2" y="9.2" width="3.3" height="10.6" fill="currentColor"/><circle cx="5.85" cy="5.9" r="1.95" fill="currentColor"/><path d="M10 9.2h3.2v1.5c.5-.9 1.7-1.8 3.5-1.8 3.4 0 4 2.2 4 5.1v5.8h-3.3v-5.1c0-1.3 0-2.9-1.8-2.9s-2.1 1.4-2.1 2.8v5.2H10z" fill="currentColor"/>',
youtube:
'<rect x="3" y="6" width="18" height="12.5" rx="3.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.4 9.4v5.7l4.9-2.85z" fill="currentColor"/>',
link: '<path d="M10 14l4-4M8.5 11.5 6.6 13.4a3.2 3.2 0 0 0 4.5 4.5l1.9-1.9M15.5 12.5l1.9-1.9a3.2 3.2 0 0 0-4.5-4.5L11 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>',
};
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<footer
class:list={["npf", `npf--${variant}`]}
id={id}
data-npf-root
data-animate={animate ? "" : undefined}
>
{(hasTop || hasPanel) && (
<div class="npf-main">
{
hasTop && (
<div class="npf-top npf-rise" style="--npf-i: 0">
{heading && <h2 class="npf-heading">{heading}</h2>}
{form && (
<form class="npf-form" action={form.action} method={form.method}>
<label class="npf-sr" for={emailId}>{form.label}</label>
<div class="npf-field">
<input
class="npf-input"
id={emailId}
type="email"
name={form.name}
placeholder={form.placeholder}
autocomplete="email"
required
/>
<button class="npf-submit" type="submit">
<span>{form.buttonLabel}</span>
<svg class="npf-plane" viewBox="0 0 22 21" aria-hidden="true" focusable="false">
<path d="M20.6 1.2 1.6 8.6c-.8.3-.8 1.4 0 1.7l6 2.2 2.2 6.3c.3.8 1.4.9 1.8.1L21.9 2.5c.4-.8-.4-1.6-1.3-1.3zM9.1 12.2l7.7-6.9-6 8.1-.3 3.2z" fill="currentColor" />
</svg>
</button>
</div>
</form>
)}
</div>
)
}
{variant === "band" && hasTop && hasPanel && <div class="npf-rule" aria-hidden="true" />}
{hasPanel && (
<div class="npf-panel">
<div class="npf-links npf-rise" style="--npf-i: 1">
{hasBrand && (
<div class="npf-brandblock">
{hasMark && (
<a class="npf-brand" href={brandHref} aria-label={logo ? undefined : `${brandName} home`}>
{
logo ? (
<img
class="npf-logo-img"
src={logo.src}
alt={logo.alt}
width={logo.width ?? 157}
height={logo.height ?? 30}
loading="lazy"
/>
) : (
<>
<svg class="npf-mark" viewBox="0 0 46 30" aria-hidden="true" focusable="false">
<path d="M2 9h11M5 14h9M1 19h13" fill="none" stroke="var(--npf-orange)" stroke-width="2.6" stroke-linecap="round" />
<rect x="14" y="4" width="17" height="15" rx="2" fill="currentColor" />
<path d="M32 8h6.2l5.3 5.6V19H32z" fill="currentColor" />
<path d="M34.4 10.4h3.2l3 3.2h-6.2z" fill="var(--npf-orange)" />
<circle cx="20" cy="21.5" r="3.4" fill="currentColor" stroke="var(--npf-navy)" stroke-width="1.6" />
<circle cx="37.5" cy="21.5" r="3.4" fill="currentColor" stroke="var(--npf-navy)" stroke-width="1.6" />
<path d="M4 27.5h40" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span class="npf-wordmark" aria-hidden="true">{brandName}</span>
</>
)
}
</a>
)}
{blurb && <p class="npf-blurb">{blurb}</p>}
{
social.length > 0 && (
<ul class="npf-social" role="list">
{social.map((s) => (
<li>
<a
class="npf-social-link"
href={s.href}
aria-label={s.label}
target={isExternal(s.href) ? "_blank" : undefined}
rel={isExternal(s.href) ? "noopener noreferrer" : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" set:html={GLYPHS[s.network] ?? GLYPHS.link} />
</a>
</li>
))}
</ul>
)
}
</div>
)}
{hasCols && (
<nav class="npf-cols" aria-label={navLabel}>
{
columns.map((col) => (
<div class="npf-col">
<h3 class="npf-col-heading">{col.heading}</h3>
<span class="npf-underline" aria-hidden="true"><span /><span /></span>
<ul class="npf-list" role="list">
{col.links.map((link) => (
<li>
<a class="npf-link" href={link.href}>
<svg class="npf-bullet" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
<path d="M4.5 5l4 4-4 4M9.5 5l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>{link.label}</span>
</a>
</li>
))}
</ul>
</div>
))
}
{
contacts.length > 0 && (
<div class="npf-col npf-col--contact">
<h3 class="npf-col-heading">{contactHeading}</h3>
<span class="npf-underline" aria-hidden="true"><span /><span /></span>
<ul class="npf-list npf-list--contact" role="list">
{contacts.map((c) => (
<li>
<a class="npf-link" href={c.href}>
<svg class="npf-contact-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false" set:html={CONTACT_ICONS[c.icon]} />
<span>{c.label}</span>
</a>
</li>
))}
</ul>
</div>
)
}
</nav>
)}
</div>
</div>
)}
</div>
)}
{copyText && (
<div class="npf-bottom">
<p class="npf-copy">
{
copyrightHref ? (
<a class="npf-copy-link" href={copyrightHref}>
{copyText}
</a>
) : (
copyText
)
}
</p>
</div>
)}
</footer>
)}
<style>
.npf {
--npf-navy: var(--tg-navy, #020e28);
--npf-orange: var(--tg-orange, #fd5523);
--npf-hover: #0e2d3a;
--npf-soft: #b0b2b8;
--npf-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--npf-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
--npf-dur: 0.4s;
--npf-ease: cubic-bezier(0.44, 0, 0.56, 1);
box-sizing: border-box;
width: 100%;
background: var(--npf-navy);
color: #fff;
font-family: var(--npf-body);
overflow: hidden;
}
.npf *,
.npf *::before,
.npf *::after {
box-sizing: border-box;
}
.npf ul,
.npf p,
.npf h2,
.npf h3 {
margin: 0;
}
.npf ul {
padding: 0;
list-style: none;
}
.npf-main {
display: flex;
flex-direction: column;
gap: 48px;
padding: 60px 20px 40px;
}
/* ---- newsletter row ---- */
.npf-top {
display: flex;
flex-direction: column;
gap: 30px;
width: 100%;
max-width: 1224px;
margin: 0 auto;
}
.npf-heading {
max-width: 420px;
font: 600 32px/1.2 var(--npf-display);
color: #fff;
text-transform: capitalize;
}
.npf-form {
width: 100%;
}
.npf-field {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
}
.npf-input {
width: 100%;
height: 48px;
padding: 0 25px;
border: 1px solid rgb(255 255 255 / 0.18);
border-radius: 50px;
background: rgb(255 255 255 / 0.07);
color: #fff;
font: 400 15px/1 var(--npf-body);
transition:
background-color var(--npf-dur) var(--npf-ease),
border-color var(--npf-dur) var(--npf-ease),
box-shadow var(--npf-dur) var(--npf-ease);
}
.npf-input::placeholder {
color: #d4d6db;
opacity: 1;
}
.npf-input:focus {
outline: none;
border-color: var(--npf-orange);
background: rgb(255 255 255 / 0.1);
box-shadow: 0 0 0 3px rgb(253 85 35 / 0.35);
}
.npf-submit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 48px;
padding: 0 19px 0 25px;
border: 0;
border-radius: 50px;
background: var(--npf-orange);
color: #fff;
font: 600 16px/1 var(--npf-display);
cursor: pointer;
transition: background-color var(--npf-dur) var(--npf-ease);
}
.npf-submit:hover {
background: var(--npf-hover);
}
.npf-plane {
width: 20px;
height: 20px;
flex: none;
}
.npf-rule {
width: 100%;
max-width: 1224px;
height: 1px;
margin: 0 auto;
background: rgb(255 255 255 / 0.2);
}
/* ---- brand block and columns ---- */
.npf-links {
display: flex;
flex-direction: column;
gap: 36px;
width: 100%;
max-width: 1224px;
margin: 0 auto;
}
.npf-brandblock {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
max-width: 320px;
}
.npf-brand {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
color: #fff;
text-decoration: none;
border-radius: 6px;
}
.npf-mark {
width: 46px;
height: 30px;
flex: none;
}
.npf-wordmark {
font: 600 24px/1 var(--npf-display);
letter-spacing: -0.01em;
}
.npf-logo-img {
display: block;
height: 30px;
width: auto;
}
.npf-blurb {
color: var(--npf-soft);
font: 400 16px/21px var(--npf-body);
}
.npf .npf-social {
display: flex;
align-items: center;
gap: 10px;
}
.npf-social-link {
position: relative;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 100px;
background: rgb(255 255 255 / 0.07);
color: #fff;
transition: background-color var(--npf-dur) var(--npf-ease);
}
/* 36px circle as measured, 44px target. */
.npf-social-link::after {
content: "";
position: absolute;
inset: -4px;
border-radius: inherit;
}
.npf-social-link svg {
width: 15px;
height: 15px;
}
.npf-social-link:hover {
background: var(--npf-orange);
}
.npf-cols {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 36px;
}
.npf-col-heading {
font: 700 24px/1 var(--npf-display);
color: #fff;
}
.npf-underline {
display: flex;
gap: 6px;
margin: 18px 0 20px;
}
.npf-underline span {
display: block;
width: 18px;
height: 2px;
background: #fff;
}
.npf-underline span + span {
width: 83px;
background: var(--npf-orange);
}
.npf .npf-list {
display: flex;
flex-direction: column;
gap: 0;
}
.npf-link {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
color: var(--npf-soft);
font: 400 16px/21px var(--npf-body);
text-decoration: none;
border-radius: 4px;
transition: color var(--npf-dur) var(--npf-ease);
}
.npf-link:hover {
color: var(--npf-orange);
}
.npf-bullet {
width: 18px;
height: 18px;
flex: none;
}
.npf-contact-icon {
width: 16px;
height: 16px;
flex: none;
}
.npf-list--contact .npf-link {
overflow-wrap: anywhere;
}
/* ---- the panel ---- */
.npf--panel .npf-panel {
position: relative;
width: 100%;
max-width: 1385px;
margin: 0 auto;
padding: 30px 16px;
border-radius: 20px;
/* A navy fill with a 10% white wash inside a 2px gradient hairline,
as measured: border-box gradient under a padding-box fill. */
border: 2px solid transparent;
background:
linear-gradient(107deg, rgb(255 255 255 / 0.1), rgb(255 255 255 / 0.1)) padding-box,
linear-gradient(var(--npf-navy), var(--npf-navy)) padding-box,
linear-gradient(107deg, rgb(255 255 255 / 0.2), rgb(255 255 255 / 0.5)) border-box;
}
/* ---- copyright ---- */
.npf-bottom {
padding: 0 20px 40px;
text-align: center;
}
.npf-copy {
color: #fff;
font: 400 16px/21px var(--npf-body);
}
.npf-copy-link {
color: inherit;
text-decoration: none;
border-radius: 4px;
transition: color var(--npf-dur) var(--npf-ease);
}
.npf-copy-link:hover {
color: var(--npf-orange);
}
.npf--band .npf-main {
padding-bottom: 60px;
}
.npf--band .npf-bottom {
padding: 30px 20px;
background: var(--npf-orange);
}
/* Orange on orange would vanish: on the band the link turns navy. */
.npf--band .npf-copy-link:hover {
color: var(--npf-navy);
}
/* ---- focus ---- */
.npf a:focus-visible,
.npf button:focus-visible {
outline: 2px solid var(--npf-orange);
outline-offset: 3px;
}
.npf--band .npf-copy-link:focus-visible,
.npf-submit:focus-visible {
outline-color: #fff;
}
.npf-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- appear on scroll ----
The hidden start pose applies only once the script has marked the root
ready, so without JS or under reduced motion everything is at rest. */
.npf-rise {
transition:
opacity 0.8s var(--npf-ease) calc(var(--npf-i, 0) * 0.1s),
transform 0.8s var(--npf-ease) calc(var(--npf-i, 0) * 0.1s);
}
.npf[data-npf-ready]:not([data-in]) .npf-rise {
opacity: 0;
transform: translateY(40px);
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.npf-main {
gap: 64px;
padding: 80px 30px 40px;
}
.npf-top {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 40px;
}
.npf-heading {
font-size: 44px;
line-height: 1.2;
}
.npf-form {
flex: 0 1 443px;
}
.npf-field {
display: block;
}
.npf-input {
height: 56px;
padding-right: 160px;
}
.npf-submit {
position: absolute;
top: 0;
right: 0;
height: 56px;
}
.npf-links {
gap: 56px;
}
.npf-cols {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 30px;
}
.npf--panel .npf-panel {
padding: 60px 40px;
border-radius: 40px;
}
.npf .npf-list {
gap: 2px;
}
}
/* ---- desktop: brand block left, three columns right ---- */
@media (min-width: 1200px) {
.npf-main {
gap: 80px;
padding: 80px 20px 40px;
}
.npf--band .npf-main {
padding-bottom: 88px;
}
.npf-heading {
font-size: 48px;
line-height: 57.6px;
}
.npf-links {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 40px;
}
.npf-brandblock {
flex: 0 0 307px;
gap: 40px;
}
.npf-brandblock .npf-blurb {
margin-top: -10px;
}
.npf-cols {
flex: 0 1 815px;
gap: 0;
}
.npf--panel .npf-panel {
padding: 78px 20px;
border-radius: 50px;
}
.npf-top,
.npf-links {
max-width: 1224px;
}
}
/* Band variant: the columns keep the reference's 80px rhythm, no panel. */
.npf--band .npf-panel {
width: 100%;
}
@media (prefers-reduced-motion: reduce) {
.npf {
--npf-dur: 1ms;
}
.npf-rise {
transition: none;
}
}
@media (forced-colors: active) {
.npf-submit,
.npf-social-link {
border: 1px solid CanvasText;
}
}
</style>
<script>
/*
* Appear-on-scroll for the footer's top content. One IntersectionObserver
* per instance, scoped to its root, torn down on astro:before-swap. It sets
* `data-in` once and stops watching. Under reduced motion, without
* IntersectionObserver, or with animate={false}, nothing ever starts hidden.
*/
function initNewsletterPanelFooter(root: HTMLElement) {
if (root.dataset.npfReady === "") return;
if (!root.hasAttribute("data-animate")) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
return;
}
const controller = new AbortController();
const { signal } = controller;
root.dataset.npfReady = "";
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
}
},
{ threshold: 0.12 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.npfReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-npf-root]").forEach(initNewsletterPanelFooter);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/NewsletterPanelFooter.astro =====
--- SECTIONS ---
- Contact bar overlay header (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/contact-bar-overlay-header/
- Breadcrumb page banner (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/breadcrumb-page-banner/
- Contact info cards (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/contact-info-cards/
- Map contact form (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/map-contact-form/
- Cargo logo marquee (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/cargo-logo-marquee/
- Newsletter panel footer (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/newsletter-panel-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.