The 28 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 — About"
Library page: https://astro.baysixmedia.com/templates/transport-about/
Page file: src/pages/transport-about.astro
Files: 28 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-about.astro =====
---
/**
* Transport site — About — a page template from the Astro Component Library.
* https://astro.baysixmedia.com/templates/transport-about/
*
* 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 ExperienceIntroCards from "../components/ExperienceIntroCards.astro";
import DottedProcessSteps from "../components/DottedProcessSteps.astro";
import CommittedAboutSplit from "../components/CommittedAboutSplit.astro";
import TeamHoverCards from "../components/TeamHoverCards.astro";
import CargoLogoMarquee from "../components/CargoLogoMarquee.astro";
import ProjectPhotoMarquee from "../components/ProjectPhotoMarquee.astro";
import QuoteCardSlider from "../components/QuoteCardSlider.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": "/about"
};
const breadcrumbPageBannerProps = {
"image": {
"src": "/breadcrumb-page-banner/ship-at-sea.webp",
"alt": ""
},
"title": "About Us"
};
const experienceIntroCardsProps = {
"eyebrow": "Our Track Record",
"heading": "Freight Moved On Time, Handled With Care, Priced Up Front.",
"cta": {
"label": "Know More",
"href": "/about"
},
"contact": {
"title": "Call Us For Any Inquiry",
"phone": "+1 (510) 555-0148"
},
"photoCard": {
"image": {
"src": "/experience-intro-cards/truck-sunset.webp",
"alt": "A white semi truck on a motorway at sunset"
},
"label": "Road Freight"
},
"statCard": {
"value": 520,
"suffix": "+",
"caption": "Drivers, dispatchers and brokers who answer the phone themselves"
},
"featureCard": {
"text": "#1 rated regional carrier for on-time delivery",
"image": {
"src": "/experience-intro-cards/mountain-road.webp",
"alt": "A road winding through a forested mountain valley"
}
}
};
const dottedProcessStepsProps = {
"heading": "Three Steps From Quote to Delivery",
"steps": [
{
"title": "Request a Rate",
"text": "Tell us what is moving, from where and by when. You get a clear, itemised rate within the hour.",
"icon": "globe-parcel"
},
{
"title": "Lock In Capacity",
"text": "Confirm the booking and we reserve the truck, box or rail slot on the lane you picked.",
"icon": "truck-plane"
},
{
"title": "Track to the Dock",
"text": "Follow every leg live, and a named coordinator stays on it until the freight is signed for.",
"icon": "globe-truck"
}
]
};
const committedAboutSplitProps = {
"image": {
"src": "/committed-about-split/port-aerial.webp",
"alt": ""
},
"videoTitle": "Northpass Logistics: a day at the port",
"heading": "Freight partners who keep your business moving",
"text": "Since 1998 Northpass has planned, booked and tracked freight for companies that can't afford a late load. Our dispatchers know every lane we run, so your cargo arrives when we said it would.",
"points": [
"Dispatchers who answer the phone",
"Inspected equipment, every trip",
"On-time delivery you can plan around"
],
"cta": {
"label": "About More",
"href": "/about"
},
"mediaSide": "right",
"background": "#ffffff",
"videoUrl": "https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ"
};
const teamHoverCardsProps = {
"heading": "Meet the Team Behind Your Freight",
"members": [
{
"name": "Karim Haddad",
"role": "Chief Executive",
"href": "#",
"image": {
"src": "/team-hover-cards/member-1.webp",
"alt": "Smiling bearded man in a red hard hat and a navy jacket"
},
"socials": [
{
"network": "instagram",
"href": "https://instagram.com"
},
{
"network": "x",
"href": "https://x.com"
},
{
"network": "facebook",
"href": "https://facebook.com"
}
]
},
{
"name": "Luca Moretti",
"role": "Head of Operations",
"href": "#",
"image": {
"src": "/team-hover-cards/member-2.webp",
"alt": "Man in a white hard hat and a work jacket, tipping the brim"
},
"socials": [
{
"network": "instagram",
"href": "https://instagram.com"
},
{
"network": "x",
"href": "https://x.com"
},
{
"network": "facebook",
"href": "https://facebook.com"
}
]
},
{
"name": "Kwame Mensah",
"role": "Fleet Director",
"href": "#",
"image": {
"src": "/team-hover-cards/member-3.webp",
"alt": "Smiling man in a white hard hat, sunglasses and a white shirt"
},
"socials": [
{
"network": "instagram",
"href": "https://instagram.com"
},
{
"network": "x",
"href": "https://x.com"
},
{
"network": "facebook",
"href": "https://facebook.com"
}
]
}
]
};
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": "Over 300 Shippers Trust Northpass With Their Freight"
};
const projectPhotoMarqueeProps = {
"heading": "Recent Lanes We Planned, Moved And Delivered",
"cards": [
{
"title": "Ocean Freight",
"href": "/projects/ocean-freight",
"image": {
"src": "/project-photo-marquee/project-1.webp",
"alt": "Container ship moored under port cranes"
}
},
{
"title": "Air Freight",
"href": "/projects/air-freight",
"image": {
"src": "/project-photo-marquee/project-2.webp",
"alt": "Cargo being loaded onto an aircraft"
}
},
{
"title": "Warehousing",
"href": "/projects/warehousing",
"image": {
"src": "/project-photo-marquee/project-3.webp",
"alt": "Racked pallets in a distribution warehouse"
}
},
{
"title": "Rail Intermodal",
"href": "/projects/rail-intermodal",
"image": {
"src": "/project-photo-marquee/project-4.webp",
"alt": "Freight train carrying stacked containers"
}
},
{
"title": "Linehaul Trucking",
"href": "/projects/linehaul",
"image": {
"src": "/project-photo-marquee/project-5.webp",
"alt": "Semi truck on an open highway"
}
}
]
};
const quoteCardSliderProps = {
"cards": [
{
"quote": "“We moved our whole West Coast distribution to Northpass in one quarter. I expected a rough handover; instead the team mapped every lane with us and hit every pickup window from week one.”",
"name": "Teresa Alvarez",
"role": "Freight Coordinator",
"rating": 4,
"image": {
"src": "/quote-card-slider/client-1.webp",
"alt": ""
}
},
{
"quote": "“Their coordinators answer on the first ring and actually know where the truck is. That alone has saved us hours every week.”",
"name": "Nadia Brooks",
"role": "Logistics Analyst",
"rating": 5,
"image": {
"src": "/quote-card-slider/client-2.webp",
"alt": ""
}
},
{
"quote": "“Rail plus final-mile trucking under one invoice was the pitch. A year in, it's exactly what we got.”",
"name": "Owen Hartley",
"role": "Plant Manager",
"rating": 4,
"image": {
"src": "/quote-card-slider/client-3.webp",
"alt": ""
}
}
]
};
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>About [Business name]</title>
<meta name="description" content="[One-sentence story of the company: when it started, what it moves, who it serves.]" />
<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} />
<!-- Experience intro cards -->
<ExperienceIntroCards {...experienceIntroCardsProps} />
<!-- Dotted process steps -->
<DottedProcessSteps {...dottedProcessStepsProps} />
<!-- Committed about split -->
<CommittedAboutSplit {...committedAboutSplitProps} />
<!-- Team hover cards -->
<TeamHoverCards {...teamHoverCardsProps} />
<!-- Cargo logo marquee -->
<CargoLogoMarquee {...cargoLogoMarqueeProps} />
<!-- Project photo marquee -->
<ProjectPhotoMarquee {...projectPhotoMarqueeProps} />
<!-- Quote card slider -->
<QuoteCardSlider {...quoteCardSliderProps} />
</main>
<!-- Newsletter panel footer -->
<NewsletterPanelFooter {...newsletterPanelFooterProps} />
</body>
</html>
===== END src/pages/transport-about.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/ExperienceIntroCards.astro =====
---
/**
* ExperienceIntroCards
*
* An intro band for an About page. Top row: an orange uppercase eyebrow with
* a short rule on the left; on the right a big navy heading, an orange pill
* ("Know More ↗") and a "call us" block with a phone link. Below, three cards
* of one height: a photo with a frosted label chip, a navy stat card with a
* big number that counts up, and a light card with a line of copy over a
* photo tucked into its bottom-right corner.
*
* Appear on scroll: the title row and the card grid rise 40px and fade in
* (cards staggered 0.1s); the label chip slides in from the left; the number
* counts up once. Without JS or under reduced motion everything is at rest
* and the number shows its final value.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface ExperienceLink {
label: string;
href: string;
}
export interface ExperiencePhoto {
src?: string;
alt?: string;
}
export interface ExperienceIntroCardsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** The orange pill. `null` (the default) drops it. */
cta?: ExperienceLink | null;
/** The "call us" block. `null` (the default) drops it; a phone with no digits and no `href` drops it too. */
contact?: {
title?: string;
phone: string;
/** Defaults to `tel:` plus the digits of `phone`. */
href?: string;
/** A 44px round portrait. Omitted: an orange phone glyph in a tinted disc. */
avatar?: ExperiencePhoto;
} | null;
/** Card 1: a photo with a small frosted label chip. No `image.src`: a neutral placeholder. */
photoCard?: { image?: ExperiencePhoto; label?: string };
/** Card 2: the navy stat card. `value` counts up from 0 on appear. Omitted: no stat card. */
statCard?: { value: number; prefix?: string; suffix?: string; caption?: string } | null;
/**
* Card 3: a line of copy over a photo in the bottom-right corner. The
* line is only ever the caller's: omitted, or a card passed without
* `text`, and the card has no line — never a ranking nobody earned.
*/
featureCard?: { text?: string; image?: ExperiencePhoto };
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ExperienceIntroCardsProps {}
/*
* The copy, the phone, the figure and the photos are the caller's: nothing
* about a business is filled in here. The catalog demo passes its sample
* content through the registry's `props`.
*/
const {
eyebrow = "",
heading = "",
headingLevel = "h2",
cta = null,
contact = null,
photoCard = {},
statCard = null,
featureCard = {},
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;
/* A photo given without alt is decorative. No photo: the neutral placeholder. */
const photo = (given?: ExperiencePhoto) => ({ src: given?.src ?? "", alt: given?.alt ?? "" });
const img1 = photo(photoCard.image);
const img3 = photo(featureCard.image);
const chip = photoCard.label ?? "";
const featureText = featureCard.text ?? "";
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
const trimmed = number.trim();
const digits = trimmed.replace(/\D/g, "");
return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};
/* A call link only with somewhere to dial: words in `phone` are not a number. */
const phoneNumber = typeof contact?.phone === "string" ? contact.phone.trim() : "";
const passedHref = contact?.href ? safeHref(contact.href) : "#";
const callHref = phoneNumber ? (passedHref !== "#" ? passedHref : telHref(phoneNumber)) : "";
const call = contact && callHref ? { ...contact, phone: phoneNumber, href: callHref } : null;
const Heading = headingLevel;
const stat =
statCard && Number.isFinite(statCard.value)
? { ...statCard, value: Math.max(0, Math.round(statCard.value)) }
: null;
const statValue = stat ? stat.value : 0;
const statText = stat ? `${stat.prefix ?? ""}${statValue.toLocaleString("en-US")}${stat.suffix ?? ""}` : "";
/* Nothing to say → nothing rendered: an eyebrow alone is not a section. */
const hasContent = Boolean(heading || cta || call || stat || featureText || chip || img1.src || img3.src);
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section class:list={["eic", className]} id={id} data-eic-root>
<div class="eic-inner">
{(eyebrow || heading || cta || call) && (
<div class="eic-top" data-eic-appear>
{
eyebrow && (
<p class="eic-eyebrow">
<span>{eyebrow}</span>
<span class="eic-rule" aria-hidden="true" />
</p>
)
}
{(heading || cta || call) && (
<div class="eic-intro">
{heading && <Heading class="eic-heading">{heading}</Heading>}
{
(cta || call) && (
<div class="eic-actions">
{cta && (
<a class="eic-btn" href={safeHref(cta.href)}>
{cta.label}
<svg class="eic-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
<path d="M2 10 10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
)}
{call && (
<div class="eic-call">
{call.avatar?.src ? (
<img class="eic-avatar" src={call.avatar.src} alt={call.avatar.alt ?? ""} width="44" height="44" loading="lazy" decoding="async" />
) : (
<span class="eic-avatar eic-avatar--icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M6.6 10.8a15.2 15.2 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11.4 11.4 0 0 0 3.6.57 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1z" />
</svg>
</span>
)}
<div class="eic-call-text">
{call.title && <p class="eic-call-title">{call.title}</p>}
<p class="eic-call-line">
<a class="eic-phone" href={call.href}>{call.phone}</a>
</p>
</div>
</div>
)}
</div>
)
}
</div>
)}
</div>
)}
<div class="eic-grid">
<div class="eic-card eic-card--photo" data-eic-appear style="--eic-i: 0">
{
img1.src ? (
<img class="eic-cover" src={img1.src} alt={img1.alt} width="796" height="652" loading="lazy" decoding="async" />
) : (
<div class="eic-ph" role="img" aria-label="Image placeholder: landscape photo">
<div class="eic-ph-text" aria-hidden="true">
<span class="eic-ph-label">Image</span>
<span class="eic-ph-hint">Landscape photo</span>
<span class="eic-ph-spec">~1.22:1 · 796 × 652 px</span>
</div>
</div>
)
}
{chip && <p class="eic-chip">{chip}</p>}
</div>
{stat && (
<div class="eic-card eic-card--stat" data-eic-appear style="--eic-i: 1">
<svg class="eic-waves" viewBox="0 0 400 330" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<g fill="none" stroke="currentColor" stroke-width="1">
<path d="M120 -10C210 60 250 140 410 170" />
<path d="M140 -10C225 65 265 150 410 190" />
<path d="M160 -10C240 70 280 160 410 210" />
<path d="M180 -10C255 75 295 170 410 230" />
<path d="M200 -10C270 80 310 180 410 250" />
<path d="M220 -10C285 85 325 190 410 270" />
<path d="M240 -10C300 90 340 200 410 290" />
<path d="M260 -10C315 95 355 210 410 310" />
</g>
</svg>
<p class="eic-stat">
<span aria-hidden="true">
{stat.prefix}<span data-eic-count data-target={statValue}>{statValue.toLocaleString("en-US")}</span>{stat.suffix}
</span>
<span class="eic-sr">{statText}</span>
</p>
<div class="eic-stat-foot">
<span class="eic-stat-rule" aria-hidden="true" />
{stat.caption && <p class="eic-stat-caption">{stat.caption}</p>}
</div>
</div>
)}
<div class="eic-card eic-card--feature" data-eic-appear style="--eic-i: 2">
{featureText && <p class="eic-feature-text">{featureText}</p>}
<div class="eic-feature-media">
{
img3.src ? (
<img class="eic-cover" src={img3.src} alt={img3.alt} width="740" height="400" loading="lazy" decoding="async" />
) : (
<div class="eic-ph" role="img" aria-label="Image placeholder: wide landscape photo">
<div class="eic-ph-text" aria-hidden="true">
<span class="eic-ph-label">Image</span>
<span class="eic-ph-hint">Wide landscape photo</span>
<span class="eic-ph-spec">~1.85:1 · 740 × 400 px</span>
</div>
</div>
)
}
</div>
</div>
</div>
</div>
</section>
)}
<style>
.eic {
--eic-navy: var(--tg-navy, #020e28);
--eic-orange: var(--tg-orange, #fd5523);
--eic-muted: var(--tg-muted, #61697d);
--eic-surface: var(--tg-surface, #ecf0f1);
--eic-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--eic-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--eic-ease: cubic-bezier(0.22, 1, 0.36, 1);
--eic-link: 0.4s cubic-bezier(0.44, 0, 0.56, 1);
box-sizing: border-box;
padding: 60px 0;
background: #fff;
color: var(--eic-navy);
font-family: var(--eic-body);
}
.eic *,
.eic *::before,
.eic *::after {
box-sizing: border-box;
}
.eic p {
margin: 0;
}
.eic-inner {
display: flex;
flex-direction: column;
gap: 40px;
max-width: 1293px;
margin: 0 auto;
padding: 0 20px;
}
/* ---- title row ---- */
.eic-top {
display: grid;
gap: 16px;
}
.eic .eic-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
font: 600 16px/1 var(--eic-display);
text-transform: uppercase;
color: var(--eic-orange);
}
.eic-rule {
display: block;
width: 40px;
height: 1px;
background: currentColor;
}
.eic-intro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 28px;
min-width: 0;
}
.eic-heading {
margin: 0;
font: 600 32px/1.2 var(--eic-display);
color: var(--eic-navy);
overflow-wrap: anywhere;
}
.eic-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.eic-btn {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 18px 26px;
border-radius: 100px;
background: var(--eic-orange);
color: #fff;
font: 600 14px/1 var(--eic-body);
text-decoration: none;
transition: background-color 0.35s ease-out;
}
.eic-btn-arrow {
flex: none;
transition: transform 0.35s ease-out;
}
.eic-btn:hover {
background: var(--eic-navy);
}
.eic-btn:hover .eic-btn-arrow {
transform: rotate(45deg);
}
.eic-btn:focus-visible {
outline: 2px solid var(--eic-navy);
outline-offset: 3px;
}
.eic-call {
display: flex;
align-items: center;
gap: 16px;
}
.eic-avatar {
flex: none;
display: block;
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
}
.eic-avatar--icon {
display: grid;
place-items: center;
background: rgb(253 85 35 / 0.1);
color: var(--eic-orange);
}
.eic-call-text {
display: flex;
flex-direction: column;
gap: 8px;
}
.eic .eic-call-title,
.eic .eic-call-line {
font: 600 16px/1.2 var(--eic-display);
color: var(--eic-navy);
}
.eic-phone {
display: inline-block;
padding: 4px 0;
margin: -4px 0;
color: var(--eic-orange);
text-decoration: none;
transition: color var(--eic-link);
}
.eic-phone:hover {
color: var(--eic-navy);
}
.eic-phone:focus-visible {
outline: 2px solid var(--eic-orange);
outline-offset: 2px;
border-radius: 2px;
}
/* ---- cards ---- */
.eic-grid {
display: grid;
gap: 24px;
}
.eic-card {
position: relative;
min-width: 0;
border-radius: 30px;
overflow: hidden;
}
.eic-card--photo {
aspect-ratio: 398 / 326;
}
.eic-cover {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.eic .eic-chip {
position: absolute;
left: 24px;
bottom: 24px;
padding: 12px 18px;
border-radius: 18px;
background: rgb(255 255 255 / 0.25);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font: 600 16px/1 var(--eic-display);
color: #fff;
}
.eic-card--stat {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 40px;
padding: 24px;
background: var(--eic-navy);
color: #fff;
}
.eic-waves {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
color: rgb(255 255 255 / 0.07);
pointer-events: none;
}
.eic .eic-stat {
position: relative;
font: 600 64px/1.2 var(--eic-display);
font-variant-numeric: tabular-nums;
color: #fff;
}
.eic-stat-foot {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
}
.eic-stat-rule {
display: block;
width: 100%;
height: 3px;
background: rgb(255 255 255 / 0.2);
}
.eic .eic-stat-caption {
max-width: 340px;
font: 400 16px/21px var(--eic-body);
color: #fff;
}
.eic-card--feature {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 28px;
background: var(--eic-surface);
}
.eic .eic-feature-text {
padding: 24px 24px 0;
font: 600 22px/1.42 var(--eic-display);
color: var(--eic-navy);
}
.eic-feature-media {
position: relative;
height: 180px;
margin-left: 24px;
border-radius: 30px 0 0 0;
overflow: hidden;
}
.eic-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- placeholder for src: "" ---- */
.eic-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);
}
.eic-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.eic-ph-label {
font: 500 12px/1 var(--eic-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.eic-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--eic-body);
}
.eic-ph-spec {
font: 400 12px/1.2 var(--eic-body);
}
/* ---- appear: hidden start only once the script marks the root ready ---- */
[data-eic-appear] {
transition:
opacity 0.8s var(--eic-ease),
transform 0.8s var(--eic-ease);
transition-delay: calc(var(--eic-i, 0) * 0.1s);
}
.eic-chip {
transition:
opacity 0.7s var(--eic-ease) 0.35s,
transform 0.7s var(--eic-ease) 0.35s;
}
.eic[data-eic-ready] [data-eic-appear]:not([data-in]) {
opacity: 0;
transform: translateY(40px);
}
.eic[data-eic-ready] [data-eic-appear]:not([data-in]) .eic-chip {
opacity: 0;
transform: translateX(-30px);
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.eic {
padding: 90px 0;
}
.eic-inner {
gap: 50px;
}
.eic-heading {
font-size: 40px;
}
.eic-actions {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 35px;
}
.eic-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 30px;
}
.eic-card--photo {
aspect-ratio: auto;
min-height: 326px;
}
.eic-card--feature {
grid-column: 1 / -1;
min-height: 326px;
}
.eic-card--stat {
padding: 30px;
}
.eic .eic-stat {
font-size: 74px;
}
.eic .eic-feature-text {
padding: 30px 30px 0;
font-size: 24px;
line-height: 34px;
}
.eic-feature-media {
height: 200px;
margin-left: 28px;
}
.eic .eic-chip {
left: 31px;
bottom: 31px;
}
}
/* ---- desktop: eyebrow left, heading column right, three cards ---- */
@media (min-width: 1200px) {
.eic {
padding: 120px 0;
}
.eic-inner {
gap: 56px;
}
.eic-top {
grid-template-columns: minmax(0, 419fr) minmax(0, 834fr);
align-items: start;
gap: 0;
}
.eic .eic-eyebrow {
font-size: 18px;
}
.eic-intro {
gap: 40px;
}
.eic-heading {
font-size: 48px;
line-height: 57.6px;
}
.eic-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 326px;
}
.eic-card--feature {
grid-column: auto;
}
.eic .eic-stat-caption {
max-width: 338px;
}
}
@media (prefers-reduced-motion: reduce) {
.eic-btn:hover .eic-btn-arrow {
transform: none;
}
[data-eic-appear],
.eic-chip {
transition: none;
}
}
@media (forced-colors: active) {
.eic-card {
border: 1px solid CanvasText;
}
.eic-btn,
.eic .eic-chip {
border: 1px solid ButtonText;
}
.eic-stat-rule,
.eic-rule {
background: CanvasText;
}
}
</style>
<script>
/*
* Appear on scroll plus the stat count-up. One IntersectionObserver per
* instance, watching the title row and the three cards; each gets
* `data-in` once and is unobserved. The count-up runs once when the stat
* card arrives. Every observer and frame is released on astro:before-swap.
* Under reduced motion (or without IntersectionObserver) nothing is ever
* hidden and the number simply shows its final value.
*/
function initExperienceIntroCards(root: HTMLElement) {
if (root.dataset.eicReady !== undefined) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!("IntersectionObserver" in window)) return;
const controller = new AbortController();
const { signal } = controller;
let frame = 0;
const counter = root.querySelector<HTMLElement>("[data-eic-count]");
const target = Number(counter?.dataset.target ?? 0);
const format = (n: number) => Math.round(n).toLocaleString("en-US");
const countUp = () => {
if (!counter || !Number.isFinite(target) || target <= 0) return;
const start = performance.now();
const duration = 2200;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
counter.textContent = format(target * (1 - Math.pow(1 - t, 3)));
if (t < 1 && !signal.aborted) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
};
root.dataset.eicReady = "";
if (counter) counter.textContent = "0";
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target as HTMLElement;
el.dataset.in = "";
observer.unobserve(el);
if (counter && el.contains(counter)) countUp();
}
},
{ threshold: 0.15 },
);
root.querySelectorAll<HTMLElement>("[data-eic-appear]").forEach((el) => observer.observe(el));
signal.addEventListener("abort", () => {
observer.disconnect();
cancelAnimationFrame(frame);
if (counter) counter.textContent = format(target);
});
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.eicReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-eic-root]").forEach(initExperienceIntroCards);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ExperienceIntroCards.astro =====
===== FILE (required): public/experience-intro-cards/truck-sunset.webp =====
[binary asset — download https://astro.baysixmedia.com/experience-intro-cards/truck-sunset.webp and save it to public/experience-intro-cards/truck-sunset.webp]
===== FILE (required): public/experience-intro-cards/mountain-road.webp =====
[binary asset — download https://astro.baysixmedia.com/experience-intro-cards/mountain-road.webp and save it to public/experience-intro-cards/mountain-road.webp]
===== FILE (required): src/components/DottedProcessSteps.astro =====
---
/**
* DottedProcessSteps
*
* A light surface section: a centred orange eyebrow between two short lines,
* a big navy heading, and three steps in a row. Each step is a round pale
* medallion holding a line icon, hugged on its left by an orange half ring,
* with a small orange number badge on top; then a title and a short text.
* Dotted curved arrows join the steps (the first dips, the second arches).
*
* Appear on scroll: the title block and each step rise 40px and fade in,
* staggered; the number badges slide in from -20px; the connectors draw in
* left to right. The hidden start pose applies only after the script marks
* the root ready, so no-JS and reduced motion render at rest.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export type DottedProcessIcon = "globe-parcel" | "truck-plane" | "globe-truck" | "clipboard" | "warehouse";
export interface DottedProcessStep {
title: string;
text?: string;
/** Built-in line icon. Defaults cycle through the first three. */
icon?: DottedProcessIcon;
/** Badge text. Defaults to the 1-based index, zero padded ("01"). */
number?: string;
}
export interface DottedProcessStepsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** Level of each step title. Defaults to one below the heading. */
stepHeadingLevel?: "h3" | "h4";
steps?: DottedProcessStep[];
/** The dotted arrows between steps (desktop and tablet only). */
connectors?: boolean;
/** The section surface. */
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends DottedProcessStepsProps {}
/* No demo content: steps and heading are props. The generic eyebrow stays,
but it never makes the section render on its own. */
const {
eyebrow = "How It Works",
heading = "",
headingLevel = "h2",
stepHeadingLevel,
steps = [],
connectors = true,
background,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
const StepHeading = stepHeadingLevel ?? (headingLevel === "h2" ? "h3" : "h4");
const CYCLE: DottedProcessIcon[] = ["globe-parcel", "truck-plane", "globe-truck"];
/* A step with no title is dropped; with none left, nothing renders. */
const items = (steps ?? []).filter((step) => step?.title).map((step, i) => ({
...step,
icon: step.icon ?? CYCLE[i % CYCLE.length],
number: step.number ?? String(i + 1).padStart(2, "0"),
}));
/* Connectors sit on the column boundaries of a three-up row; with any other
count the geometry does not hold, so they are drawn only for three. */
const showConnectors = connectors && items.length === 3;
---
{items.length > 0 && (
<>{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
class:list={["dps", className]}
id={id}
style={background ? `--dps-bg: ${background};` : undefined}
data-dps-root
>
<div class="dps-inner">
<div class="dps-title" data-dps-rise>
{
eyebrow && (
<p class="dps-eyebrow">
<span class="dps-eyebrow-line" aria-hidden="true" />
{eyebrow}
<span class="dps-eyebrow-line" aria-hidden="true" />
</p>
)
}
{heading && <Heading class="dps-heading">{heading}</Heading>}
</div>
<div class="dps-row">
<ol class="dps-steps" role="list">
{
items.map((step, i) => (
<li class="dps-step" data-dps-rise style={`--dps-i: ${i};`}>
<div class="dps-medal" aria-hidden="true">
<span class="dps-ring" />
<span class="dps-disc">
{step.icon === "globe-parcel" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="24" cy="30" r="18" />
<ellipse cx="24" cy="30" rx="7.5" ry="18" />
<path d="M7.5 23h33M7.5 37h24" />
<rect x="33" y="31" width="19" height="16" rx="1.5" fill="#fff9f9" />
<path d="M33 36.5h19M42.5 31v5.5" />
<path d="M36 22h14m-3.5-3.5L50 22l-3.5 3.5" />
</svg>
)}
{step.icon === "truck-plane" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<g transform="translate(38 17) rotate(-28)">
<path d="M-14 0H11" />
<path d="M2 0-6-10h-3.5L-6 0l-3.5 10H-6z" />
<path d="M-11 0-14-5h-2.5l1.5 5-1.5 5h2.5z" />
</g>
<rect x="7" y="31" width="29" height="16" rx="1.5" />
<path d="M16 31v16M26 31v16" />
<path d="M36 35h9l6 7v5H36z" />
<path d="M40 39h5" />
<circle cx="15" cy="49.5" r="3.5" fill="#fff9f9" />
<circle cx="44" cy="49.5" r="3.5" fill="#fff9f9" />
</svg>
)}
{step.icon === "globe-truck" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6v9m-3.5-3.5L20 15l3.5-3.5" />
<circle cx="20" cy="33" r="14" />
<ellipse cx="20" cy="33" rx="6" ry="14" />
<path d="M6.5 28h27M6.5 38h20" />
<rect x="27" y="35" width="15" height="12" rx="1.5" fill="#fff9f9" />
<path d="M42 38h6l5 5v4H42z" fill="#fff9f9" />
<circle cx="32" cy="49" r="3" fill="#fff9f9" />
<circle cx="47" cy="49" r="3" fill="#fff9f9" />
<g transform="translate(45 16) rotate(-28) scale(0.8)">
<path d="M-14 0H11" />
<path d="M2 0-6-10h-3.5L-6 0l-3.5 10H-6z" />
<path d="M-11 0-14-5h-2.5l1.5 5-1.5 5h2.5z" />
</g>
</svg>
)}
{step.icon === "clipboard" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="13" y="10" width="34" height="42" rx="3" />
<path d="M23 10V7h14v3M23 12h14" />
<path d="M20 24l3 3 5-6M32 25h9M20 36l3 3 5-6M32 37h9" />
</svg>
)}
{step.icon === "warehouse" && (
<svg viewBox="0 0 60 60" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 24 30 10l24 14v26H6z" />
<path d="M15 50V30h30v20M15 36h30M15 43h30" />
<rect x="21" y="44" width="8" height="6" />
<rect x="31" y="44" width="8" height="6" />
</svg>
)}
</span>
<span class="dps-num" data-dps-num>{step.number}</span>
</div>
<div class="dps-copy">
<StepHeading class="dps-step-title">
<span class="dps-sr">Step {i + 1}: </span>
{step.title}
</StepHeading>
{step.text && <p class="dps-step-text">{step.text}</p>}
</div>
</li>
))
}
</ol>
{
showConnectors && (
<div class="dps-links" aria-hidden="true">
<svg class="dps-link dps-link--1" viewBox="0 0 220 43" data-dps-link>
<path class="dps-dots" d="M3 7Q100 52 199 12" />
<path class="dps-head" d="M213.1 7 202.5 18.5 203.7 10.6 197.5 5.5Z" />
</svg>
<svg class="dps-link dps-link--2" viewBox="0 0 220 43" data-dps-link>
<path class="dps-dots" d="M3 30Q105-10 199 27.6" />
<path class="dps-head" d="M213 33.2 197.4 34.5 203.7 29.5 202.6 21.5Z" />
</svg>
</div>
)
}
</div>
</div>
</section></>
)}
<style>
.dps {
--dps-navy: var(--tg-navy, #020e28);
--dps-orange: var(--tg-orange, #fd5523);
--dps-muted: var(--tg-muted, #61697d);
--dps-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--dps-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--dps-ease: cubic-bezier(0.22, 1, 0.36, 1);
box-sizing: border-box;
padding: 80px 0;
background: var(--dps-bg, var(--tg-surface, #ecf0f1));
color: var(--dps-navy);
font-family: var(--dps-body);
overflow-x: clip;
}
.dps *,
.dps *::before,
.dps *::after {
box-sizing: border-box;
}
.dps-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 48px;
max-width: 1264px;
margin: 0 auto;
padding: 0 20px;
}
.dps-title {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
text-align: center;
}
.dps .dps-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--dps-display);
text-transform: uppercase;
color: var(--dps-orange);
}
.dps-eyebrow-line {
display: block;
width: 40px;
height: 1px;
background: currentColor;
}
.dps .dps-heading {
margin: 0;
font: 600 32px/1.2 var(--dps-display);
letter-spacing: normal;
color: var(--dps-navy);
overflow-wrap: break-word;
}
.dps-row {
position: relative;
width: 100%;
}
.dps .dps-steps {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 48px;
margin: 0;
padding: 0;
list-style: none;
}
.dps-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
min-width: 0;
text-align: center;
}
/* ---- medallion: 150 × 166, the measured box ---- */
.dps-medal {
position: relative;
flex: none;
width: 150px;
height: 166px;
}
.dps-ring {
position: absolute;
top: 16px;
left: 0;
width: 75px;
height: 149px;
border-radius: 75px 0 0 75px / 74.5px 0 0 74.5px;
background: var(--dps-orange);
}
.dps-disc {
position: absolute;
top: 26px;
left: 10px;
display: grid;
place-items: center;
width: 130px;
height: 130px;
border-radius: 50%;
background: #fff9f9;
color: var(--dps-navy);
}
.dps-disc svg {
display: block;
width: 60px;
height: 60px;
}
.dps-num {
position: absolute;
top: 0;
left: 50%;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin-left: -20px;
border-radius: 50%;
background: var(--dps-orange);
font: 500 16px/26px var(--dps-display);
color: #fff;
}
.dps-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-width: 329px;
}
.dps .dps-step-title {
margin: 0;
font: 600 24px/1.33 var(--dps-display);
color: var(--dps-navy);
}
.dps .dps-step-text {
margin: 0;
font: 400 16px/21.04px var(--dps-body);
color: var(--dps-muted);
}
.dps-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- connectors: hidden on a phone, where the steps stack ---- */
.dps-links {
display: none;
}
.dps-link {
position: absolute;
top: 83px;
width: clamp(96px, 15.3vw, 220px);
height: auto;
aspect-ratio: 220 / 43;
overflow: visible;
transform: translate(-50%, -50%);
clip-path: inset(0 0 0 0);
}
.dps-link--1 {
left: 32.1%;
}
.dps-link--2 {
left: 67.9%;
}
.dps-dots {
fill: none;
stroke: var(--dps-navy);
stroke-width: 2.6;
stroke-linecap: round;
stroke-dasharray: 0.1 6.4;
}
.dps-head {
fill: var(--dps-navy);
}
/* ---- appear on scroll ---- */
.dps-title,
.dps-step {
transition:
opacity 0.8s var(--dps-ease),
transform 0.8s var(--dps-ease);
}
.dps-step {
transition-delay: calc(0.15s + var(--dps-i, 0) * 0.1s);
}
.dps-num {
transition: transform 0.8s var(--dps-ease) calc(0.35s + var(--dps-i, 0) * 0.1s);
}
.dps-link {
transition: clip-path 1s var(--dps-ease) 0.6s;
}
.dps-link--2 {
transition-delay: 0.8s;
}
.dps[data-dps-ready]:not([data-in]) .dps-title,
.dps[data-dps-ready]:not([data-in]) .dps-step {
opacity: 0;
transform: translateY(40px);
}
.dps[data-dps-ready]:not([data-in]) .dps-num {
transform: translateX(-20px);
}
.dps[data-dps-ready]:not([data-in]) .dps-link {
clip-path: inset(0 100% 0 0);
}
/* ---- tablet and up: the three-up row with connectors ---- */
@media (min-width: 810px) {
.dps {
padding: 100px 0;
}
.dps-inner {
gap: 60px;
}
.dps .dps-heading {
font-size: 40px;
}
.dps .dps-steps {
grid-template-columns: repeat(3, minmax(0, 329px));
justify-content: space-between;
column-gap: 24px;
}
.dps-links {
display: block;
}
.dps-step {
gap: 40px;
}
.dps-copy {
gap: 20px;
}
}
@media (min-width: 1200px) {
.dps {
padding: 120px 0;
}
.dps .dps-eyebrow {
font-size: 18px;
}
.dps .dps-heading {
font-size: 48px;
line-height: 57.6px;
}
.dps .dps-step-title {
font-size: 30px;
line-height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.dps-title,
.dps-step,
.dps-num,
.dps-link {
transition: none;
}
.dps[data-dps-ready]:not([data-in]) .dps-title,
.dps[data-dps-ready]:not([data-in]) .dps-step {
opacity: 1;
transform: none;
}
.dps[data-dps-ready]:not([data-in]) .dps-num {
transform: none;
}
.dps[data-dps-ready]:not([data-in]) .dps-link {
clip-path: inset(0 0 0 0);
}
}
@media (forced-colors: active) {
.dps-ring,
.dps-num {
forced-color-adjust: none;
}
.dps-disc {
border: 1px solid CanvasText;
}
}
</style>
<script>
/*
* Appear-on-scroll. One IntersectionObserver per instance, scoped to its
* root, torn down on astro:before-swap. It sets `data-in` once and stops
* watching. Under reduced motion it marks the root in straight away.
*/
function initDottedProcessSteps(root: HTMLElement) {
if (root.dataset.dpsReady === "" || root.dataset.in === "") return;
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
return;
}
root.dataset.dpsReady = "";
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.dpsReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-dps-root]").forEach(initDottedProcessSteps);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/DottedProcessSteps.astro =====
===== FILE (required): src/components/CommittedAboutSplit.astro =====
---
/**
* CommittedAboutSplit
*
* A two-column about section: a rounded photo on one side, and on the other an
* orange eyebrow with a short rule, a heading, a paragraph, three "»" points
* and an orange pill link. `mediaSide` puts the photo left or right.
*
* Give it a `videoUrl` and the photo becomes a video thumbnail: a navy wash,
* a round play button in the middle, and a modal <dialog> that plays the
* video (a YouTube/Vimeo embed in an iframe, or an .mp4/.webm file in a
* <video>). Without JavaScript the play button is a plain link to the video.
*
* On scroll the photo slides in from its outer side (40px) and the copy from
* the other side.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface CommittedAboutSplitLink {
label: string;
href: string;
}
export interface CommittedAboutSplitProps {
/** Which side the photo sits on at 810px and up. On a phone the DOM order holds. */
mediaSide?: "left" | "right";
/** The photo, or the video thumbnail. No `src` renders the labelled placeholder. */
image?: { src?: string; alt?: string };
/** Turns the photo into a video thumbnail with a play button and a modal player. */
videoUrl?: string;
/** Accessible name for the play button and the player. Omitted: "Play video" / "Video". */
videoTitle?: string;
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
text?: string;
/** The "»" points. `[]` drops the list. */
points?: string[];
/** The orange pill. `null` (the default) drops it. */
cta?: CommittedAboutSplitLink | null;
/** Section surface. The home variant is #ecf0f1, the about variant white. */
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends CommittedAboutSplitProps {}
/*
* The heading, copy, points, button, photo and video title are the caller's:
* nothing about a business is filled in here. "About us" is a generic label
* and never renders the section on its own. The catalog demo passes its
* sample content through the registry's `props`.
*/
const {
mediaSide = "left",
image: imageProp,
videoUrl,
videoTitle = "",
eyebrow = "About us",
heading = "",
headingLevel = "h2",
text = "",
points = [],
cta = null,
background = "#ecf0f1",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const hasVideo = Boolean(videoUrl && videoUrl.trim());
/* A photo given without alt is decorative. No photo: the neutral placeholder. */
const image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const pointList = (points ?? []).filter((p) => typeof p === "string" && p.trim());
const title = videoTitle.trim();
const playLabel = title ? `Play video: ${title}` : "Play video";
/* Copy worth a column; the generic eyebrow alone is not. */
const hasCopy = Boolean(heading || text || pointList.length > 0 || cta);
/* Nothing to say → nothing rendered. */
const hasContent = hasCopy || hasVideo;
const Heading = headingLevel;
const uid = id ?? `cas-${Math.random().toString(36).slice(2, 8)}`;
const dialogId = `${uid}-player`;
const titleId = `${uid}-player-title`;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const video = hasVideo ? safeHref(videoUrl!) : "";
const isFile = /\.(mp4|webm|ogg)(\?|#|$)/i.test(video);
const IMAGE_SPEC = hasVideo ? "1:1 · 1128 × 1124 px" : "~0.95:1 · 1152 × 1214 px";
const IMAGE_HINT = hasVideo ? "Video thumbnail, square" : "Photo, near-square portrait";
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section
class:list={["cas", className]}
id={id}
style={`--cas-bg: ${background};`}
data-cas-root
data-media-side={mediaSide}
data-video={hasVideo ? "" : undefined}
>
<div class="cas-inner">
{
mediaSide === "right" && hasCopy && (
<div class="cas-copy" data-cas-copy>
{eyebrow && (
<p class="cas-eyebrow">
{eyebrow}
<span class="cas-rule" aria-hidden="true" />
</p>
)}
{heading && <Heading class="cas-heading">{heading}</Heading>}
{text && <p class="cas-text">{text}</p>}
{pointList.length > 0 && (
<ul class="cas-points" role="list">
{pointList.map((point) => (
<li class="cas-point">
<svg class="cas-chev" viewBox="0 0 18 14" width="18" height="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" />
</svg>
<span>{point}</span>
</li>
))}
</ul>
)}
{cta && (
<a class="cas-btn" href={safeHref(cta.href)}>
{cta.label}
<svg class="cas-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path d="M2 10 10 2M3.5 2H10v6.5" />
</svg>
</a>
)}
</div>
)
}
<div class="cas-media" data-cas-media>
{
image.src ? (
<img
src={image.src}
alt={image.alt}
width={hasVideo ? 1128 : 1152}
height={hasVideo ? 1124 : 1214}
loading="lazy"
decoding="async"
/>
) : (
<div class="cas-ph" role="img" aria-label={`Image placeholder: ${IMAGE_HINT}`}>
<div class="cas-ph-text" aria-hidden="true">
<span class="cas-ph-label">Image</span>
<span class="cas-ph-hint">{IMAGE_HINT}</span>
<span class="cas-ph-spec">{IMAGE_SPEC}</span>
</div>
</div>
)
}
{
hasVideo && (
<>
<span class="cas-wash" aria-hidden="true" />
<a
class="cas-play"
href={video}
target="_blank"
rel="noopener"
aria-label={playLabel}
aria-haspopup="dialog"
data-cas-play
>
<span class="cas-play-halo" aria-hidden="true" />
<span class="cas-play-disc" aria-hidden="true">
<svg viewBox="0 0 12 14" width="12" height="14" focusable="false">
<path d="M1 1.2v11.6a.6.6 0 0 0 .9.5l9.6-5.8a.6.6 0 0 0 0-1L1.9.7a.6.6 0 0 0-.9.5Z" />
</svg>
</span>
</a>
</>
)
}
</div>
{
mediaSide !== "right" && hasCopy && (
<div class="cas-copy" data-cas-copy>
{eyebrow && (
<p class="cas-eyebrow">
{eyebrow}
<span class="cas-rule" aria-hidden="true" />
</p>
)}
{heading && <Heading class="cas-heading">{heading}</Heading>}
{text && <p class="cas-text">{text}</p>}
{pointList.length > 0 && (
<ul class="cas-points" role="list">
{pointList.map((point) => (
<li class="cas-point">
<svg class="cas-chev" viewBox="0 0 18 14" width="18" height="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" />
</svg>
<span>{point}</span>
</li>
))}
</ul>
)}
{cta && (
<a class="cas-btn" href={safeHref(cta.href)}>
{cta.label}
<svg class="cas-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path d="M2 10 10 2M3.5 2H10v6.5" />
</svg>
</a>
)}
</div>
)
}
</div>
{
hasVideo && (
<dialog class="cas-dialog" id={dialogId} aria-labelledby={titleId} data-cas-dialog data-src={video} data-kind={isFile ? "file" : "embed"}>
<div class="cas-dialog-bar">
<p class="cas-dialog-title" id={titleId}>{title || "Video"}</p>
<button type="button" class="cas-close" data-cas-close aria-label="Close video">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
<div class="cas-frame" data-cas-frame />
</dialog>
)
}
</section>
)}
<style>
.cas {
--cas-navy: var(--tg-navy, #020e28);
--cas-orange: var(--tg-orange, #fd5523);
--cas-muted: var(--tg-muted, #61697d);
--cas-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--cas-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--cas-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
--cas-radius: 30px;
box-sizing: border-box;
padding: 80px 20px;
background: var(--cas-bg, #ecf0f1);
color: var(--cas-navy);
overflow-x: clip;
}
.cas[data-video] {
--cas-radius: 20px;
}
.cas *,
.cas *::before,
.cas *::after {
box-sizing: border-box;
}
.cas-inner {
display: flex;
flex-direction: column;
gap: 30px;
max-width: 1253px;
margin: 0 auto;
}
/* ---- media ---- */
.cas-media {
position: relative;
width: 100%;
aspect-ratio: 350 / 278;
border-radius: var(--cas-radius);
overflow: hidden;
background: #dfe5e7;
}
.cas[data-video] .cas-media {
aspect-ratio: 1;
}
.cas-media img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cas-wash {
position: absolute;
inset: 0;
background: rgb(2 14 40 / 0.5);
}
.cas-play {
position: absolute;
top: 50%;
left: 50%;
display: grid;
place-items: center;
width: 64px;
height: 64px;
margin: -32px 0 0 -32px;
border-radius: 50%;
cursor: pointer;
}
.cas-play-halo {
position: absolute;
inset: 0;
border-radius: 50%;
background: #fff;
opacity: 0.3;
}
.cas-play-disc {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding-left: 2px;
border-radius: 50%;
background: #fff;
transition: transform 0.3s var(--cas-ease);
}
.cas-play-disc svg {
fill: var(--cas-orange);
}
.cas-play:hover .cas-play-disc {
transform: scale(1.05);
}
.cas-play:focus-visible {
outline: 3px solid #fff;
outline-offset: 4px;
}
.cas-ph {
display: grid;
place-items: center;
width: 100%;
height: 100%;
padding: 16px;
text-align: center;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgb(2 14 40 / 0.62);
}
.cas-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cas-ph-label {
font: 600 12px/1 var(--cas-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cas-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--cas-body);
}
.cas-ph-spec {
font: 400 12px/1.2 var(--cas-body);
}
/* ---- copy ---- */
.cas-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.cas .cas-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
margin: 0 0 16px;
font: 600 16px/1 var(--cas-display);
text-transform: uppercase;
color: var(--cas-orange);
}
.cas-rule {
display: none;
width: 40px;
height: 1px;
background: var(--cas-orange);
}
.cas-heading {
margin: 0;
font: 600 32px/1.2 var(--cas-display);
letter-spacing: normal;
text-transform: capitalize;
color: var(--cas-navy);
}
.cas .cas-text {
margin: 14px 0 0;
font: 400 16px/21.04px var(--cas-body);
color: var(--cas-muted);
}
.cas .cas-points {
display: flex;
flex-direction: column;
gap: 14px;
margin: 22px 0 0;
padding: 0;
list-style: none;
}
.cas-point {
display: flex;
align-items: flex-start;
gap: 12px;
font: 600 18px/26px var(--cas-display);
color: var(--cas-navy);
}
.cas-chev {
flex: none;
margin-top: 6px;
fill: none;
stroke: var(--cas-orange);
stroke-width: 2.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.cas .cas-btn {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
margin-top: 30px;
padding: 18px 24px;
border-radius: 100px;
background: var(--cas-orange);
font: 600 14px/12.46px var(--cas-body);
text-transform: capitalize;
color: #fff;
text-decoration: none;
transition: background-color 0.3s var(--cas-ease);
}
.cas-btn-arrow {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.3s var(--cas-ease);
}
.cas .cas-btn:hover {
background: var(--cas-navy);
}
.cas .cas-btn:hover .cas-btn-arrow {
transform: rotate(45deg);
}
.cas .cas-btn:focus-visible {
outline: 2px solid var(--cas-navy);
outline-offset: 3px;
}
/* ---- appear: media from its outer side, copy from the other ---- */
.cas-media,
.cas-copy {
transition:
opacity 0.8s var(--cas-ease),
transform 0.8s var(--cas-ease);
}
.cas[data-cas-ready]:not([data-in]) .cas-media,
.cas[data-cas-ready]:not([data-in]) .cas-copy {
opacity: 0;
}
.cas[data-cas-ready][data-media-side="left"]:not([data-in]) .cas-media,
.cas[data-cas-ready][data-media-side="right"]:not([data-in]) .cas-copy {
transform: translateX(-40px);
}
.cas[data-cas-ready][data-media-side="left"]:not([data-in]) .cas-copy,
.cas[data-cas-ready][data-media-side="right"]:not([data-in]) .cas-media {
transform: translateX(40px);
}
/* ---- dialog ---- */
.cas-dialog {
width: min(960px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
padding: 0;
border: 0;
border-radius: 16px;
background: var(--cas-navy);
color: #fff;
overflow: hidden;
}
.cas-dialog::backdrop {
background: rgb(2 14 40 / 0.8);
}
.cas-dialog-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 6px 6px 18px;
}
.cas .cas-dialog-title {
margin: 0;
font: 600 16px/1.3 var(--cas-display);
}
.cas-close {
display: grid;
place-items: center;
flex: none;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #fff;
cursor: pointer;
transition: background-color 0.3s var(--cas-ease);
}
.cas-close svg {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.cas-close:hover {
background: var(--cas-orange);
}
.cas-close:focus-visible {
outline: 2px solid var(--cas-orange);
outline-offset: 2px;
}
.cas-frame {
position: relative;
aspect-ratio: 16 / 9;
background: #000;
}
.cas-frame :global(iframe),
.cas-frame :global(video) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
/* ---- tablet and up: side by side ---- */
@media (min-width: 810px) {
.cas {
padding: 100px 30px;
}
.cas-inner {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 40px;
}
.cas-media {
flex: 0 0 46%;
aspect-ratio: 576 / 607;
}
.cas[data-video] .cas-media {
flex-basis: 45%;
aspect-ratio: 564 / 562;
}
.cas-copy {
flex: 0 1 619px;
}
.cas .cas-eyebrow {
margin-bottom: 24px;
font-size: 18px;
line-height: 12.6px;
}
.cas-rule {
display: block;
}
.cas-heading {
font-size: 40px;
}
.cas .cas-text {
margin-top: 40px;
}
.cas .cas-points {
gap: 16px;
margin-top: 40px;
}
.cas-point {
font-size: 20px;
line-height: 30px;
}
.cas-chev {
margin-top: 8px;
}
.cas .cas-btn {
min-height: 56px;
margin-top: 50px;
padding: 22px 30px;
}
}
/* ---- desktop: the measured 1253 row ---- */
@media (min-width: 1200px) {
.cas {
padding: 120px 20px;
}
/* Step 39.2: --tg-grid-max / --tg-grid-gutter put the row on a site grid at desktop; the fallbacks give the original 1253px row. */
.cas-inner {
--cas-grid-max: var(--tg-grid-max, 1293px);
--cas-grid-gutter: var(--tg-grid-gutter, 20px);
max-width: calc(var(--cas-grid-max) - 2 * var(--cas-grid-gutter));
}
.cas-heading {
font-size: 48px;
line-height: 57.6px;
}
}
@media (prefers-reduced-motion: reduce) {
.cas-media,
.cas-copy,
.cas-play-disc,
.cas-btn-arrow {
transition: none;
}
.cas .cas-btn:hover .cas-btn-arrow,
.cas-play:hover .cas-play-disc {
transform: none;
}
.cas[data-cas-ready]:not([data-in]) .cas-media,
.cas[data-cas-ready]:not([data-in]) .cas-copy {
opacity: 1;
transform: none;
}
}
@media (forced-colors: active) {
.cas .cas-btn,
.cas-play-disc {
border: 1px solid ButtonText;
}
}
</style>
<script>
/*
* Appear-on-scroll, plus the video player when the section has one.
* One IntersectionObserver per instance; every listener hangs off one
* AbortController, aborted on astro:before-swap. The player is a native
* modal <dialog>: Escape and the backdrop close it, focus goes back to the
* play button, and the iframe or <video> is created on open and removed on
* close so nothing keeps playing. Nothing moves focus or scrolls on load.
*/
function initCommittedAboutSplit(root: HTMLElement) {
if (root.dataset.casReady === "") return;
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
root.dataset.casReady = "";
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
} else {
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
}
},
{ threshold: 0.2 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
}
const play = root.querySelector<HTMLAnchorElement>("[data-cas-play]");
const dialog = root.querySelector<HTMLDialogElement>("[data-cas-dialog]");
const frame = dialog?.querySelector<HTMLElement>("[data-cas-frame]");
const close = dialog?.querySelector<HTMLButtonElement>("[data-cas-close]");
if (play && dialog && frame && typeof dialog.showModal === "function") {
const src = dialog.dataset.src ?? "";
const title = dialog.querySelector(".cas-dialog-title")?.textContent ?? "Video";
const mount = () => {
frame.replaceChildren();
if (dialog.dataset.kind === "file") {
const video = document.createElement("video");
video.src = src;
video.controls = true;
video.playsInline = true;
video.autoplay = !reduced;
video.setAttribute("aria-label", title);
frame.append(video);
} else {
const iframe = document.createElement("iframe");
const url = new URL(src, location.href);
if (!reduced) url.searchParams.set("autoplay", "1");
iframe.src = url.toString();
iframe.title = title;
iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
iframe.allowFullscreen = true;
frame.append(iframe);
}
};
play.addEventListener(
"click",
(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
event.preventDefault();
mount();
dialog.showModal();
close?.focus();
},
{ signal },
);
close?.addEventListener("click", () => dialog.close(), { signal });
/* A click on the backdrop lands on the dialog element itself. */
dialog.addEventListener(
"click",
(event) => {
if (event.target === dialog) dialog.close();
},
{ signal },
);
dialog.addEventListener(
"close",
() => {
frame.replaceChildren();
play.focus();
},
{ signal },
);
signal.addEventListener("abort", () => {
if (dialog.open) dialog.close();
frame.replaceChildren();
});
}
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.casReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-cas-root]").forEach(initCommittedAboutSplit);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/CommittedAboutSplit.astro =====
===== FILE (required): public/committed-about-split/port-dock.webp =====
[binary asset — download https://astro.baysixmedia.com/committed-about-split/port-dock.webp and save it to public/committed-about-split/port-dock.webp]
===== FILE (required): public/committed-about-split/port-aerial.webp =====
[binary asset — download https://astro.baysixmedia.com/committed-about-split/port-aerial.webp and save it to public/committed-about-split/port-aerial.webp]
===== FILE (required): src/components/TeamHoverCards.astro =====
---
/**
* TeamHoverCards
*
* A light surface section: a centred orange eyebrow between two short lines,
* a big navy heading, and three portrait cards. Each card is a pale pink
* frame around a rounded photo, with a white name / role strip over the
* photo's bottom edge and a navy round "share" icon on its right.
*
* Hover (pointer devices, 810px and up), as measured: the photo scales to
* 1.05; the navy share icon fades out and a white pill of social links fades
* in, rising from the icon, capped by an orange copy of the icon. The same
* reveal runs on :focus-within, so the social links are reachable by
* keyboard. On a phone, and on touch screens that cannot hover, the links are
* a plain row under the name instead, as on the reference phone layout.
*
* Appear on scroll: the title block and the cards rise 40px, staggered 0.1s.
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export type TeamSocialNetwork = "instagram" | "x" | "facebook" | "linkedin";
export interface TeamSocialLink {
network: TeamSocialNetwork;
href: string;
}
export interface TeamMember {
name: string;
role?: string;
/** Profile page. The name (and the photo) link to it when set. */
href?: string;
/** `src: ""` renders the labelled placeholder. */
image?: { src?: string; alt?: string };
/** `[]` drops the share icon and the social links. */
socials?: TeamSocialLink[];
}
export interface TeamHoverCardsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** Level of each name. Defaults to one below the heading. */
memberHeadingLevel?: "h3" | "h4";
members?: TeamMember[];
/** The section surface. */
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends TeamHoverCardsProps {}
/*
* No built-in people: the section draws only the members you pass, and a
* member without `socials` gets none. The component never invents a
* person or a profile; the catalog demo passes its own.
*/
const {
eyebrow = "Our People",
heading = "",
headingLevel = "h2",
memberHeadingLevel,
members = [],
background,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
const NameHeading = memberHeadingLevel ?? (headingLevel === "h2" ? "h3" : "h4");
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const LABELS: Record<TeamSocialNetwork, string> = {
instagram: "Instagram",
x: "X",
facebook: "Facebook",
linkedin: "LinkedIn",
};
const cards = members.map((m) => {
const image = { src: "", alt: "", ...(m.image ?? {}) };
return {
...m,
image,
href: m.href ? safeHref(m.href) : undefined,
socials: (m.socials ?? []).map((s) => ({ ...s, href: safeHref(s.href) })),
};
});
/* Measured slot: 352 × 414 at desktop (~0.85:1); the files are 704 × 828. */
const IMAGE_SPEC = "~0.85:1 · 704 × 828 px";
---
{cards.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{cards.length > 0 && (
<section
class:list={["thc", className]}
id={id}
style={background ? `--thc-bg: ${background};` : undefined}
data-thc-root
>
<div class="thc-inner">
{(eyebrow || heading) && (
<div class="thc-title" data-thc-rise>
{
eyebrow && (
<p class="thc-eyebrow">
<span class="thc-eyebrow-line" aria-hidden="true" />
{eyebrow}
<span class="thc-eyebrow-line" aria-hidden="true" />
</p>
)
}
{heading && <Heading class="thc-heading">{heading}</Heading>}
</div>
)}
<ul class="thc-grid" role="list">
{
cards.map((card, i) => (
<li class="thc-card" data-thc-rise style={`--thc-i: ${i};`}>
<div class="thc-media">
{card.image.src ? (
card.href ? (
<a class="thc-media-link" href={card.href} tabindex="-1" aria-hidden="true">
<img src={card.image.src} alt="" width="704" height="828" loading="lazy" decoding="async" />
</a>
) : (
<img src={card.image.src} alt={card.image.alt} width="704" height="828" loading="lazy" decoding="async" />
)
) : (
<div class="thc-ph" role="img" aria-label={`Image placeholder: portrait of ${card.name}`}>
<div class="thc-ph-text" aria-hidden="true">
<span class="thc-ph-label">Image</span>
<span class="thc-ph-hint">Portrait, head and shoulders</span>
<span class="thc-ph-spec">{IMAGE_SPEC}</span>
</div>
</div>
)}
</div>
<div class="thc-strip">
<div class="thc-info">
<NameHeading class="thc-name">
{card.href ? <a href={card.href}>{card.name}</a> : card.name}
</NameHeading>
{card.role && <p class="thc-role">{card.role}</p>}
</div>
{card.socials.length > 0 && (
<div class="thc-share">
<span class="thc-share-icon" aria-hidden="true">
<svg viewBox="0 0 20 16" width="20" height="16">
<path d="M8.2 10.4a3.4 3.4 0 0 0 4.8 0l3-3a3.4 3.4 0 0 0-4.8-4.8L9.8 4M11.8 5.6a3.4 3.4 0 0 0-4.8 0l-3 3a3.4 3.4 0 0 0 4.8 4.8l1.4-1.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
</svg>
</span>
<div class="thc-pill">
<ul class="thc-social" role="list" aria-label={`${card.name} on social media`}>
{card.socials.map((s) => (
<li>
<a class="thc-social-link" href={s.href} rel="noopener noreferrer" target="_blank">
<span class="thc-sr">{LABELS[s.network]} (opens in a new tab)</span>
{s.network === "instagram" && (
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
<rect x="1.2" y="1.2" width="11.6" height="11.6" rx="3.4" fill="none" stroke="currentColor" stroke-width="1.4" />
<circle cx="7" cy="7" r="2.7" fill="none" stroke="currentColor" stroke-width="1.4" />
<circle cx="10.4" cy="3.6" r="0.9" fill="currentColor" />
</svg>
)}
{s.network === "x" && (
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
<path d="M1.5 1.5h3.3l7.7 11H9.2zM12.3 1.5 8 6.4M1.8 12.5 6 7.7" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" />
</svg>
)}
{s.network === "facebook" && (
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
<path d="M8 13.5V7.6h2l.3-2.3H8V3.9c0-.7.2-1.1 1.2-1.1h1.2V.7A15 15 0 0 0 8.6.6C6.9.6 5.7 1.6 5.7 3.5v1.8h-2v2.3h2v5.9z" fill="currentColor" />
</svg>
)}
{s.network === "linkedin" && (
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
<path d="M1.3 4.8h2.6v8H1.3zM2.6.9a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3M5.6 4.8h2.5v1.1c.4-.7 1.3-1.3 2.6-1.3 2.7 0 3.1 1.7 3.1 4v4.2h-2.6V9.1c0-.9 0-2-1.3-2s-1.6 1-1.6 2v3.7H5.6z" fill="currentColor" />
</svg>
)}
</a>
</li>
))}
</ul>
<span class="thc-pill-icon" aria-hidden="true">
<svg viewBox="0 0 20 16" width="20" height="16">
<path d="M8.2 10.4a3.4 3.4 0 0 0 4.8 0l3-3a3.4 3.4 0 0 0-4.8-4.8L9.8 4M11.8 5.6a3.4 3.4 0 0 0-4.8 0l-3 3a3.4 3.4 0 0 0 4.8 4.8l1.4-1.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
</svg>
</span>
</div>
</div>
)}
</div>
</li>
))
}
</ul>
</div>
</section>
)}
<style>
.thc {
--thc-navy: var(--tg-navy, #020e28);
--thc-band: #001f3f;
--thc-orange: var(--tg-orange, #fd5523);
--thc-muted: var(--tg-muted, #61697d);
--thc-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--thc-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--thc-ease: cubic-bezier(0.22, 1, 0.36, 1);
box-sizing: border-box;
padding: 80px 0;
background: var(--thc-bg, var(--tg-surface, #ecf0f1));
color: var(--thc-navy);
font-family: var(--thc-body);
overflow-x: clip;
}
.thc *,
.thc *::before,
.thc *::after {
box-sizing: border-box;
}
.thc-inner {
display: flex;
flex-direction: column;
gap: 40px;
max-width: 1264px;
margin: 0 auto;
padding: 0 20px;
}
.thc-title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.thc .thc-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--thc-display);
text-transform: uppercase;
color: var(--thc-orange);
}
/* Left aligned on a phone, as measured, so the lines drop there. */
.thc-eyebrow-line {
display: none;
width: 40px;
height: 1px;
background: currentColor;
}
.thc .thc-heading {
margin: 0;
font: 600 32px/1.2 var(--thc-display);
color: var(--thc-navy);
overflow-wrap: break-word;
}
.thc .thc-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
margin: 0;
padding: 0;
list-style: none;
}
/* ---- card: phone / no-hover layout first ---- */
.thc-card {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
border-radius: 12px;
background: #fff9f9;
overflow: hidden;
}
.thc-media {
position: relative;
aspect-ratio: 7 / 5;
overflow: hidden;
}
.thc-media-link,
.thc-media img {
display: block;
width: 100%;
height: 100%;
}
.thc-media img {
object-fit: cover;
object-position: 50% 25%;
transition: transform 0.4s var(--thc-ease);
}
.thc-strip {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.thc-info {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.thc .thc-name {
margin: 0;
font: 600 20px/1.36 var(--thc-display);
color: var(--thc-navy);
}
.thc-name a {
color: inherit;
text-decoration: none;
transition: color 0.3s var(--thc-ease);
}
.thc-name a:hover {
color: var(--thc-orange);
}
.thc-name a:focus-visible {
outline: 2px solid var(--thc-orange);
outline-offset: 3px;
border-radius: 2px;
}
.thc .thc-role {
margin: 0;
font: 400 16px/21.04px var(--thc-body);
color: var(--thc-orange);
}
.thc-share-icon,
.thc-pill-icon {
display: none;
}
.thc .thc-social {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.thc-social-link {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
color: var(--thc-orange);
transition:
background-color 0.3s var(--thc-ease),
color 0.3s var(--thc-ease);
}
.thc-social-link svg {
display: block;
width: 14px;
height: 14px;
}
.thc-social-link:hover {
background: var(--thc-orange);
color: #fff;
}
.thc-social-link:focus-visible {
outline: 2px solid var(--thc-navy);
outline-offset: 2px;
}
.thc-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.thc-ph {
display: grid;
place-items: center;
width: 100%;
height: 100%;
padding: 16px;
text-align: center;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgb(2 14 40 / 0.62);
}
.thc-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.thc-ph-label {
font: 600 12px/1 var(--thc-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.thc-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--thc-body);
}
.thc-ph-spec {
font: 400 12px/1.2 var(--thc-body);
}
.thc-card:hover .thc-media img {
transform: scale(1.05);
}
/* ---- appear on scroll ---- */
.thc-title,
.thc-card {
transition:
opacity 0.8s var(--thc-ease),
transform 0.8s var(--thc-ease);
}
.thc-card {
transition-delay: calc(0.1s + var(--thc-i, 0) * 0.1s);
}
.thc[data-thc-ready]:not([data-in]) .thc-title,
.thc[data-thc-ready]:not([data-in]) .thc-card {
opacity: 0;
transform: translateY(40px);
}
/* ---- tablet and up: centred title, three-up grid ---- */
@media (min-width: 810px) {
.thc {
padding: 100px 0;
}
.thc-inner {
gap: 60px;
}
.thc-title {
align-items: center;
text-align: center;
}
.thc-eyebrow-line {
display: block;
}
.thc .thc-heading {
font-size: 40px;
}
.thc .thc-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.thc .thc-grid > .thc-card {
flex: 1 1 220px;
max-width: 392px;
}
.thc-card {
padding: 20px;
border-radius: 20px;
overflow: visible;
cursor: pointer;
}
.thc-media {
aspect-ratio: 352 / 414;
border-radius: 15px;
}
}
/* ---- the measured overlay strip and hover reveal: pointer devices ---- */
@media (min-width: 810px) and (hover: hover) {
.thc-strip {
position: absolute;
right: 20px;
bottom: 20px;
left: 20px;
z-index: 2;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 10px;
min-height: 85px;
padding: 13px 20px;
border-radius: 15px;
background: #fff;
}
.thc-info {
gap: 8px;
}
.thc .thc-name {
font-size: 22px;
line-height: 29.92px;
}
.thc-share {
position: relative;
flex: none;
width: 56px;
height: 56px;
}
.thc-share-icon,
.thc-pill-icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 100px;
color: #fff;
}
.thc-share-icon {
background: var(--thc-band);
transition: opacity 0.4s var(--thc-ease);
}
.thc-pill-icon {
flex: none;
margin: 0 -6px;
background: var(--thc-orange);
}
/* The pill rises from the icon: socials on top, orange icon at the
bottom, sitting exactly over the navy one. */
.thc-pill {
position: absolute;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: 10px;
width: 56px;
padding: 6px 6px 0;
border-radius: 100px;
background: #fff;
opacity: 0;
pointer-events: none;
transition: opacity 0.4s var(--thc-ease);
}
.thc .thc-social {
flex-direction: column;
flex-wrap: nowrap;
gap: 8px;
}
.thc-social-link {
background: rgb(253 85 35 / 0.1);
}
.thc-card:hover .thc-share-icon,
.thc-card:focus-within .thc-share-icon {
opacity: 0;
}
.thc-card:hover .thc-pill,
.thc-card:focus-within .thc-pill {
opacity: 1;
pointer-events: auto;
}
}
@media (min-width: 1200px) {
.thc {
padding: 120px 0;
}
.thc .thc-eyebrow {
font-size: 18px;
}
.thc .thc-heading {
font-size: 48px;
line-height: 57.6px;
}
}
@media (prefers-reduced-motion: reduce) {
.thc-title,
.thc-card,
.thc-media img {
transition: none;
}
.thc-card:hover .thc-media img {
transform: none;
}
.thc[data-thc-ready]:not([data-in]) .thc-title,
.thc[data-thc-ready]:not([data-in]) .thc-card {
opacity: 1;
transform: none;
}
}
@media (forced-colors: active) {
.thc-card {
border: 1px solid CanvasText;
}
.thc-social-link {
border: 1px solid LinkText;
}
}
</style>
<script>
/*
* Appear-on-scroll only; the hover reveal is CSS. One IntersectionObserver
* per instance, scoped to its root, torn down on astro:before-swap.
*/
function initTeamHoverCards(root: HTMLElement) {
if (root.dataset.thcReady === "" || root.dataset.in === "") return;
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
return;
}
root.dataset.thcReady = "";
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.thcReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-thc-root]").forEach(initTeamHoverCards);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/TeamHoverCards.astro =====
===== FILE (required): public/team-hover-cards/member-1.webp =====
[binary asset — download https://astro.baysixmedia.com/team-hover-cards/member-1.webp and save it to public/team-hover-cards/member-1.webp]
===== FILE (required): public/team-hover-cards/member-2.webp =====
[binary asset — download https://astro.baysixmedia.com/team-hover-cards/member-2.webp and save it to public/team-hover-cards/member-2.webp]
===== FILE (required): public/team-hover-cards/member-3.webp =====
[binary asset — download https://astro.baysixmedia.com/team-hover-cards/member-3.webp and save it to public/team-hover-cards/member-3.webp]
===== 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/ProjectPhotoMarquee.astro =====
---
/**
* ProjectPhotoMarquee
*
* A "latest projects" band: a centred orange eyebrow between two short rules
* and a two-line navy heading over a faint chevron pattern, then a full-bleed
* strip of tall rounded photo cards that drifts left forever. Each card has a
* white label bar along its bottom edge.
*
* Motion: one full loop of the card set takes `duration` seconds (65 by
* default, as measured). The set is rendered twice; the copy is aria-hidden
* and inert. The strip pauses on hover and while anything inside has focus;
* while a card has keyboard focus the strip snaps to its start so the browser
* can scroll the focused card into view. On appear the title rises 40px and
* each label bar slides in from −160px. Under reduced motion the strip is a
* static, horizontally scrollable row.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface ProjectCard {
title: string;
/** Makes the whole card a link. */
href?: string;
image?: { src?: string; alt?: string };
}
export interface ProjectPhotoMarqueeProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
cards?: ProjectCard[];
/** Seconds for one full loop of the card set. */
duration?: number;
/** Accessible name of the card list. */
listLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ProjectPhotoMarqueeProps {}
const {
eyebrow = "Latest Projects",
heading = "",
headingLevel = "h2",
cards = [],
duration = 65,
listLabel = "Recent projects",
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 Heading = headingLevel;
/* No cards, no band: the eyebrow and heading never render on their own. */
const list = (cards ?? []).filter((c) => c && c.title);
const seconds = Number.isFinite(duration) && duration > 0 ? duration : 65;
---
{list.length > 0 && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["ppm", className]} id={id} data-ppm-root style={`--ppm-duration: ${seconds}s;`}>
<div class="ppm-title" data-ppm-appear>
{
eyebrow && (
<p class="ppm-eyebrow">
<span class="ppm-rule" aria-hidden="true" />
<span>{eyebrow}</span>
<span class="ppm-rule" aria-hidden="true" />
</p>
)
}
{heading && <Heading class="ppm-heading">{heading}</Heading>}
</div>
{
list.length > 0 && (
<div class="ppm-viewport" data-ppm-viewport data-ppm-appear>
<div class="ppm-track">
{[0, 1].map((copy) => (
<ul
class="ppm-set"
aria-label={copy === 0 ? listLabel : undefined}
aria-hidden={copy === 1 ? "true" : undefined}
inert={copy === 1}
data-ppm-copy={copy === 1 ? "" : undefined}
>
{list.map((card) => {
const src = card.image?.src ?? "";
const alt = card.image?.alt ?? "";
const Tag = card.href ? "a" : "div";
return (
<li class="ppm-item">
<Tag class="ppm-card" href={card.href ? safeHref(card.href) : undefined}>
{src ? (
<img class="ppm-img" src={src} alt={alt} width="720" height="960" loading="lazy" decoding="async" />
) : (
<span class="ppm-ph" role="img" aria-label={`Image placeholder: ${card.title}`}>
<span class="ppm-ph-text" aria-hidden="true">
<span class="ppm-ph-label">Image</span>
<span class="ppm-ph-spec">3:4 · 720 × 960 px</span>
</span>
</span>
)}
<span class="ppm-label">
<span class="ppm-label-text">{card.title}</span>
</span>
</Tag>
</li>
);
})}
</ul>
))}
</div>
</div>
)
}
</section>
</>
)}
<style>
.ppm {
--ppm-navy: var(--tg-navy, #020e28);
--ppm-orange: var(--tg-orange, #fd5523);
--ppm-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--ppm-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--ppm-ease: cubic-bezier(0.22, 1, 0.36, 1);
--ppm-card-w: 290px;
--ppm-gap: 24px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 40px;
padding: 60px 0;
overflow: hidden;
background-color: #f1f2f4;
/* A faint chevron texture, redrawn rather than copied. */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='24' viewBox='0 0 48 24'%3E%3Cpath d='M0 18 12 6l12 12 12-12 12 12' fill='none' stroke='%23020e28' stroke-opacity='.045' stroke-width='1.2'/%3E%3C/svg%3E");
background-size: 48px 24px;
color: var(--ppm-navy);
font-family: var(--ppm-body);
}
.ppm *,
.ppm *::before,
.ppm *::after {
box-sizing: border-box;
}
.ppm-title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
width: 100%;
max-width: 1293px;
margin: 0 auto;
padding: 0 20px;
}
.ppm .ppm-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--ppm-display);
text-transform: uppercase;
color: var(--ppm-orange);
}
.ppm-rule {
display: none;
width: 40px;
height: 1px;
background: currentColor;
}
.ppm-heading {
margin: 0;
max-width: 577px;
font: 600 32px/1.2 var(--ppm-display);
color: var(--ppm-navy);
overflow-wrap: anywhere;
}
/* ---- the strip ---- */
.ppm-viewport {
/* Hidden, not clip: when a card takes keyboard focus the browser must be
able to scroll it into view. The script puts scrollLeft back after. */
overflow-x: hidden;
padding-block: 6px;
margin-block: -6px;
}
.ppm-track {
display: flex;
width: max-content;
animation: ppm-drift var(--ppm-duration) linear infinite;
}
.ppm .ppm-set {
display: flex;
gap: var(--ppm-gap);
margin: 0;
/* Trailing gap inside each set so −50% lands exactly on the copy. */
padding: 0 var(--ppm-gap) 0 0;
list-style: none;
}
.ppm-item {
flex: none;
width: var(--ppm-card-w);
}
.ppm-card {
position: relative;
display: block;
aspect-ratio: 3 / 4;
border-radius: 20px;
overflow: hidden;
color: inherit;
text-decoration: none;
}
a.ppm-card:focus-visible {
outline: 3px solid var(--ppm-orange);
outline-offset: -3px;
}
.ppm-img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ppm-label {
position: absolute;
left: 14px;
right: 14px;
bottom: 14px;
display: flex;
padding: 10px 13px;
border-radius: 5px;
background: #fff;
transition:
opacity 0.8s var(--ppm-ease),
transform 0.8s var(--ppm-ease);
}
.ppm-label-text {
font: 600 20px/1.4 var(--ppm-display);
color: var(--ppm-navy);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
a.ppm-card:focus-visible .ppm-label {
outline: 2px solid var(--ppm-orange);
}
.ppm-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);
}
.ppm-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 60px;
}
.ppm-ph-label {
font: 500 12px/1 var(--ppm-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ppm-ph-spec {
font: 400 12px/1.2 var(--ppm-body);
}
@keyframes ppm-drift {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.ppm-viewport:hover .ppm-track,
.ppm-viewport:focus-within .ppm-track {
animation-play-state: paused;
}
/* Keyboard focus on a card: stop the drift outright so the track sits at
its start and the browser's scroll-into-view lands on the right card. */
.ppm-viewport:has(:focus-visible) .ppm-track {
animation: none;
}
/* ---- appear: hidden start only once the script marks the root ready ---- */
.ppm-title {
transition:
opacity 0.8s var(--ppm-ease),
transform 0.8s var(--ppm-ease);
}
.ppm[data-ppm-anim] .ppm-title:not([data-in]) {
opacity: 0;
transform: translateY(40px);
}
.ppm[data-ppm-anim] .ppm-viewport:not([data-in]) .ppm-label {
opacity: 0;
transform: translateX(-160px);
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.ppm {
--ppm-card-w: 330px;
--ppm-gap: 30px;
gap: 50px;
padding: 90px 0;
}
.ppm-title {
align-items: center;
text-align: center;
}
.ppm-rule {
display: block;
}
.ppm-heading {
font-size: 40px;
}
.ppm-label {
left: 20px;
right: 20px;
bottom: 20px;
padding: 13px;
}
.ppm-label-text {
font-size: 24px;
line-height: 34px;
}
}
/* ---- desktop: 360 × 480 cards, 30px apart ---- */
@media (min-width: 1200px) {
.ppm {
--ppm-card-w: 360px;
gap: 58px;
padding: 120px 0;
}
.ppm .ppm-eyebrow {
font-size: 18px;
}
.ppm-heading {
font-size: 48px;
line-height: 57.6px;
}
}
/* ---- reduced motion: a still, scrollable row ---- */
@media (prefers-reduced-motion: reduce) {
.ppm-track {
animation: none;
}
.ppm-viewport {
overflow-x: auto;
scrollbar-width: thin;
}
.ppm .ppm-set[data-ppm-copy] {
display: none;
}
.ppm .ppm-set {
padding-left: 20px;
}
.ppm-title,
.ppm-label {
transition: none;
}
}
@media (forced-colors: active) {
.ppm-card {
border: 1px solid CanvasText;
}
.ppm-label {
border: 1px solid CanvasText;
}
.ppm-rule {
background: CanvasText;
}
}
</style>
<script>
/*
* Appear on scroll, plus one clean-up for keyboard users. One
* IntersectionObserver per instance watches the title and the strip and
* sets `data-in` on each once. When focus leaves the strip, the scroll the
* browser made to reveal a focused card is put back to 0 so the resumed
* drift starts from a clean origin. Everything is released on
* astro:before-swap. The drift itself is pure CSS and runs without JS.
*/
function initProjectPhotoMarquee(root: HTMLElement) {
if (root.dataset.ppmReady !== undefined) return;
root.dataset.ppmReady = "";
const controller = new AbortController();
const { signal } = controller;
const viewport = root.querySelector<HTMLElement>("[data-ppm-viewport]");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (viewport && !reduced) {
// The browser leaves a half-visible card where it is; bring the
// whole focused card into the strip (horizontal only).
viewport.addEventListener(
"focusin",
(event) => {
const card = (event.target as HTMLElement).closest<HTMLElement>(".ppm-card");
if (!card) return;
requestAnimationFrame(() => {
const v = viewport.getBoundingClientRect();
const c = card.getBoundingClientRect();
if (c.right > v.right) viewport.scrollLeft += c.right - v.right + 16;
else if (c.left < v.left) viewport.scrollLeft -= v.left - c.left + 16;
});
},
{ signal },
);
viewport.addEventListener(
"focusout",
(event) => {
const next = (event as FocusEvent).relatedTarget as Node | null;
if (!next || !viewport.contains(next)) viewport.scrollLeft = 0;
},
{ signal },
);
}
if (!reduced && "IntersectionObserver" in window) {
// The hidden start pose applies only from here on.
root.dataset.ppmAnim = "";
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-ppm-appear]").forEach((el) => observer.observe(el));
signal.addEventListener("abort", () => observer.disconnect());
}
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.ppmReady;
delete root.dataset.ppmAnim;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-ppm-root]").forEach(initProjectPhotoMarquee);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ProjectPhotoMarquee.astro =====
===== FILE (required): public/project-photo-marquee/project-1.webp =====
[binary asset — download https://astro.baysixmedia.com/project-photo-marquee/project-1.webp and save it to public/project-photo-marquee/project-1.webp]
===== FILE (required): public/project-photo-marquee/project-2.webp =====
[binary asset — download https://astro.baysixmedia.com/project-photo-marquee/project-2.webp and save it to public/project-photo-marquee/project-2.webp]
===== FILE (required): public/project-photo-marquee/project-3.webp =====
[binary asset — download https://astro.baysixmedia.com/project-photo-marquee/project-3.webp and save it to public/project-photo-marquee/project-3.webp]
===== FILE (required): public/project-photo-marquee/project-4.webp =====
[binary asset — download https://astro.baysixmedia.com/project-photo-marquee/project-4.webp and save it to public/project-photo-marquee/project-4.webp]
===== FILE (required): public/project-photo-marquee/project-5.webp =====
[binary asset — download https://astro.baysixmedia.com/project-photo-marquee/project-5.webp and save it to public/project-photo-marquee/project-5.webp]
===== FILE (required): src/components/QuoteCardSlider.astro =====
---
/**
* QuoteCardSlider
*
* A testimonial slider on a pale grey band with drawn concentric ripples:
* an eyebrow and heading on the left, two round orange arrow buttons on the
* right, then a row of white quote cards. Each card has an italic quote, a
* star row, an orange quote badge sitting in its bottom-right corner and a
* speech-bubble tail pointing down at the person: a ringed photo, a name and
* a role.
*
* Each arrow click moves exactly one card (card width + 30px gap, 418px at
* desktop) and wraps round at the ends, as measured. No autoplay. Three
* cards show at desktop, two on a tablet; a phone stacks them all and drops
* the arrows, as the reference does.
*
* Accessible carousel: a region with aria-roledescription="carousel",
* labelled buttons, a polite live announcement only after a button press,
* and every card outside the visible window inert and aria-hidden.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface QuoteCard {
quote: string;
name: string;
role?: string;
/**
* 0–5 stars, and only the rating this person gave. Omit it and the card
* has no star row: a missing rating is never drawn as five stars.
*/
rating?: number;
/** Round photo. `src: ""` renders the initials disc instead. */
image?: { src?: string; alt?: string };
}
export interface QuoteCardSliderProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
cards?: QuoteCard[];
/** Accessible name of the carousel region. Defaults to the heading. */
label?: string;
/** Draw the concentric ripple pattern behind the section. */
pattern?: boolean;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends QuoteCardSliderProps {}
const {
eyebrow = "Our testimonial",
heading = "What Our Clients Say About Us",
headingLevel = "h2",
/* No cards of its own: testimonials come from the caller or not at all. */
cards = [],
label,
pattern = true,
background = "#eef0f2",
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 ?? `qcs-${Math.random().toString(36).slice(2, 9)}`;
const trackId = `${uid}-track`;
const regionLabel = label ?? heading;
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]!.toUpperCase())
.join("");
/* A rating comes from data or not at all: no number, no star row. */
const clampRating = (r: number | undefined) =>
typeof r === "number" && Number.isFinite(r) ? Math.max(0, Math.min(5, Math.round(r))) : null;
/* Concentric ripples, centred below the band: white strokes on the grey. */
const RIPPLES = Array.from({ length: 22 }, (_, k) => 260 + k * 64);
---
{cards.length > 0 && (<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["qcs", className]} id={id} style={`--qcs-bg: ${background};`} data-qcs-root>
{
pattern && (
<svg class="qcs-pattern" viewBox="0 0 1440 810" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
<g fill="none" stroke="#ffffff" stroke-opacity="0.55">
{RIPPLES.map((r, k) => (
<ellipse cx="880" cy="1060" rx={r * 1.35} ry={r} stroke-width={k % 3 === 0 ? 14 : 6} />
))}
</g>
</svg>
)
}
<div class="qcs-inner">
<div class="qcs-top">
<div class="qcs-title" data-qcs-appear>
{
eyebrow && (
<p class="qcs-eyebrow">
{eyebrow}
<span class="qcs-eyebrow-line" aria-hidden="true" />
</p>
)
}
<Heading class="qcs-heading">{heading}</Heading>
</div>
<div class="qcs-controls" data-qcs-appear>
<button type="button" class="qcs-arrow" aria-label="Previous testimonial" aria-controls={trackId} data-qcs-prev>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
<path d="M20 12H5M11 6l-6 6 6 6" />
</svg>
</button>
<button type="button" class="qcs-arrow" aria-label="Next testimonial" aria-controls={trackId} data-qcs-next>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
<path d="M4 12h15M13 6l6 6-6 6" />
</svg>
</button>
</div>
</div>
<div class="qcs-carousel" role="region" aria-roledescription="carousel" aria-label={regionLabel}>
<div class="qcs-viewport" data-qcs-viewport>
<div class="qcs-track" id={trackId} data-qcs-track>
{
cards.map((card, i) => {
const rating = clampRating(card.rating);
const image = { src: "", alt: "", ...(card.image ?? {}) };
return (
<div
class="qcs-slide"
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${cards.length}`}
data-qcs-slide
data-qcs-name={card.name}
data-qcs-index={i + 1}
style={`--qcs-o: ${i}; --qcs-i: ${i + 1};`}
>
<figure class="qcs-figure" data-qcs-appear>
<div class="qcs-bubble">
<blockquote class="qcs-quote">
<p>{card.quote}</p>
</blockquote>
<div class="qcs-bubble-foot">
{rating !== null && (
<span class="qcs-stars" role="img" aria-label={`Rated ${rating} out of 5`}>
{Array.from({ length: 5 }, (_, s) => (
<svg class:list={["qcs-star", { "qcs-star--off": s >= rating }]} viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path d="M6 .9l1.5 3.2 3.5.4-2.6 2.4.7 3.5L6 8.6 2.9 10.4l.7-3.5L1 4.5l3.5-.4z" />
</svg>
))}
</span>
)}
<span class="qcs-badge" aria-hidden="true">
<svg viewBox="0 0 32 24" width="32" height="24" focusable="false">
<path d="M7.5 1.5A6 6 0 0 0 1.5 7.5a6 6 0 0 0 6 6c.6 0 1.1 0 1.6-.2-.7 3-2.9 5.4-5.9 6.6l1 2c5.7-1.9 9.3-6.7 9.3-12.9 0-4.4-2.6-7.5-6-7.5ZM24.5 1.5a6 6 0 0 0-6 6 6 6 0 0 0 6 6c.6 0 1.1 0 1.6-.2-.7 3-2.9 5.4-5.9 6.6l1 2c5.7-1.9 9.3-6.7 9.3-12.9 0-4.4-2.6-7.5-6-7.5Z" />
</svg>
</span>
</div>
<span class="qcs-tail" aria-hidden="true" />
</div>
<figcaption class="qcs-person">
<span class="qcs-ring">
{image.src ? (
<img class="qcs-avatar" src={image.src} alt={image.alt} width="148" height="148" loading="lazy" decoding="async" />
) : (
<span class="qcs-avatar qcs-avatar--initials" aria-hidden="true">
{initials(card.name)}
</span>
)}
</span>
<span class="qcs-person-text">
<span class="qcs-name">{card.name}</span>
{card.role && <span class="qcs-role">{card.role}</span>}
</span>
</figcaption>
</figure>
</div>
);
})
}
</div>
</div>
<div class="qcs-sr" aria-live="polite" aria-atomic="true" data-qcs-live></div>
</div>
</div>
</section>
</>)}
<style>
.qcs {
--qcs-navy: var(--tg-navy, #020e28);
--qcs-orange: var(--tg-orange, #fd5523);
--qcs-muted: var(--tg-muted, #61697d);
--qcs-quote-ink: #001f3f;
--qcs-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--qcs-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
--qcs-ease: cubic-bezier(0.22, 1, 0.36, 1);
--qcs-gap: 30px;
--qcs-visible: 1;
position: relative;
box-sizing: border-box;
padding: 60px 16px;
background: var(--qcs-bg);
color: var(--qcs-navy);
font-family: var(--qcs-body);
overflow: hidden;
isolation: isolate;
}
.qcs *,
.qcs *::before,
.qcs *::after {
box-sizing: border-box;
}
.qcs-pattern {
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
pointer-events: none;
}
.qcs-inner {
display: flex;
flex-direction: column;
gap: 32px;
max-width: 1224px;
margin: 0 auto;
}
.qcs-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.qcs-title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
max-width: 477px;
}
.qcs .qcs-eyebrow {
display: flex;
align-items: center;
gap: 12px;
margin: 0;
font: 600 16px/1 var(--qcs-display);
text-transform: uppercase;
color: var(--qcs-orange);
}
.qcs-eyebrow-line {
display: block;
width: 40px;
height: 1px;
background: currentColor;
}
.qcs-heading {
margin: 0;
font: 600 30px/1.2 var(--qcs-display);
text-transform: capitalize;
color: var(--qcs-navy);
}
/* ---- arrows: filled orange discs, white arrows ---- */
.qcs-controls {
display: none;
gap: 15px;
flex: none;
}
.qcs-arrow {
display: grid;
place-items: center;
width: 56px;
height: 56px;
margin: 0;
padding: 0;
border: 1px solid var(--qcs-orange);
border-radius: 50%;
background: var(--qcs-orange);
color: #fff;
cursor: pointer;
transition:
background-color 0.3s ease-out,
border-color 0.3s ease-out;
}
.qcs-arrow svg {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.qcs-arrow:hover {
border-color: var(--qcs-navy);
background: var(--qcs-navy);
}
.qcs-arrow:focus-visible {
outline: 2px solid var(--qcs-navy);
outline-offset: 3px;
}
/* ---- the slides: phone = a stacked list ---- */
.qcs-viewport {
position: relative;
}
.qcs-track {
display: flex;
flex-direction: column;
gap: 24px;
}
.qcs-slide {
order: var(--qcs-o);
min-width: 0;
}
/* Clones exist only for the wrap-round and never show in the list. */
.qcs-slide[data-qcs-clone] {
display: none;
}
.qcs-figure {
display: flex;
flex-direction: column;
gap: 20px;
margin: 0;
}
.qcs-bubble {
position: relative;
display: flex;
flex-direction: column;
gap: 18px;
border-radius: 20px;
background: #fff;
}
.qcs-quote {
margin: 0;
padding: 24px 24px 0;
}
.qcs .qcs-quote p {
margin: 0;
font: italic 500 15px/1.47 var(--qcs-body);
color: var(--qcs-quote-ink);
}
.qcs-bubble-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 24px;
}
.qcs-stars {
display: inline-flex;
gap: 3px;
}
.qcs-star {
display: block;
fill: var(--qcs-orange);
stroke: var(--qcs-orange);
stroke-width: 0.8;
stroke-linejoin: round;
}
.qcs-star--off {
fill: none;
}
/* Round on three corners, the card's own 20px radius on the fourth, so it
sits flush in the bottom-right corner. */
.qcs-badge {
display: grid;
place-items: center;
width: 60px;
height: 60px;
border-radius: 50% 50% 20px 50%;
background: var(--qcs-orange);
}
.qcs-badge svg {
display: block;
width: 26px;
height: auto;
fill: none;
stroke: #fff;
stroke-width: 1.4;
}
.qcs-tail {
position: absolute;
top: 100%;
left: 58px;
width: 51px;
height: 25px;
margin-top: -1px;
background: #fff;
clip-path: polygon(0 0, 100% 0, 58% 88%, 50% 100%, 42% 88%);
}
.qcs-person {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0 0 40px;
}
.qcs-ring {
display: grid;
place-items: center;
flex: none;
width: 64px;
height: 64px;
padding: 4px;
border: 1px dashed var(--qcs-orange);
border-radius: 50%;
}
.qcs-avatar {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.qcs-avatar--initials {
display: grid;
place-items: center;
background: var(--qcs-navy);
color: #fff;
font: 600 16px/1 var(--qcs-display);
}
.qcs-person-text {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.qcs-name {
font: 500 18px/28px var(--qcs-display);
color: var(--qcs-navy);
}
.qcs-role {
font: 400 14px/1.2 var(--qcs-body);
color: var(--qcs-muted);
}
.qcs-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: a horizontal slider ---- */
@media (min-width: 810px) {
.qcs {
--qcs-visible: 2;
padding: 100px 30px;
}
.qcs-inner {
gap: 20px;
}
.qcs-title {
gap: 24px;
}
.qcs .qcs-eyebrow {
font-size: 18px;
line-height: 12.6px;
}
.qcs-heading {
font-size: 40px;
}
.qcs[data-qcs-ready][data-qcs-slides] .qcs-controls {
display: flex;
}
.qcs-viewport {
overflow: hidden;
}
/* Without the script the row can still be scrolled by hand. */
.qcs:not([data-qcs-ready]) .qcs-viewport {
overflow-x: auto;
}
.qcs-track {
flex-direction: row;
align-items: flex-start;
gap: var(--qcs-gap);
will-change: transform;
}
.qcs-slide {
order: 0;
flex: 0 0 calc((100% - (var(--qcs-visible) - 1) * var(--qcs-gap)) / var(--qcs-visible));
}
.qcs-slide[data-qcs-clone] {
display: block;
}
.qcs-quote {
padding: 40px 40px 0;
}
.qcs .qcs-quote p {
font-size: 16px;
line-height: 23.488px;
}
.qcs-bubble-foot {
padding-left: 40px;
}
.qcs-badge {
width: 74px;
height: 74px;
}
.qcs-badge svg {
width: 32px;
}
.qcs-person {
padding-top: 0;
}
.qcs-ring {
width: 80px;
height: 80px;
padding: 5px;
}
}
@media (min-width: 1200px) {
.qcs {
--qcs-visible: 3;
padding: 120px 20px;
}
/* Measured: the arrows end 118px short of the container's right edge. */
.qcs-top {
padding-right: 118px;
}
.qcs-heading {
font-size: 48px;
line-height: 57.6px;
}
}
/* ---- appear on scroll: start pose only once the script is ready ---- */
[data-qcs-appear] {
transition:
opacity 0.8s var(--qcs-ease),
transform 0.8s var(--qcs-ease);
transition-delay: calc(var(--qcs-i, 0) * 0.1s);
}
.qcs[data-qcs-ready]:not([data-in]) [data-qcs-appear] {
opacity: 0;
transform: translateY(40px);
}
@media (prefers-reduced-motion: reduce) {
[data-qcs-appear],
.qcs-arrow {
transition: none;
}
.qcs[data-qcs-ready]:not([data-in]) [data-qcs-appear] {
opacity: 1;
transform: none;
}
}
@media (forced-colors: active) {
.qcs-bubble {
border: 1px solid CanvasText;
}
.qcs-arrow {
border-color: ButtonText;
}
}
</style>
<script>
/*
* QuoteCardSlider controller.
*
* The wrap-round works by rotation: the originals are cloned once, and a
* move slides the track one step (card width + gap, read from the DOM),
* then moves the first slide to the end and resets the transform. Prev
* does the same in reverse. So there is never an empty slot at either end
* and never a jump back across the whole list.
*
* Whatever sits outside the visible window is inert and aria-hidden, so
* only one copy of each card is ever exposed. The live region speaks only
* after a button press. No timers besides a transitionend fallback, no
* autoplay, no focus moves; everything on one AbortController, aborted on
* astro:before-swap.
*/
function initQuoteCardSlider(root: HTMLElement) {
if (root.dataset.qcsReady === "") return;
const viewport = root.querySelector<HTMLElement>("[data-qcs-viewport]");
const track = root.querySelector<HTMLElement>("[data-qcs-track]");
const prev = root.querySelector<HTMLButtonElement>("[data-qcs-prev]");
const next = root.querySelector<HTMLButtonElement>("[data-qcs-next]");
const live = root.querySelector<HTMLElement>("[data-qcs-live]");
if (!viewport || !track || !prev || !next || !live) return;
const originals = [...track.querySelectorAll<HTMLElement>("[data-qcs-slide]")];
const total = originals.length;
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const sliderMode = window.matchMedia("(min-width: 810px)");
/* Clone the set once (twice for two cards) so the view is never short. */
const clones: HTMLElement[] = [];
if (total >= 2) {
const copies = total >= 3 ? 1 : 2;
for (let c = 0; c < copies; c++) {
originals.forEach((slide) => {
const clone = slide.cloneNode(true) as HTMLElement;
clone.setAttribute("data-qcs-clone", "");
clone.removeAttribute("id");
clone.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"));
track.append(clone);
clones.push(clone);
});
}
root.setAttribute("data-qcs-slides", String(total));
}
const slides = () => [...track.children] as HTMLElement[];
function step() {
const [a, b] = slides();
if (!a || !b) return 0;
return b.getBoundingClientRect().left - a.getBoundingClientRect().left;
}
function visibleCount() {
const s = step();
if (!s) return 1;
return Math.max(1, Math.round((viewport!.clientWidth + (s - slides()[0].offsetWidth)) / s));
}
/* Only the slides in view are exposed; the rest are inert. */
function syncExposure() {
if (!sliderMode.matches || total < 2) {
slides().forEach((slide) => {
const clone = slide.hasAttribute("data-qcs-clone");
slide.inert = clone;
if (clone) slide.setAttribute("aria-hidden", "true");
else slide.removeAttribute("aria-hidden");
});
return;
}
const n = Math.min(visibleCount(), total);
slides().forEach((slide, i) => {
const shown = i < n;
slide.inert = !shown;
if (shown) slide.removeAttribute("aria-hidden");
else slide.setAttribute("aria-hidden", "true");
});
}
let busy: null | (() => void) = null;
let fallback = 0;
function settle(done: () => void) {
const finish = () => {
if (busy !== finish) return;
busy = null;
window.clearTimeout(fallback);
done();
};
busy = finish;
fallback = window.setTimeout(finish, 900);
}
/* One persistent listener; the cards' own appear transitions bubble
here too, so only the track's transform counts. */
track.addEventListener(
"transitionend",
(event) => {
if (event.target === track && event.propertyName === "transform") busy?.();
},
{ signal },
);
function resetTransform() {
track!.style.transition = "none";
track!.style.transform = "";
void track!.offsetWidth;
track!.style.transition = "";
}
function announce(first: HTMLElement | undefined) {
if (!first) return;
live!.textContent = `Showing testimonial ${first.dataset.qcsIndex} of ${total}: ${first.dataset.qcsName ?? ""}`;
}
function move(dir: 1 | -1) {
if (total < 2) return;
busy?.();
const s = step();
const animate = !reduced.matches && s > 0;
/* The card that will lead the view once this move lands. */
const all = slides();
const target = dir === 1 ? all[1] : all[all.length - 1];
if (dir === 1) {
const done = () => {
track!.append(track!.firstElementChild!);
resetTransform();
syncExposure();
};
if (!animate) {
done();
} else {
track!.style.transition = "transform 0.6s var(--qcs-ease)";
track!.style.transform = `translateX(${-s}px)`;
settle(done);
}
} else {
track!.prepend(track!.lastElementChild!);
if (!animate) {
resetTransform();
syncExposure();
} else {
track!.style.transition = "none";
track!.style.transform = `translateX(${-s}px)`;
void track!.offsetWidth;
track!.style.transition = "transform 0.6s var(--qcs-ease)";
track!.style.transform = "translateX(0px)";
/* Expose the incoming card as it slides in. */
syncExposure();
settle(() => {
resetTransform();
syncExposure();
});
}
}
announce(target);
}
prev.addEventListener("click", () => move(-1), { signal });
next.addEventListener("click", () => move(1), { signal });
sliderMode.addEventListener("change", () => {
busy?.();
syncExposure();
}, { signal });
window.addEventListener("resize", () => syncExposure(), { signal, passive: true });
root.dataset.qcsReady = "";
syncExposure();
let observer: IntersectionObserver | undefined;
if (reduced.matches || !("IntersectionObserver" in window)) {
root.setAttribute("data-in", "");
} else {
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.setAttribute("data-in", "");
observer?.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(root);
}
document.addEventListener(
"astro:before-swap",
() => {
busy?.();
window.clearTimeout(fallback);
observer?.disconnect();
controller.abort();
clones.forEach((clone) => clone.remove());
originals.forEach((slide) => {
track.append(slide);
slide.inert = false;
slide.removeAttribute("aria-hidden");
});
resetTransform();
live.textContent = "";
root.removeAttribute("data-in");
root.removeAttribute("data-qcs-slides");
delete root.dataset.qcsReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-qcs-root]").forEach(initQuoteCardSlider);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/QuoteCardSlider.astro =====
===== FILE (required): public/quote-card-slider/client-1.webp =====
[binary asset — download https://astro.baysixmedia.com/quote-card-slider/client-1.webp and save it to public/quote-card-slider/client-1.webp]
===== FILE (required): public/quote-card-slider/client-2.webp =====
[binary asset — download https://astro.baysixmedia.com/quote-card-slider/client-2.webp and save it to public/quote-card-slider/client-2.webp]
===== FILE (required): public/quote-card-slider/client-3.webp =====
[binary asset — download https://astro.baysixmedia.com/quote-card-slider/client-3.webp and save it to public/quote-card-slider/client-3.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/
- Experience intro cards (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/experience-intro-cards/
- Dotted process steps (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/dotted-process-steps/
- Committed about split (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/committed-about-split/
- Team hover cards (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/team-hover-cards/
- Cargo logo marquee (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/cargo-logo-marquee/
- Project photo marquee (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/project-photo-marquee/
- Quote card slider (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/quote-card-slider/
- 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.