The 29 files and nothing else: the page file and every component, each fenced with its path, followed by each component's verification status. For placing it yourself.
Read it first
Page template kit: "Tideway about page"
Library page: https://astro.baysixmedia.com/templates/tideway-about/
Page file: src/pages/tideway-about.astro
Files: 29 required
Write each block below to the path named in its header, relative to the project root. The page file imports every component by relative path, so keep the destinations as given or update its imports to match. A block marked (required) is one the page does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/pages/tideway-about.astro =====
---
/**
* Tideway about page — a page template from the Astro Component Library.
* https://astro.baysixmedia.com/templates/tideway-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 ScrollPillHeader from "../components/ScrollPillHeader.astro";
import TagTitleBanner from "../components/TagTitleBanner.astro";
import AboutStatsIntro from "../components/AboutStatsIntro.astro";
import LogoMarqueeStrip from "../components/LogoMarqueeStrip.astro";
import ChecklistFeatureGrid from "../components/ChecklistFeatureGrid.astro";
import TeamRevealCards from "../components/TeamRevealCards.astro";
import BadgeImageCtaBand from "../components/BadgeImageCtaBand.astro";
import ScrollProgressTimeline from "../components/ScrollProgressTimeline.astro";
import PortraitRatingTestimonials from "../components/PortraitRatingTestimonials.astro";
import ShipCtaFooter from "../components/ShipCtaFooter.astro";
const scrollPillHeaderProps = {
"items": [
{
"label": "Home",
"href": "/",
"children": [
{
"label": "Homepage 1",
"href": "/"
},
{
"label": "Homepage 2",
"href": "/home-2"
}
]
},
{
"label": "About",
"href": "/about"
},
{
"label": "Services",
"href": "/services"
},
{
"label": "Contact",
"href": "/contact"
}
],
"brandName": "Tideway",
"cta": {
"label": "Get a Quote",
"href": "/contact"
},
"currentPath": "/about"
};
const tagTitleBannerProps = {
"title": "About",
"tag": "Tideway",
"breadcrumbs": [
{
"label": "Home",
"href": "/"
},
{
"label": "About"
}
],
"background": {
"src": "/tag-title-banner/port.webp"
}
};
const aboutStatsIntroProps = {
"heading": "Delivering reliable *freight* solutions with nationwide *transport* expertise",
"image": {
"src": "/about-stats-intro/ship-bow.webp",
"alt": "A container ship steaming towards the camera"
},
"subheading": "Your trusted freight partner",
"paragraphs": [
"For more than fifteen years we have helped businesses simplify their supply chains with dependable road, sea and air freight, careful cargo handling and one team that owns every shipment from pickup to proof of delivery.",
"Our coordinators plan each route around the deadline and the cargo, then keep you updated at every milestone so nothing arrives as a surprise."
],
"checklist": [
"Domestic & international freight",
"Secure cargo transportation",
"Real-time shipment tracking",
"Dedicated support team"
],
"cta": {
"label": "Get in Touch",
"href": "/contact"
},
"years": {
"value": "15+",
"label": "Years of freight excellence"
},
"reviews": {
"count": "1,600+",
"label": "Reviews",
"score": "4.8/5.0",
"avatars": [
"/about-stats-intro/avatar-1.webp",
"/about-stats-intro/avatar-2.webp",
"/about-stats-intro/avatar-3.webp"
]
}
};
const logoMarqueeStripProps = {
"logos": [
{
"name": "Northfield",
"glyph": "sun",
"color": "#2f6fe4"
},
{
"name": "Arcline",
"glyph": "arch",
"color": "#14b8a6"
},
{
"name": "Keystone",
"glyph": "print",
"color": "#8b5cf6"
},
{
"name": "Verdana Co",
"glyph": "leaf",
"color": "#22a45d"
},
{
"name": "Summitra",
"glyph": "wave",
"color": "#ff6d45"
},
{
"name": "Stackwell",
"glyph": "stack",
"color": "#0b4654"
}
],
"highlight": "",
"caption": "",
"variant": "tray"
};
const checklistFeatureGridProps = {
"heading": "Reliable *freight* solutions for your business",
"features": [
{
"title": "Advanced freight management",
"icon": "shapes",
"points": [
"Efficient freight planning",
"Optimised cargo routing"
]
},
{
"title": "Experienced logistics team",
"icon": "team",
"points": [
"Skilled industry professionals",
"Reliable operational support"
]
},
{
"title": "Smarter supply chains",
"icon": "chart",
"points": [
"Data-led planning",
"Streamlined handoffs"
]
},
{
"title": "Customer-first service",
"icon": "support",
"points": [
"A named coordinator",
"Clear, proactive updates"
]
}
],
"images": [
{
"src": "/checklist-feature-grid/courier-box.webp",
"alt": "A courier carrying a parcel through a doorway"
},
{
"src": "/checklist-feature-grid/cargo-plane.webp",
"alt": "Cargo being loaded onto a freighter aircraft on the apron"
}
]
};
const teamRevealCardsProps = {
"heading": "Dedicated freight *specialists*",
"cta": {
"label": "Meet the Team",
"href": "/team"
},
"members": [
{
"name": "Ava Linden",
"role": "Operations Lead",
"tint": "#fbdbe0",
"photo": {
"src": "/team-reveal-cards/member-1.webp",
"alt": ""
},
"social": [
{
"network": "linkedin",
"href": "https://www.linkedin.com/in/ava"
},
{
"network": "x",
"href": "https://x.com/ava"
},
{
"network": "email",
"href": "mailto:ava@tideway.example"
}
]
},
{
"name": "Noah Reyes",
"role": "Customs Specialist",
"tint": "#dcf5ec",
"photo": {
"src": "/team-reveal-cards/member-2.webp",
"alt": ""
},
"social": [
{
"network": "linkedin",
"href": "https://www.linkedin.com/in/noah"
},
{
"network": "x",
"href": "https://x.com/noah"
},
{
"network": "email",
"href": "mailto:noah@tideway.example"
}
]
},
{
"name": "Maya Collins",
"role": "Account Manager",
"tint": "#cfe6f2",
"photo": {
"src": "/team-reveal-cards/member-3.webp",
"alt": ""
},
"social": [
{
"network": "linkedin",
"href": "https://www.linkedin.com/in/maya"
},
{
"network": "x",
"href": "https://x.com/maya"
},
{
"network": "email",
"href": "mailto:maya@tideway.example"
}
]
},
{
"name": "Ethan Ward",
"role": "Fleet Coordinator",
"tint": "#fde3d8",
"photo": {
"src": "/team-reveal-cards/member-4.webp",
"alt": ""
},
"social": [
{
"network": "linkedin",
"href": "https://www.linkedin.com/in/ethan"
},
{
"network": "x",
"href": "https://x.com/ethan"
},
{
"network": "email",
"href": "mailto:ethan@tideway.example"
}
]
}
]
};
const badgeImageCtaBandProps = {
"badgeText": "Shipping · Cargo · Freight · Transport · ",
"heading": "Let's move your cargo with confidence",
"points": [
"Experienced freight specialists",
"On-time delivery commitment",
"Modern, tracked fleet"
],
"cta": {
"label": "Get a Quote",
"href": "/contact"
},
"image": {
"src": "/badge-image-cta-band/port-ship.webp",
"alt": "A container ship moored under port cranes on a clear day"
}
};
const scrollProgressTimelineProps = {
"heading": "Building excellence in freight since day one",
"milestones": [
{
"period": "2025–2026",
"title": "Expanding our national network",
"text": "New cross-dock hubs on both coasts and a second rail partner cut average transit times by a full day."
},
{
"period": "2020–2022",
"title": "Live tracking for every load",
"text": "We rolled out real-time shipment tracking and digital proof of delivery across the whole fleet."
},
{
"period": "2009–2010",
"title": "Company founded",
"text": "Started with three trucks and one promise: dependable freight, and a person who answers the phone."
}
]
};
const portraitRatingTestimonialsProps = {
"testimonials": [
{
"quote": "They took over our weekly store deliveries and the missed-appointment calls simply stopped. Every load shows up in the tracker before we think to ask.",
"name": "Marcus Hale",
"role": "Operations Manager",
"score": "5.0",
"avatar": {
"src": "/portrait-rating-testimonials/avatar-1.webp",
"alt": ""
}
},
{
"quote": "Customs paperwork used to cost us a week per container. Now it is cleared before the ship docks, and our retail branches stopped running short.",
"name": "Leila Moreno",
"role": "Retail Logistics Lead",
"score": "5.0",
"avatar": {
"src": "/portrait-rating-testimonials/avatar-2.webp",
"alt": ""
}
},
{
"quote": "Their cold-chain loads arrive documented, on temperature and on time. From pickup confirmation to unloading, nothing gets lost in a handoff.",
"name": "Owen Castillo",
"role": "Distribution Coordinator",
"score": "5.0",
"avatar": {
"src": "/portrait-rating-testimonials/avatar-3.webp",
"alt": ""
}
},
{
"quote": "Building materials now reach our job sites on the day we plan for. The drivers call ahead and the site crews are never left waiting.",
"name": "Hannah Brooks",
"role": "Procurement Head",
"score": "5.0",
"avatar": {
"src": "/portrait-rating-testimonials/avatar-4.webp",
"alt": ""
}
}
],
"portrait": {
"src": "/portrait-rating-testimonials/portrait.webp",
"alt": "A smiling site worker in a hard hat and high-visibility vest"
},
"rating": {
"score": "4.9/5.0",
"count": "(2K Reviews)"
}
};
const shipCtaFooterProps = {
"heading": "Explore our *real world* freight services",
"cta": {
"label": "Get a Quote",
"href": "/contact"
},
"brandName": "Tideway",
"tagline": "A freight partner that plans every load and answers the phone when it matters.",
"newsletter": {
"action": "/subscribe",
"label": "Email address",
"placeholder": "you@company.com",
"button": "Submit"
},
"columns": [
{
"heading": "Services",
"links": [
{
"label": "Freight Services",
"href": "/services/freight"
},
{
"label": "Cargo Transportation",
"href": "/services/cargo"
},
{
"label": "Road Transport",
"href": "/services/road"
},
{
"label": "Global Shipping",
"href": "/services/global"
}
]
},
{
"heading": "Company",
"links": [
{
"label": "About Us",
"href": "/about"
},
{
"label": "Contact",
"href": "/contact"
},
{
"label": "Careers",
"href": "/careers"
}
]
}
],
"contact": [
{
"label": "(555) 014-2290",
"href": "tel:+15550142290"
},
{
"label": "hello@tideway.example",
"href": "mailto:hello@tideway.example"
},
{
"label": "1200 Harbor Way, Oakland, CA",
"href": "https://maps.google.com/?q=1200+Harbor+Way+Oakland+CA"
}
],
"legalLinks": [
{
"label": "Terms & Conditions",
"href": "/terms"
},
{
"label": "Privacy Policy",
"href": "/privacy"
}
],
"social": [
{
"network": "facebook",
"href": "https://facebook.com"
},
{
"network": "x",
"href": "https://x.com"
},
{
"network": "instagram",
"href": "https://instagram.com"
}
],
"background": {
"src": "/ship-cta-footer/ship-at-sea.webp"
}
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About [Business name] — [Freight promise]</title>
<meta name="description" content="[One-sentence story of the company, its history and its team.]" />
<style is:global>
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: #ffffff;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
color: #111;
}
.skip-link {
position: absolute;
left: 1rem;
top: -100px;
z-index: 1000;
padding: 0.6rem 1rem;
background: #111;
color: #fff;
border-radius: 6px;
}
.skip-link:focus {
top: 1rem;
}
</style>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<!-- Scroll pill header -->
<ScrollPillHeader {...scrollPillHeaderProps} />
<main id="main">
<!-- Tag title banner -->
<TagTitleBanner {...tagTitleBannerProps} />
<!-- About stats intro -->
<AboutStatsIntro {...aboutStatsIntroProps} />
<!-- Logo marquee strip -->
<LogoMarqueeStrip {...logoMarqueeStripProps} />
<!-- Checklist feature grid -->
<ChecklistFeatureGrid {...checklistFeatureGridProps} />
<!-- Team reveal cards -->
<TeamRevealCards {...teamRevealCardsProps} />
<!-- Badge image CTA band -->
<BadgeImageCtaBand {...badgeImageCtaBandProps} />
<!-- Scroll progress timeline -->
<ScrollProgressTimeline {...scrollProgressTimelineProps} />
<!-- Portrait rating testimonials -->
<PortraitRatingTestimonials {...portraitRatingTestimonialsProps} />
</main>
<!-- Ship CTA footer -->
<ShipCtaFooter {...shipCtaFooterProps} />
</body>
</html>
===== END src/pages/tideway-about.astro =====
===== FILE (required): src/components/ScrollPillHeader.astro =====
---
/**
* ScrollPillHeader
*
* A 1350px pill bar fixed 20px from the top of the viewport. Over the hero it
* is a faint white wash with white type; once the page scrolls past
* `threshold` it turns solid white with navy type. Logo on the left, links in
* the middle (an item with `children` opens a small dropdown card), an orange
* "Get a Quote" pill on the right. Below 1000px it folds to the logo and a
* round menu button that opens a white card under the bar.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface ScrollPillHeaderLink {
label: string;
href: string;
}
export interface ScrollPillHeaderItem {
label: string;
/** Required for a plain link; ignored on a dropdown trigger. */
href?: string;
/** Turns the item into a dropdown trigger. */
children?: ScrollPillHeaderLink[];
/**
* Mega-menu shaped data from other headers in this library. The links of
* every group are flattened into one dropdown list; headings are dropped.
*/
groups?: { heading?: string; links: ScrollPillHeaderLink[] }[];
}
export interface ScrollPillHeaderProps {
items?: ScrollPillHeaderItem[];
/** Pass Astro.url.pathname. Matching links get aria-current="page". */
currentPath?: string;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark. Give a light and a dark version. */
logo?: { src: string; srcOnDark?: string; alt: string; width?: number; height?: number };
/** The orange pill. `null` drops it. */
cta?: ScrollPillHeaderLink | null;
/** Always solid white, for pages whose top is not a dark hero. */
solid?: boolean;
/** Scroll distance in px after which the bar turns solid. */
threshold?: number;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends ScrollPillHeaderProps {}
/* No invented site: links, brand and CTA are all the caller's. Each block
renders only when its data is supplied; with none, nothing renders. */
const {
items: rawItems = [],
currentPath,
brandName,
brandHref = "/",
logo,
cta = null,
solid = false,
threshold = 8,
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
id,
} = Astro.props;
const items = rawItems.map((item) => ({
label: item.label,
href: item.href ?? "#",
children: item.children ?? item.groups?.flatMap((g) => g.links),
}));
const seed = id ? id : `sph-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href?: string) => (href && SAFE.test(href.trim()) ? href.trim() : "#");
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) => Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: { children?: ScrollPillHeaderLink[] }) => (item.children ?? []).some((c) => isCurrent(c.href));
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasMenu = hasItems || Boolean(cta);
const hasContent = hasBrand || hasMenu;
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
class="sph"
id={id}
data-header-root
data-sph-root
data-sph-threshold={threshold}
data-solid={solid ? "" : undefined}
>
<div class="sph-bar">
{hasBrand && (
<a class="sph-brand" href={safeHref(brandHref)} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<>
<img class="sph-logo sph-logo--light" src={logo.src} alt={logo.alt} width={logo.width ?? 167} height={logo.height ?? 43} />
{logo.srcOnDark && (
<img class="sph-logo sph-logo--dark" src={logo.srcOnDark} alt="" width={logo.width ?? 167} height={logo.height ?? 43} />
)}
</>
) : (
<>
<svg class="sph-mark" viewBox="0 0 40 40" aria-hidden="true">
<path d="M6 21a14 14 0 0 1 28 0h-6a8 8 0 0 0-16 0Z" fill="currentColor" />
<path d="M20 17 11 30h6v7h6v-7h6Z" fill="currentColor" />
</svg>
<span class="sph-word">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="sph-nav" aria-label={navLabel}>
<ul class="sph-list">
{
items.map((item, i) =>
item.children?.length ? (
<li class="sph-item sph-item--drop" data-sph-drop>
<button
type="button"
class="sph-link sph-trigger"
aria-expanded="false"
aria-controls={`${seed}-drop-${i}`}
data-sph-trigger
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="sph-chev" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2 4.25 6 8l4-3.75" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<ul class="sph-drop" id={`${seed}-drop-${i}`} data-sph-panel>
{item.children.map((child) => (
<li>
<a href={safeHref(child.href)} aria-current={isCurrent(child.href) ? "page" : undefined}>
{child.label}
</a>
</li>
))}
</ul>
</li>
) : (
<li class="sph-item">
<a class="sph-link" href={safeHref(item.href)} aria-current={isCurrent(item.href) ? "page" : undefined}>
{item.label}
</a>
</li>
),
)
}
</ul>
</nav>
)}
{
cta && (
<a class="sph-cta" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="sph-cta-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
{hasMenu && (
<button type="button" class="sph-burger" aria-expanded="false" aria-controls={menuId} data-sph-burger>
<span class="sph-sr">{menuLabel}</span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9.25" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M12 7.5v9M7.5 12h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
)}
</div>
{hasMenu && (
<div class="sph-menu" id={menuId} data-sph-menu>
{hasItems && (
<ul>
{
items.flatMap((item) =>
item.children?.length
? item.children.map((child) => (
<li>
<a href={safeHref(child.href)} aria-current={isCurrent(child.href) ? "page" : undefined}>
{child.label}
</a>
</li>
))
: [
<li>
<a href={safeHref(item.href)} aria-current={isCurrent(item.href) ? "page" : undefined}>
{item.label}
</a>
</li>,
],
)
}
</ul>
)}
{
cta && (
<a class="sph-cta sph-cta--menu" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="sph-cta-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
)}
</header>
)}
<style>
.sph {
--sph-navy: var(--tw-navy, #0b4654);
--sph-deep: var(--tw-navy-deep, #052931);
--sph-accent: var(--tw-accent, #ff6d45);
--sph-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--sph-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--sph-ease: cubic-bezier(0.44, 0, 0.56, 1);
--sph-ink: #fff;
position: fixed;
top: 12px;
left: 50%;
z-index: 40;
width: min(1350px, calc(100% - 24px));
transform: translateX(-50%);
font-family: var(--sph-body);
}
.sph *,
.sph *::before,
.sph *::after {
box-sizing: border-box;
}
.sph[data-scrolled],
.sph[data-solid],
.sph[data-sph-open] {
--sph-ink: var(--sph-navy);
}
.sph-bar {
position: relative;
display: flex;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 12px 0 16px;
border-radius: 999px;
background: rgb(255 255 255 / 0.1);
color: var(--sph-ink);
/* No colour transition here: the links carry their own, and a second
one on the parent would chain behind it. */
transition:
background-color 0.4s var(--sph-ease),
box-shadow 0.4s var(--sph-ease);
}
.sph[data-scrolled] .sph-bar,
.sph[data-solid] .sph-bar,
.sph[data-sph-open] .sph-bar {
background: #fff;
box-shadow: 0 0 0 1px rgb(11 70 84 / 0.08);
}
.sph-brand {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: auto;
color: inherit;
text-decoration: none;
border-radius: 8px;
}
.sph-mark {
width: 32px;
height: 32px;
color: var(--sph-accent);
}
.sph-word {
font: 500 26px/1 var(--sph-display);
letter-spacing: -0.5px;
}
.sph-logo {
display: block;
width: auto;
height: 34px;
}
.sph-logo--dark {
display: none;
}
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--dark {
display: block;
}
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--dark ~ .sph-logo--light,
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--light:has(+ .sph-logo--dark) {
display: none;
}
.sph-nav,
.sph > .sph-bar > .sph-cta {
display: none;
}
.sph-list {
display: flex;
align-items: center;
gap: 35px;
margin: 0;
padding: 0;
list-style: none;
}
.sph-item {
position: relative;
}
.sph-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 0;
border: 0;
background: none;
color: inherit;
font: 500 16px/25.6px var(--sph-body);
letter-spacing: -0.32px;
text-decoration: none;
cursor: pointer;
transition: color 0.3s var(--sph-ease);
}
.sph-link:hover,
.sph-item--drop[data-open] .sph-trigger,
.sph-link[aria-current="page"],
.sph-trigger[data-current] {
color: var(--sph-accent);
}
.sph-chev {
width: 14px;
height: 14px;
transition: transform 0.3s var(--sph-ease);
}
.sph-item--drop[data-open] .sph-chev {
transform: rotate(180deg);
}
.sph-drop {
position: absolute;
top: calc(100% + 16px);
left: 0;
min-width: 290px;
margin: 0;
padding: 6px 10px;
list-style: none;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(5 41 49 / 0.14);
opacity: 0;
visibility: hidden;
transform: translateY(6px);
transition:
opacity 0.25s var(--sph-ease),
transform 0.25s var(--sph-ease),
visibility 0s linear 0.25s;
}
/* A bridge over the gap, so moving the pointer from the trigger to the
card does not cross empty space and close it. */
.sph-drop::before {
content: "";
position: absolute;
inset: -18px 0 100% 0;
}
.sph-item--drop[data-open] .sph-drop {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0s;
}
.sph-drop li + li {
border-top: 1px solid rgb(11 70 84 / 0.1);
}
.sph-drop a {
display: block;
padding: 10px 0;
color: var(--sph-navy);
font: 500 16px/1.4 var(--sph-body);
text-transform: uppercase;
text-decoration: none;
transition: color 0.3s var(--sph-ease);
}
.sph-drop a:hover,
.sph-drop a[aria-current="page"] {
color: var(--sph-accent);
}
.sph-cta {
display: inline-flex;
align-items: center;
gap: 12px;
height: 50px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--sph-accent);
color: #fff;
font: 400 16px/25.6px var(--sph-body);
letter-spacing: -0.32px;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.3s var(--sph-ease);
}
.sph-cta:hover {
background: var(--sph-deep);
}
.sph-cta-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--sph-accent);
transition: transform 0.3s var(--sph-ease);
}
.sph-cta-ic svg {
width: 18px;
height: 18px;
}
.sph-cta:hover .sph-cta-ic {
transform: rotate(-45deg);
}
.sph-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--sph-accent);
cursor: pointer;
}
.sph-burger svg {
width: 26px;
height: 26px;
transition: transform 0.3s var(--sph-ease);
}
.sph[data-sph-open] .sph-burger svg {
transform: rotate(45deg);
}
.sph-menu {
position: absolute;
top: calc(100% + 10px);
left: 0;
right: 0;
display: none;
padding: 12px 20px 20px;
border-radius: 20px;
background: #fff;
box-shadow: 0 16px 40px rgb(5 41 49 / 0.18);
}
.sph[data-sph-open] .sph-menu {
display: block;
}
.sph-menu ul {
margin: 0 0 16px;
padding: 0;
list-style: none;
}
.sph-menu li + li {
border-top: 1px solid rgb(11 70 84 / 0.1);
}
.sph-menu a:not(.sph-cta) {
display: block;
padding: 12px 0;
color: var(--sph-navy);
font: 500 17px/1.4 var(--sph-body);
text-decoration: none;
}
.sph-menu a:not(.sph-cta):hover,
.sph-menu a[aria-current="page"] {
color: var(--sph-accent);
}
.sph-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.sph a:focus-visible,
.sph button:focus-visible {
outline: 2px solid var(--sph-accent);
outline-offset: 3px;
}
/* No JavaScript: no menu button, and the phone card is shown under the bar
as a plain list so every link stays reachable. */
.sph:not([data-sph-ready]) .sph-burger {
display: none;
}
.sph:not([data-sph-ready]) .sph-menu {
display: block;
}
.sph:not([data-sph-ready]) .sph-bar {
background: #fff;
--sph-ink: var(--sph-navy);
color: var(--sph-navy);
}
.sph:not([data-sph-ready]) .sph-item--drop:focus-within .sph-drop,
.sph:not([data-sph-ready]) .sph-item--drop:hover .sph-drop {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0s;
}
@media (min-width: 810px) {
.sph {
top: 20px;
width: min(1350px, calc(100% - 60px));
}
.sph-bar {
height: 80px;
padding: 0 16px 0 22px;
}
}
@media (min-width: 1000px) {
.sph {
width: min(1350px, calc(100% - 90px));
}
.sph-bar {
height: 90px;
padding: 0 20px 0 22px;
}
.sph-nav {
display: block;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.sph > .sph-bar > .sph-cta {
display: inline-flex;
}
.sph-burger,
.sph:not([data-sph-ready]) .sph-menu,
.sph .sph-menu {
display: none !important;
}
.sph-mark {
width: 38px;
height: 38px;
}
.sph-word {
font-size: 32px;
}
.sph-logo {
height: 43px;
}
}
@media (prefers-reduced-motion: reduce) {
.sph-bar,
.sph-link,
.sph-chev,
.sph-drop,
.sph-cta,
.sph-cta-ic,
.sph-burger svg {
transition: none;
}
.sph-cta:hover .sph-cta-ic {
transform: none;
}
}
@media (forced-colors: active) {
.sph-bar,
.sph-drop,
.sph-menu {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* ScrollPillHeader controller: the scroll swap, the desktop dropdowns and
* the phone menu. One closure per instance, every lookup scoped to its
* root, every listener on one AbortController aborted before a client-side
* navigation swaps the document. No body scroll lock.
*/
function initScrollPillHeader(root: HTMLElement) {
if (root.dataset.sphReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-sph-burger]");
const menu = root.querySelector<HTMLElement>("[data-sph-menu]");
root.dataset.sphReady = "";
const controller = new AbortController();
const { signal } = controller;
const threshold = Number(root.dataset.sphThreshold) || 8;
const desktop = window.matchMedia("(min-width: 1000px)");
const hoverable = window.matchMedia("(hover: hover)");
/* ---- scroll swap ---- */
let ticking = false;
const applyScroll = () => {
ticking = false;
root.toggleAttribute("data-scrolled", window.scrollY > threshold);
};
applyScroll();
window.addEventListener(
"scroll",
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(applyScroll);
},
{ passive: true, signal },
);
/* ---- dropdowns ---- */
const drops = [...root.querySelectorAll<HTMLElement>("[data-sph-drop]")];
const setDrop = (drop: HTMLElement, open: boolean, returnFocus = false) => {
const trigger = drop.querySelector<HTMLButtonElement>("[data-sph-trigger]");
if (!trigger || drop.hasAttribute("data-open") === open) return;
drop.toggleAttribute("data-open", open);
trigger.setAttribute("aria-expanded", String(open));
if (!open && returnFocus) trigger.focus({ preventScroll: true });
};
const closeDrops = (except?: HTMLElement) => drops.forEach((d) => d !== except && setDrop(d, false));
for (const drop of drops) {
const trigger = drop.querySelector<HTMLButtonElement>("[data-sph-trigger]");
if (!trigger) continue;
trigger.addEventListener(
"click",
() => {
const next = !drop.hasAttribute("data-open");
closeDrops(drop);
setDrop(drop, next);
},
{ signal },
);
drop.addEventListener(
"pointerenter",
(e) => {
if (e.pointerType === "mouse" && hoverable.matches) {
closeDrops(drop);
setDrop(drop, true);
}
},
{ signal },
);
drop.addEventListener(
"pointerleave",
(e) => {
if (e.pointerType === "mouse" && hoverable.matches) setDrop(drop, false);
},
{ signal },
);
drop.addEventListener(
"focusout",
(e) => {
const next = e.relatedTarget as Node | null;
if (next && !drop.contains(next)) setDrop(drop, false);
},
{ signal },
);
}
/* ---- phone menu ---- */
let open = false;
/* No links and no CTA means no menu button and no card: the scroll
swap and dropdowns above still run. */
const setMenu = (next: boolean, returnFocus = false) => {
if (!burger || !menu || open === next) return;
open = next;
root.toggleAttribute("data-sph-open", next);
burger.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger.focus({ preventScroll: true });
};
burger?.addEventListener("click", () => setMenu(!open), { signal });
menu?.addEventListener(
"click",
(e) => {
if ((e.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
root.addEventListener(
"focusout",
(e) => {
const next = e.relatedTarget as Node | null;
if (open && next && !root.contains(next)) setMenu(false);
},
{ signal },
);
/* Escape and outside click on the document: after a client-side
navigation focus can sit outside the header. */
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") return;
const openDrop = drops.find((d) => d.hasAttribute("data-open"));
if (openDrop) {
e.preventDefault();
setDrop(openDrop, false, openDrop.contains(document.activeElement));
} else if (open) {
e.preventDefault();
setMenu(false, true);
}
},
{ signal },
);
document.addEventListener(
"pointerdown",
(e) => {
if (root.contains(e.target as Node)) return;
closeDrops();
setMenu(false);
},
{ signal },
);
desktop.addEventListener(
"change",
() => {
setMenu(false);
closeDrops();
},
{ signal },
);
document.addEventListener(
"astro:before-swap",
() => {
setMenu(false);
closeDrops();
controller.abort();
delete root.dataset.sphReady;
},
{ once: true, signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-sph-root]").forEach(initScrollPillHeader);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ScrollPillHeader.astro =====
===== FILE (required): src/components/TagTitleBanner.astro =====
---
/**
* TagTitleBanner
*
* An inner-page banner built as a split: the breadcrumb and a heavy
* uppercase title with a stamped tag on a navy field, and — when a photo is
* given — a full-height photo panel meeting it at an accent seam. Below
* 700px of its own width the photo becomes a strip that closes the banner.
* Leaves room at the top for a fixed pill header.
*
* Sizes follow the component's own width (container queries), so it holds
* the same composition in a sidebar layout or a narrow column. The title
* rises in on load; the breadcrumb follows. Both end at rest and are off
* under reduced motion.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface TagTitleCrumb {
label: string;
/** Omit on the last crumb, the current page. */
href?: string;
}
export interface TagTitleBannerProps {
title?: string;
/** The orange box after the title. `""` drops it. */
tag?: string;
breadcrumbs?: TagTitleCrumb[];
/** Background photo. `src: ""` renders the navy band alone. */
background?: { src?: string };
animate?: boolean;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends TagTitleBannerProps {}
const {
title = "",
tag = "",
breadcrumbs: rawCrumbs = [],
background: bgProp,
animate = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* No photo by default: the navy band renders alone. No title, no banner. */
const background = { src: "", ...(bgProp ?? {}) };
const breadcrumbs = rawCrumbs.filter((c) => c?.label);
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
---
{title && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["ttb", className]} id={id} data-animate={animate ? "" : undefined} data-media={background.src ? "" : undefined}>
<div class="ttb-grid">
<div class="ttb-inner">
{
breadcrumbs.length > 0 && (
<nav class="ttb-crumbs" aria-label="Breadcrumb">
<ol>
{breadcrumbs.map((c, i) => (
<li>
{c.href && i < breadcrumbs.length - 1 ? (
<a href={safeHref(c.href)}>{c.label}</a>
) : (
<span aria-current={i === breadcrumbs.length - 1 ? "page" : undefined}>{c.label}</span>
)}
</li>
))}
</ol>
</nav>
)
}
<h1 class="ttb-title">
<span class="ttb-word">{title}</span>
{tag && <span class="ttb-tag">{tag}</span>}
</h1>
</div>
{
background.src && (
<div class="ttb-media" aria-hidden="true">
<img src={background.src} alt="" width="1920" height="600" fetchpriority="high" decoding="async" />
</div>
)
}
</div>
</section>
</>
)}
<style>
.ttb {
--ttb-navy: var(--tw-navy, #0b4654);
--ttb-accent: var(--tw-accent, #ff6d45);
--ttb-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--ttb-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--ttb-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--ttb-seam: 6px;
container-type: inline-size;
position: relative;
isolation: isolate;
overflow: hidden;
background: var(--ttb-navy);
color: #fff;
font-family: var(--ttb-body);
/* The closing edge: the banner ends on a line, never on open colour. */
border-bottom: var(--ttb-seam) solid var(--ttb-accent);
}
.ttb *,
.ttb *::before,
.ttb *::after {
box-sizing: border-box;
}
.ttb-grid {
display: grid;
}
/* Step 39.2: --tw-grid-max / --tw-grid-gutter put the title on a site grid; the fallbacks are the original 1440 / 64. */
.ttb-inner {
--ttb-max: var(--tw-grid-max, 1440px);
--ttb-gutter: var(--tw-grid-gutter, 64px);
}
.ttb-inner {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 18px;
min-width: 0;
padding: 124px 20px 40px;
}
/* ---- breadcrumb: wayfinding first, above the title ---- */
.ttb-crumbs ol {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 2px;
margin: 0;
padding: 0;
list-style: none;
font: 500 15px/1.4 var(--ttb-body);
}
.ttb-crumbs li {
display: flex;
align-items: center;
min-width: 0;
}
.ttb-crumbs li + li::before {
content: "/";
margin: 0 8px 0 6px;
color: rgb(255 255 255 / 0.4);
}
.ttb-crumbs a,
.ttb-crumbs span {
display: inline-block;
padding: 5px 0;
overflow-wrap: anywhere;
}
.ttb-crumbs a {
color: rgb(255 255 255 / 0.74);
text-decoration: underline;
text-decoration-color: rgb(255 255 255 / 0);
text-underline-offset: 4px;
transition: color 0.2s var(--ttb-ease), text-decoration-color 0.2s var(--ttb-ease);
}
.ttb-crumbs a:hover {
color: #fff;
text-decoration-color: rgb(255 255 255 / 0.6);
}
.ttb-crumbs a:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
border-radius: 2px;
}
.ttb-crumbs [aria-current] {
color: #fff;
font-weight: 600;
}
/* ---- title: one lockup, the tag stamped onto the end of it ---- */
.ttb-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.14em 0.2em;
margin: 0;
max-width: 16ch;
font: 700 clamp(40px, 11.5cqi, 60px) / 0.98 var(--ttb-display);
letter-spacing: -0.035em;
text-transform: uppercase;
text-wrap: balance;
overflow-wrap: anywhere;
}
.ttb-tag {
display: inline-block;
padding: 0.1em 0.26em 0.06em;
border: 2px solid transparent;
border-radius: 4px;
background: var(--ttb-accent);
color: var(--ttb-navy);
font-size: 0.62em;
line-height: 1;
letter-spacing: -0.02em;
white-space: nowrap;
}
/* ---- photo ---- */
.ttb-media {
position: relative;
min-height: 168px;
border-top: var(--ttb-seam) solid var(--ttb-accent);
}
.ttb-media img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 70% center;
}
/* A light navy grade so a busy photo sits in the palette instead of fighting it. */
.ttb-media::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgb(11 70 84 / 0.45), rgb(11 70 84 / 0.12) 55%, rgb(11 70 84 / 0.05));
}
/* ---- tablet and up: side by side, the seam runs top to bottom ---- */
@container (min-width: 700px) {
.ttb[data-media] .ttb-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.72fr);
}
.ttb-inner {
gap: 22px;
padding: 150px 40px 56px;
}
.ttb-title {
max-width: 14ch;
font-size: clamp(52px, 8.4cqi, 104px);
}
.ttb:not([data-media]) .ttb-title {
max-width: 20ch;
}
.ttb-media {
min-height: 0;
border-top: 0;
border-left: var(--ttb-seam) solid var(--ttb-accent);
}
}
@container (min-width: 1100px) {
.ttb-inner {
padding: 196px max(var(--ttb-gutter), calc((100cqi - var(--ttb-max)) / 2 + var(--ttb-gutter))) 72px;
}
.ttb[data-media] .ttb-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.78fr);
}
}
/* ---- motion: rises in, ends at rest ---- */
@keyframes ttb-rise {
from {
opacity: 0;
transform: translateY(var(--ttb-from, 18px));
}
}
.ttb[data-animate] .ttb-title {
animation: ttb-rise 0.7s var(--ttb-ease) both;
}
.ttb[data-animate] .ttb-crumbs {
--ttb-from: 10px;
animation: ttb-rise 0.6s var(--ttb-ease) 0.12s both;
}
@media (prefers-reduced-motion: reduce) {
.ttb[data-animate] .ttb-title,
.ttb[data-animate] .ttb-crumbs {
animation: none;
}
.ttb-crumbs a {
transition: none;
}
}
@media (forced-colors: active) {
.ttb {
border-bottom-color: CanvasText;
}
.ttb-media,
.ttb-tag {
border-color: CanvasText;
}
}
</style>
===== END src/components/TagTitleBanner.astro =====
===== FILE (required): public/tag-title-banner/port.webp =====
[binary asset — download https://astro.baysixmedia.com/tag-title-banner/port.webp and save it to public/tag-title-banner/port.webp]
===== FILE (required): src/components/AboutStatsIntro.astro =====
---
/**
* AboutStatsIntro
*
* An about-company section. Across the top, an outlined eyebrow on the left
* and a big three-line uppercase heading on the right, over a hairline.
* Below, a tall photo cut into a ticket shape (narrower tabs top and bottom)
* beside two pale stat cards (years in business; avatars, review count and a
* star score), a check-marked subheading, two paragraphs, a two-column
* checklist and an orange pill.
*
* Heading words rise in and the columns lift in the first time they scroll
* into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface AboutStatsIntroProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** No `src` renders a labelled placeholder. */
image?: { src?: string; alt?: string };
years?: { value: string; label: string } | null;
/**
* A review count and score from a real review source. Omit it (or pass
* `null`) and there is no review block: the component never supplies a
* rating or a number of reviews.
*/
reviews?: { count: string; label: string; score: string; avatars?: string[] } | null;
subheading?: string;
paragraphs?: string[];
checklist?: string[];
cta?: { label: string; href: string } | null;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends AboutStatsIntroProps {}
/*
* The heading, copy, checklist, button and photo are the caller's: nothing
* about a business is filled in here. "About Company" 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 {
eyebrow = "About Company",
heading = "",
headingLevel = "h2",
image: imageProp,
years = null,
reviews = null,
subheading = "",
paragraphs = [],
checklist = [],
cta = null,
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const texts = (paragraphs ?? []).filter((p) => typeof p === "string" && p.trim());
const checks = (checklist ?? []).filter((c) => typeof c === "string" && c.trim());
/* Nothing to say → nothing rendered: the generic eyebrow alone is not a section. */
const hasContent = Boolean(heading || years || reviews || subheading || texts.length > 0 || checks.length > 0 || cta);
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Sub = headingLevel === "h2" ? "h3" : "h4";
/* Stars are drawn only from a real numeric score, filled to it (4.8 of 5 fills 96%). Anything else, no stars. */
const scoreNum = reviews ? Number.parseFloat(String(reviews.score)) : Number.NaN;
const starFill = Number.isFinite(scoreNum) && scoreNum >= 0 && scoreNum <= 5 ? Math.round((scoreNum / 5) * 1000) / 10 : null;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section class:list={["asi", className]} id={id} style={`--asi-band:${background}`} data-asi-root>
<div class="asi-inner">
{(eyebrow || heading) && (
<div class="asi-head">
{eyebrow && <p class="asi-eyebrow">{eyebrow}</p>}
{heading && (
<Heading class="asi-heading" data-asi-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["asi-w", { "asi-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
</div>
)}
<div class="asi-body">
<div class="asi-media" data-asi-reveal="up">
{
image.src ? (
<img src={image.src} alt={image.alt} width="848" height="1200" loading="lazy" decoding="async" />
) : (
<div class="asi-ph" role="img" aria-label="Image placeholder: tall portrait photo">
<span>Image · portrait · 848 × 1200</span>
</div>
)
}
</div>
<div class="asi-copy" data-asi-reveal="up" style="--d:0.12s">
{
(years || reviews) && (
<ul class="asi-stats">
{years && (
<li class="asi-stat">
<p class="asi-years">{years.value}</p>
<p class="asi-stat-label">{years.label}</p>
</li>
)}
{reviews && (
<li class="asi-stat">
<div class="asi-reviews-top">
{reviews.avatars && reviews.avatars.length > 0 && (
<span class="asi-avatars" aria-hidden="true">
{reviews.avatars.map((src) => (
<img src={src} alt="" width="36" height="36" loading="lazy" decoding="async" />
))}
</span>
)}
<p class="asi-count">
{reviews.count}
<span>{reviews.label}</span>
</p>
</div>
<p class="asi-score">
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="m10 1.8 2.5 5.3 5.8.7-4.3 4 1.1 5.7L10 14.7l-5.1 2.8L6 11.8l-4.3-4 5.8-.7Z" fill="currentColor" />
</svg>
{reviews.score}
</p>
{starFill !== null && (
<p class="asi-stars" aria-hidden="true" style={`--asi-fill: ${starFill}%`}>
<span class="asi-stars-fill">★★★★★</span>★★★★★
</p>
)}
</li>
)}
</ul>
)
}
{
subheading && (
<Sub class="asi-sub">
<span class="asi-sub-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="m6 12.5 4 4 8-9" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>
{subheading}
</Sub>
)
}
{texts.map((p) => <p class="asi-text">{p}</p>)}
{
checks.length > 0 && (
<ul class="asi-checks">
{checks.map((c) => (
<li>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{c}
</li>
))}
</ul>
)
}
{
cta && (
<a class="asi-btn" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="asi-btn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
</div>
</div>
</section>
)}
<style>
.asi {
--asi-navy: var(--tw-navy, #0b4654);
--asi-deep: var(--tw-navy-deep, #052931);
--asi-accent: var(--tw-accent, #ff6d45);
--asi-surface: var(--tw-surface, #f0f8fa);
--asi-muted: var(--tw-muted, #616161);
--asi-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--asi-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--asi-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
--asi-ease: cubic-bezier(0.44, 0, 0.56, 1);
background: var(--asi-band);
color: var(--asi-navy);
font-family: var(--asi-body);
}
.asi *,
.asi *::before,
.asi *::after {
box-sizing: border-box;
}
.asi-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.asi-head {
display: grid;
gap: 18px;
justify-items: start;
padding-bottom: 40px;
border-bottom: 1px solid rgb(11 70 84 / 0.1);
}
.asi-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--asi-accent);
border-radius: 999px;
color: var(--asi-accent);
font: 500 15px/24px var(--asi-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.asi-heading {
max-width: 800px;
margin: 0;
font: 700 28px/1.2 var(--asi-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.asi-w {
display: inline-block;
}
.asi-accent {
color: var(--asi-accent);
}
.asi-body {
display: grid;
gap: 40px;
margin-top: 40px;
}
.asi-media {
aspect-ratio: 631 / 893;
max-height: 720px;
}
/* Ticket shape: full width in the middle, narrower tabs top and bottom. */
.asi-media img,
.asi-ph {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
clip-path: polygon(
18% 0, 82% 0, 84% 1%, 85% 3%, 85% 7.5%, 87% 8.5%, 98% 8.5%, 99.5% 9.5%, 100% 11.5%,
100% 88.5%, 99.5% 90.5%, 98% 91.5%, 87% 91.5%, 85% 92.5%, 85% 97%, 84% 99%, 82% 100%,
18% 100%, 16% 99%, 15% 97%, 15% 92.5%, 13% 91.5%, 2% 91.5%, 0.5% 90.5%, 0 88.5%,
0 11.5%, 0.5% 9.5%, 2% 8.5%, 13% 8.5%, 15% 7.5%, 15% 3%, 16% 1%
);
}
.asi-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #e3eef1 0 12px, #edf5f7 12px 24px);
font: 500 13px/1.4 var(--asi-body);
}
.asi-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.asi-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
width: 100%;
margin: 0 0 36px;
padding: 0;
list-style: none;
}
.asi-stat {
padding: 22px;
border-radius: 20px;
background: var(--asi-surface);
}
.asi-years {
margin: 0;
color: var(--asi-accent);
font: 700 40px/1.2 var(--asi-display);
letter-spacing: -0.8px;
}
.asi-stat-label {
margin: 24px 0 0;
font: 600 20px/1.2 var(--asi-card-font);
letter-spacing: -0.8px;
}
.asi-reviews-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.asi-avatars {
display: flex;
}
.asi-avatars img {
width: 36px;
height: 36px;
border: 2px solid var(--asi-surface);
border-radius: 50%;
object-fit: cover;
}
.asi-avatars img + img {
margin-left: -12px;
}
.asi-count {
margin: 0;
font: 700 16px/1.3 var(--asi-card-font);
letter-spacing: -0.64px;
}
.asi-count span {
display: block;
font-weight: 600;
}
.asi-score {
display: flex;
align-items: center;
gap: 8px;
margin: 22px 0 0;
color: var(--asi-accent);
font: 700 18px/25.2px var(--asi-card-font);
letter-spacing: -0.72px;
}
.asi-score svg {
width: 24px;
height: 24px;
}
/* Five pale stars with the real score's share filled over them. */
.asi-stars {
position: relative;
display: inline-block;
margin: 8px 0 0;
color: color-mix(in srgb, var(--asi-accent) 22%, transparent);
font-size: 22px;
line-height: 1;
letter-spacing: 3px;
white-space: nowrap;
}
.asi-stars-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--asi-fill);
overflow: hidden;
color: var(--asi-accent);
}
.asi-sub {
display: flex;
align-items: center;
gap: 12px;
margin: 0;
font: 700 22px/1.4 var(--asi-card-font);
letter-spacing: -0.88px;
}
.asi-sub-ic {
display: grid;
flex: none;
place-items: center;
width: 35px;
height: 35px;
border-radius: 50%;
background: var(--asi-accent);
color: #fff;
}
.asi-sub-ic svg {
width: 20px;
height: 20px;
}
.asi-text {
max-width: 590px;
margin: 14px 0 0;
color: var(--asi-muted);
font: 400 15px/24px var(--asi-body);
letter-spacing: -0.3px;
}
.asi-checks {
display: grid;
gap: 13px 40px;
margin: 32px 0 0;
padding: 0;
list-style: none;
}
.asi-checks li {
display: flex;
align-items: center;
gap: 12px;
color: #000;
font: 400 16px/25.6px var(--asi-body);
letter-spacing: -0.32px;
}
.asi-checks svg {
flex: none;
width: 20px;
height: 20px;
color: var(--asi-accent);
}
.asi-btn {
display: inline-flex;
align-items: center;
gap: 12px;
height: 50px;
margin-top: 44px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--asi-accent);
color: #fff;
font: 400 16px/25.6px var(--asi-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: background-color 0.3s var(--asi-ease);
}
.asi-btn:hover {
background: var(--asi-deep);
}
.asi-btn-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--asi-accent);
transition: transform 0.3s var(--asi-ease);
}
.asi-btn-ic svg {
width: 18px;
height: 18px;
}
.asi-btn:hover .asi-btn-ic {
transform: rotate(-45deg);
}
.asi-btn:focus-visible {
outline: 2px solid var(--asi-navy);
outline-offset: 3px;
}
[data-asi-reveal="up"] {
transition:
opacity 0.8s var(--asi-ease) var(--d, 0s),
transform 0.8s var(--asi-ease) var(--d, 0s);
}
.asi[data-asi-ready] [data-asi-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.asi-w {
transition:
opacity 0.6s var(--asi-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--asi-ease) calc(var(--i) * 0.06s);
}
.asi[data-asi-ready] [data-asi-reveal="words"]:not([data-in]) .asi-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.asi-inner {
padding: 90px 40px;
}
.asi-head {
grid-template-columns: 220px minmax(0, 1fr);
align-items: center;
}
.asi-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.asi-checks {
grid-template-columns: repeat(2, auto);
justify-content: start;
}
.asi-media {
max-width: 560px;
justify-self: center;
width: 100%;
}
}
@media (min-width: 1200px) {
.asi-inner {
padding: 100px 60px 60px;
}
.asi-head {
grid-template-columns: 447px minmax(0, 1fr);
padding: 0 0 50px;
}
.asi-eyebrow {
margin-left: 37px;
}
.asi-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.asi-body {
grid-template-columns: 631px minmax(0, 1fr);
gap: 58px;
margin-top: 56px;
}
.asi-media {
max-width: none;
max-height: none;
}
.asi-copy {
padding-top: 22px;
}
.asi-stats {
gap: 20px;
margin-bottom: 44px;
}
.asi-stat {
min-height: 215px;
padding: 34px 30px;
}
.asi-years {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.asi-stat-label {
font-size: 24px;
line-height: 28.8px;
letter-spacing: -0.96px;
}
.asi-sub {
font-size: 26px;
line-height: 36.4px;
letter-spacing: -1.04px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-asi-reveal="up"],
.asi-w,
.asi-btn,
.asi-btn-ic {
transition: none;
}
.asi-btn:hover .asi-btn-ic {
transform: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initAboutStatsIntro(root: HTMLElement) {
if (root.dataset.asiReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.asiReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.05, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-asi-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.asiReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-asi-root]").forEach(initAboutStatsIntro);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/AboutStatsIntro.astro =====
===== FILE (required): public/about-stats-intro/ship-bow.webp =====
[binary asset — download https://astro.baysixmedia.com/about-stats-intro/ship-bow.webp and save it to public/about-stats-intro/ship-bow.webp]
===== FILE (required): public/about-stats-intro/avatar-1.webp =====
[binary asset — download https://astro.baysixmedia.com/about-stats-intro/avatar-1.webp and save it to public/about-stats-intro/avatar-1.webp]
===== FILE (required): public/about-stats-intro/avatar-2.webp =====
[binary asset — download https://astro.baysixmedia.com/about-stats-intro/avatar-2.webp and save it to public/about-stats-intro/avatar-2.webp]
===== FILE (required): public/about-stats-intro/avatar-3.webp =====
[binary asset — download https://astro.baysixmedia.com/about-stats-intro/avatar-3.webp and save it to public/about-stats-intro/avatar-3.webp]
===== FILE (required): src/components/LogoMarqueeStrip.astro =====
---
/**
* LogoMarqueeStrip
*
* A trust strip: a centred bold line ("10,000+ trusted customers…") between
* two hairlines, over a row of logo cards that scrolls left forever. Two
* looks: `"plain"` (white cards on the band) and `"tray"` (the cards sit in
* a pale rounded tray that clips them at its edges).
*
* The marquee is a CSS animation over two copies of the list; the second copy
* is hidden from assistive technology. Hovering or focusing inside pauses it.
* Under reduced motion it stops and the logos wrap onto as many rows as they
* need, so none are hidden off-screen.
*
* Ships no logos: pass `logos` (drawn glyph + name, or `src` for a real
* client logo). With none it renders nothing.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export type LogoGlyph = "sun" | "arch" | "print" | "leaf" | "stack" | "wave";
export interface MarqueeLogo {
name: string;
/** An image replaces the drawn glyph and name. */
src?: string;
glyph?: LogoGlyph;
/** Glyph colour. */
color?: string;
href?: string;
}
export interface LogoMarqueeStripProps {
/** Bold lead-in in orange, e.g. "10,000+". `""` drops it. */
highlight?: string;
/** The rest of the line. `""` drops the whole line. */
caption?: string;
logos?: MarqueeLogo[];
variant?: "plain" | "tray";
/** Seconds for one full loop. */
duration?: number;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends LogoMarqueeStripProps {}
const {
highlight = "",
caption = "",
logos: rawLogos = [],
variant = "plain",
duration = 32,
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const GLYPHS: Record<LogoGlyph, string> = {
sun: "M16 10a6 6 0 1 0 0 12 6 6 0 0 0 0-12Z M16 2v5 M16 25v5 M2 16h5 M25 16h5 M6 6l3.5 3.5 M22.5 22.5 26 26 M6 26l3.5-3.5 M22.5 9.5 26 6",
arch: "M5 28V16a11 11 0 0 1 22 0v12 M10 28V16a6 6 0 0 1 12 0v12 M15 28V16a1 1 0 0 1 2 0v12",
print: "M16 4a12 12 0 0 1 12 12v10 M16 9a7 7 0 0 1 7 7v10 M16 14a2 2 0 0 1 2 2v10 M16 4A12 12 0 0 0 4 16v10 M16 9a7 7 0 0 0-7 7v10 M14 16v10",
leaf: "M16 3l4 6-4 6-4-6Z M16 17l4 6-4 6-4-6Z M3 16l6-4 6 4-6 4Z M17 16l6-4 6 4-6 4Z",
stack: "M4 10l12-6 12 6-12 6Z M4 16l12 6 12-6 M4 22l12 6 12-6",
wave: "M3 12c4-6 9-6 13 0s9 6 13 0 M3 20c4-6 9-6 13 0s9 6 13 0",
};
const renderList = [0, 1];
/* A logo without a name has no accessible name: dropped. No logos, no strip (the line alone would be a claim). */
const logos = rawLogos.filter((logo) => logo?.name);
---
{logos.length > 0 && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
class:list={["lms", `lms--${variant}`, className]}
id={id}
style={`--lms-band:${background};--lms-duration:${duration}s`}
aria-label={caption ? `${highlight} ${caption}`.trim() : "Customers"}
>
{
caption && (
<p class="lms-caption">
{highlight && <span class="lms-hl">{highlight}</span>} {caption}
</p>
)
}
<div class="lms-viewport">
<div class="lms-track">
{
renderList.map((copy) => (
<ul class="lms-list" aria-hidden={copy === 1 ? "true" : undefined}>
{logos.map((logo) => {
const inner = logo.src ? (
<img src={logo.src} alt={copy === 1 ? "" : logo.name} height="40" loading="lazy" decoding="async" />
) : (
<>
<svg viewBox="0 0 32 32" aria-hidden="true" style={`color:${logo.color ?? "currentColor"}`}>
<path d={GLYPHS[logo.glyph ?? "sun"]} fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>{logo.name}</span>
</>
);
return (
<li class="lms-card">
{logo.href ? (
<a href={safeHref(logo.href)} tabindex={copy === 1 ? -1 : undefined}>
{inner}
</a>
) : (
<span class="lms-logo">{inner}</span>
)}
</li>
);
})}
</ul>
))
}
</div>
</div>
</section>
</>
)}
<style>
.lms {
--lms-navy: var(--tw-navy, #0b4654);
--lms-accent: var(--tw-accent, #ff6d45);
--lms-surface: var(--tw-surface, #f0f8fa);
--lms-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
--lms-gap: 10px;
padding: 40px 0 48px;
overflow: hidden;
background: var(--lms-band);
color: var(--lms-navy);
}
.lms *,
.lms *::before,
.lms *::after {
box-sizing: border-box;
}
.lms-caption {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
max-width: 1310px;
margin: 0 auto 22px;
padding: 0 16px;
color: #000;
font: 700 16px/1.4 var(--lms-card-font);
letter-spacing: -0.64px;
text-align: center;
}
.lms-caption::before,
.lms-caption::after {
content: "";
flex: 1 1 0;
max-width: 440px;
height: 1px;
background: rgb(11 70 84 / 0.14);
}
.lms-caption::before {
margin-right: 20px;
}
.lms-caption::after {
margin-left: 20px;
}
.lms-hl {
color: var(--lms-accent);
}
.lms-viewport {
overflow: hidden;
}
.lms--tray .lms-viewport {
max-width: 1320px;
margin: 0 16px;
padding: 10px;
border-radius: 20px;
background: var(--lms-surface);
}
.lms--plain .lms-viewport {
-webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
.lms-track {
display: flex;
width: max-content;
animation: lms-scroll var(--lms-duration) linear infinite;
}
.lms:hover .lms-track,
.lms:focus-within .lms-track {
animation-play-state: paused;
}
@keyframes lms-scroll {
to {
transform: translateX(-50%);
}
}
.lms-list {
display: flex;
gap: var(--lms-gap);
margin: 0;
padding: 0 var(--lms-gap) 0 0;
list-style: none;
}
.lms-card {
flex: none;
width: 220px;
height: 120px;
border-radius: 20px;
background: #fff;
}
.lms--plain .lms-card {
box-shadow: inset 0 0 0 1px rgb(11 70 84 / 0.06);
}
.lms-card a,
.lms-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
height: 100%;
border-radius: inherit;
color: inherit;
font: 700 22px/1 var(--lms-card-font);
letter-spacing: -0.6px;
text-decoration: none;
text-transform: uppercase;
}
.lms-card a:focus-visible {
outline: 2px solid var(--lms-accent);
outline-offset: -2px;
}
.lms-card svg {
flex: none;
width: 36px;
height: 36px;
}
.lms-card img {
max-width: 80%;
max-height: 44px;
object-fit: contain;
}
@media (min-width: 810px) {
.lms {
padding: 50px 0 60px;
}
.lms-caption {
font-size: 18px;
letter-spacing: -0.72px;
}
.lms--tray .lms-viewport {
margin: 0 40px;
}
.lms-card {
width: 252px;
height: 140px;
}
}
@media (min-width: 1200px) {
.lms--tray .lms-viewport {
margin: 0 auto;
width: min(1320px, calc(100% - 120px));
}
.lms--plain .lms-card {
width: 276px;
height: 153px;
}
}
/* Reduced motion: no scroll, one copy, wrapped so every logo shows. */
@media (prefers-reduced-motion: reduce) {
.lms-track {
width: auto;
animation: none;
}
.lms-list {
flex-wrap: wrap;
justify-content: center;
padding: 0;
}
.lms-list[aria-hidden="true"] {
display: none;
}
.lms--plain .lms-viewport {
-webkit-mask-image: none;
mask-image: none;
}
}
</style>
===== END src/components/LogoMarqueeStrip.astro =====
===== FILE (required): src/components/ChecklistFeatureGrid.astro =====
---
/**
* ChecklistFeatureGrid
*
* A "why choose us" section on a pale band: outlined eyebrow and a big
* uppercase heading over a 2×2 grid of white cards (round orange icon, title,
* hairline, two check-marked points), with two stacked photos in a column on
* the right.
*
* Heading words rise in and the cards and photos lift in, staggered, the first
* time they scroll into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export type ChecklistFeatureIcon = "shapes" | "team" | "chart" | "support" | "shield" | "clock";
export interface ChecklistFeature {
title: string;
points: string[];
icon?: ChecklistFeatureIcon;
}
export interface ChecklistFeatureGridProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
features?: ChecklistFeature[];
/** Top and bottom photos. `src: ""` renders a placeholder; `null` drops the column. */
images?: [{ src?: string; alt?: string }, { src?: string; alt?: string }] | null;
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ChecklistFeatureGridProps {}
const {
eyebrow = "Why Choose Us",
heading = "",
headingLevel = "h2",
features: featuresProp = [],
images: imagesProp = null,
background = "#f0f8fa",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/*
* Only the photos passed, never topped up from a demo pair. None passed drops
* the column; an entry with `src: ""` still draws its placeholder.
*/
const passedImages = (imagesProp ?? [])
.slice(0, 2)
.filter(Boolean)
.map((p) => ({ src: p.src ?? "", alt: p.alt ?? "" }));
const images = passedImages.length > 0 ? passedImages : null;
/* A card needs its title; one without is dropped. No cards, nothing renders. */
const features = featuresProp
.filter((f) => f && f.title)
.map((f) => ({ ...f, points: (f.points ?? []).filter(Boolean) }));
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const ICONS: Record<ChecklistFeatureIcon, string> = {
shapes: "M7 4l4 7H3Z M17 14a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z M4 14h7v7H4Z M14 17h7v4h-7Z",
team: "M12 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z M6 20v-2a6 6 0 0 1 12 0v2 M4 8l8-4 8 4 M12 14v2",
chart: "M4 20V4 M4 20h16 M7 15l4-4 3 3 5-6 M16 8h3v3",
support: "M9 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z M3 19v-1a5 5 0 0 1 10 0v1 M15 8h6 M15 12h6 M17 16h4",
shield: "M12 3 19 6v5c0 5-3 8.5-7 10-4-1.5-7-5-7-10V6Z M9 12l2 2 4-4",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z M12 7v5l3 2",
};
---
{features.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{features.length > 0 && (
<section class:list={["cfg", className]} id={id} style={`--cfg-band:${background}`} data-cfg-root>
<div class:list={["cfg-inner", { "cfg-inner--solo": !images }]}>
<div class="cfg-main">
{eyebrow && <p class="cfg-eyebrow">{eyebrow}</p>}
{words.length > 0 && (
<Heading class="cfg-heading" data-cfg-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["cfg-w", { "cfg-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
<ul class="cfg-grid">
{
features.map((f, i) => (
<li class="cfg-card" data-cfg-reveal="up" style={`--d:${i * 0.1}s`}>
<div class="cfg-card-head">
<span class="cfg-ic" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d={ICONS[f.icon ?? "shield"]} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<Title class="cfg-title">{f.title}</Title>
</div>
{f.points.length > 0 && (
<ul class="cfg-points">
{f.points.map((p) => (
<li>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{p}
</li>
))}
</ul>
)}
</li>
))
}
</ul>
</div>
{
images && (
<div class="cfg-photos">
{images.map((img, i) => (
<div class:list={["cfg-photo", `cfg-photo--${i}`]} data-cfg-reveal="up" style={`--d:${0.1 + i * 0.12}s`}>
{img.src ? (
<img src={img.src} alt={img.alt} width="834" height={i === 0 ? 514 : 874} loading="lazy" decoding="async" />
) : (
<div class="cfg-ph" role="img" aria-label="Image placeholder">
<span>Image · {i === 0 ? "834 × 514" : "834 × 874"}</span>
</div>
)}
</div>
))}
</div>
)
}
</div>
</section>
)}
<style>
.cfg {
--cfg-navy: var(--tw-navy, #0b4654);
--cfg-accent: var(--tw-accent, #ff6d45);
--cfg-muted: var(--tw-muted, #616161);
--cfg-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--cfg-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--cfg-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
--cfg-ease: cubic-bezier(0.44, 0, 0.56, 1);
background: var(--cfg-band);
color: var(--cfg-navy);
font-family: var(--cfg-body);
}
.cfg *,
.cfg *::before,
.cfg *::after {
box-sizing: border-box;
}
/* Step 39.2: --tw-grid-max / --tw-grid-gutter put the content on a site grid; the fallbacks are the original values. */
.cfg-inner {
display: grid;
gap: 40px;
max-width: var(--tw-grid-max, 1440px);
margin: 0 auto;
padding: 64px 16px;
}
.cfg-main {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.cfg-eyebrow {
margin: 0 0 22px;
padding: 4px 14px;
border: 1px solid var(--cfg-accent);
border-radius: 999px;
color: var(--cfg-accent);
font: 500 15px/24px var(--cfg-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.cfg-heading {
max-width: 800px;
margin: 0;
font: 700 28px/1.2 var(--cfg-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.cfg-w {
display: inline-block;
}
.cfg-accent {
color: var(--cfg-accent);
}
.cfg-grid {
display: grid;
gap: 20px;
width: 100%;
margin: 40px 0 0;
padding: 0;
list-style: none;
}
.cfg-card {
padding: 26px;
border-radius: 20px;
background: #fff;
}
.cfg-card-head {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 24px;
border-bottom: 1px solid rgb(11 70 84 / 0.12);
}
.cfg-ic {
display: grid;
flex: none;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--cfg-accent);
color: #fff;
}
.cfg-ic svg {
width: 26px;
height: 26px;
}
.cfg-title {
margin: 0;
font: 600 22px/1.2 var(--cfg-card-font);
letter-spacing: -0.88px;
}
.cfg-points {
display: grid;
gap: 9px;
margin: 22px 0 0;
padding: 0;
list-style: none;
}
.cfg-points li {
display: flex;
align-items: center;
gap: 12px;
color: var(--cfg-muted);
font: 400 16px/25.6px var(--cfg-body);
letter-spacing: -0.32px;
}
.cfg-points svg {
flex: none;
width: 20px;
height: 20px;
color: var(--cfg-accent);
}
.cfg-photos {
display: grid;
gap: 10px;
}
.cfg-photo {
overflow: hidden;
border-radius: 20px;
}
.cfg-photo--0 {
aspect-ratio: 417 / 257;
}
.cfg-photo--1 {
aspect-ratio: 417 / 437;
}
.cfg-photo img,
.cfg-ph {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cfg-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #dbe9ed 0 12px, #e6f0f3 12px 24px);
font: 500 13px/1.4 var(--cfg-body);
}
[data-cfg-reveal="up"] {
transition:
opacity 0.8s var(--cfg-ease) var(--d, 0s),
transform 0.8s var(--cfg-ease) var(--d, 0s);
}
.cfg[data-cfg-ready] [data-cfg-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.cfg-w {
transition:
opacity 0.6s var(--cfg-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--cfg-ease) calc(var(--i) * 0.06s);
}
.cfg[data-cfg-ready] [data-cfg-reveal="words"]:not([data-in]) .cfg-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.cfg-inner {
padding: 90px 40px;
}
.cfg-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.cfg-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cfg-photos {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cfg-photo--0,
.cfg-photo--1 {
aspect-ratio: 4 / 3;
}
}
@media (min-width: 1200px) {
.cfg-inner {
grid-template-columns: minmax(0, 835px) 417px;
justify-content: space-between;
align-items: center;
padding: 110px 0 120px;
padding-inline: var(--tw-grid-gutter, 60px);
}
.cfg-inner--solo {
grid-template-columns: minmax(0, 1fr);
}
.cfg-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.cfg-grid {
gap: 21px 24px;
margin-top: 50px;
}
.cfg-card {
min-height: 237px;
padding: 30px;
}
.cfg-title {
font-size: 24px;
line-height: 28.8px;
letter-spacing: -0.96px;
}
.cfg-photos {
grid-template-columns: minmax(0, 1fr);
}
.cfg-photo--0 {
aspect-ratio: 417 / 257;
}
.cfg-photo--1 {
aspect-ratio: 417 / 437;
}
}
@media (prefers-reduced-motion: reduce) {
[data-cfg-reveal="up"],
.cfg-w {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initChecklistFeatureGrid(root: HTMLElement) {
if (root.dataset.cfgReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.cfgReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-cfg-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.cfgReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-cfg-root]").forEach(initChecklistFeatureGrid);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ChecklistFeatureGrid.astro =====
===== FILE (required): public/checklist-feature-grid/courier-box.webp =====
[binary asset — download https://astro.baysixmedia.com/checklist-feature-grid/courier-box.webp and save it to public/checklist-feature-grid/courier-box.webp]
===== FILE (required): public/checklist-feature-grid/cargo-plane.webp =====
[binary asset — download https://astro.baysixmedia.com/checklist-feature-grid/cargo-plane.webp and save it to public/checklist-feature-grid/cargo-plane.webp]
===== FILE (required): src/components/TeamRevealCards.astro =====
---
/**
* TeamRevealCards
*
* A team section: outlined eyebrow, big uppercase heading and an orange pill
* across the top over a hairline, then four pale cards. Each card has a
* portrait on a tinted panel with chamfered top corners, a name and an orange
* role. Hovering a card (or focusing into it) slides a row of round social
* links up over the foot of the portrait; on touch screens the row is always
* shown.
*
* Heading words rise in and the cards lift in, staggered, the first time they
* scroll into view.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export type TeamSocial = "linkedin" | "x" | "email" | "facebook" | "instagram";
export interface TeamMember {
name: string;
role: string;
/** `src: ""` renders initials. */
photo?: { src?: string; alt?: string };
/** Panel behind the portrait. */
tint?: string;
social?: { network: TeamSocial; href: string }[];
}
export interface TeamRevealCardsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
cta?: { label: string; href: string } | null;
members?: TeamMember[];
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends TeamRevealCardsProps {}
/* No built-in people, links or CTA route: everything shown comes from the caller. */
const {
eyebrow = "Our Team",
heading = "",
headingLevel = "h2",
cta = null,
members = [],
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Name = headingLevel === "h2" ? "h3" : "h4";
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const initials = (name: string) =>
name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
const GLYPHS: Record<TeamSocial, { label: string; path: string }> = {
linkedin: { label: "LinkedIn", path: "M5 9v10 M5 5v.01 M10 19v-6a3 3 0 0 1 6 0v6 M10 9v10" },
x: { label: "X", path: "M5 4h4l10 16h-4Z M19 4l-6 7 M5 20l6-7" },
email: { label: "Email", path: "M3 6h18v12H3Z M3 7l9 6 9-6" },
facebook: { label: "Facebook", path: "M14 8h3V4h-3a4 4 0 0 0-4 4v2H8v4h2v6h4v-6h3l1-4h-4V8Z" },
instagram: { label: "Instagram", path: "M8 4h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" },
};
---
{members.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{members.length > 0 && (
<section class:list={["trc", className]} id={id} style={`--trc-band:${background}`} data-trc-root>
<div class="trc-inner">
{(eyebrow || words.length > 0 || cta) && (
<div class="trc-head">
{eyebrow && <p class="trc-eyebrow">{eyebrow}</p>}
{words.length > 0 && (
<Heading class="trc-heading" data-trc-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["trc-w", { "trc-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
{
cta && (
<a class="trc-btn" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="trc-btn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
)}
<ul class="trc-grid">
{
members.map((m, i) => (
<li class="trc-card" data-trc-reveal="up" style={`--d:${i * 0.1}s;--tint:${m.tint ?? "#e3eef1"}`}>
<div class="trc-photo">
{m.photo?.src ? (
<img src={m.photo.src} alt={m.photo.alt ?? ""} width="570" height="496" loading="lazy" decoding="async" />
) : (
<span class="trc-initials" aria-hidden="true">
{initials(m.name)}
</span>
)}
{m.social && m.social.length > 0 && (
<ul class="trc-social">
{m.social.map((s) => (
<li>
<a href={safeHref(s.href)} rel="noopener" aria-label={`${m.name} on ${GLYPHS[s.network].label}`}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d={GLYPHS[s.network].path} fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</li>
))}
</ul>
)}
</div>
<Name class="trc-name">{m.name}</Name>
<p class="trc-role">{m.role}</p>
</li>
))
}
</ul>
</div>
</section>
)}
<style>
.trc {
--trc-navy: var(--tw-navy, #0b4654);
--trc-deep: var(--tw-navy-deep, #052931);
--trc-accent: var(--tw-accent, #ff6d45);
--trc-surface: var(--tw-surface, #f0f8fa);
--trc-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--trc-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--trc-ease: cubic-bezier(0.44, 0, 0.56, 1);
--trc-cut: 34px;
background: var(--trc-band);
color: var(--trc-navy);
font-family: var(--trc-body);
}
.trc *,
.trc *::before,
.trc *::after {
box-sizing: border-box;
}
.trc-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.trc-head {
display: grid;
gap: 18px;
justify-items: start;
padding-bottom: 40px;
border-bottom: 1px solid rgb(11 70 84 / 0.1);
}
.trc-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--trc-accent);
border-radius: 999px;
color: var(--trc-accent);
font: 500 15px/24px var(--trc-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.trc-heading {
max-width: 680px;
margin: 0;
font: 700 28px/1.2 var(--trc-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.trc-w {
display: inline-block;
}
.trc-accent {
color: var(--trc-accent);
}
.trc-btn {
display: inline-flex;
align-items: center;
gap: 12px;
height: 50px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--trc-accent);
color: #fff;
font: 400 16px/25.6px var(--trc-body);
letter-spacing: -0.32px;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.3s var(--trc-ease);
}
.trc-btn:hover {
background: var(--trc-deep);
}
.trc-btn-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--trc-accent);
transition: transform 0.3s var(--trc-ease);
}
.trc-btn-ic svg {
width: 18px;
height: 18px;
}
.trc-btn:hover .trc-btn-ic {
transform: rotate(-45deg);
}
.trc a:focus-visible {
outline: 2px solid var(--trc-navy);
outline-offset: 3px;
}
.trc-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
margin: 40px 0 0;
padding: 0;
list-style: none;
}
.trc-card {
padding: 15px 15px 22px;
border-radius: 15px;
background: var(--trc-surface);
}
.trc-photo {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 285 / 248;
overflow: hidden;
border-radius: 15px;
background: var(--tint);
clip-path: polygon(var(--trc-cut) 0, calc(100% - var(--trc-cut)) 0, 100% var(--trc-cut), 100% 100%, 0 100%, 0 var(--trc-cut));
}
.trc-photo img {
width: 100%;
height: 100%;
object-fit: cover;
mix-blend-mode: multiply;
}
.trc-initials {
font: 700 48px/1 var(--trc-display);
color: var(--trc-navy);
}
.trc-social {
position: absolute;
left: 0;
right: 0;
bottom: 12px;
display: flex;
justify-content: center;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
transition:
opacity 0.35s var(--trc-ease),
transform 0.35s var(--trc-ease);
}
.trc-social a {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff;
color: var(--trc-navy);
transition:
background-color 0.3s var(--trc-ease),
color 0.3s var(--trc-ease);
}
.trc-social a:hover {
background: var(--trc-accent);
color: #fff;
}
.trc-social svg {
width: 17px;
height: 17px;
}
/* Hidden until hover or focus, only where a pointer can hover. */
@media (hover: hover) {
.trc-card:not(:hover):not(:focus-within) .trc-social {
opacity: 0;
transform: translateY(14px);
}
}
.trc-name {
margin: 20px 0 0;
padding: 0 14px;
font: 600 20px/32px var(--trc-body);
letter-spacing: -0.4px;
}
.trc-role {
margin: 0;
padding: 0 14px;
color: var(--trc-accent);
font: 600 14px/22.4px var(--trc-body);
letter-spacing: -0.28px;
text-transform: uppercase;
}
[data-trc-reveal="up"] {
transition:
opacity 0.8s var(--trc-ease) var(--d, 0s),
transform 0.8s var(--trc-ease) var(--d, 0s);
}
.trc[data-trc-ready] [data-trc-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.trc-w {
transition:
opacity 0.6s var(--trc-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--trc-ease) calc(var(--i) * 0.06s);
}
.trc[data-trc-ready] [data-trc-reveal="words"]:not([data-in]) .trc-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 540px) {
.trc-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 810px) {
.trc-inner {
padding: 90px 40px;
}
.trc-head {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
column-gap: 40px;
}
.trc-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
}
@media (min-width: 1200px) {
.trc-inner {
padding: 100px 60px;
}
.trc-head {
grid-template-columns: 330px minmax(0, 1fr) auto;
column-gap: 0;
padding-bottom: 55px;
}
.trc-eyebrow {
margin-left: 0;
justify-self: start;
}
.trc-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.trc-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 65px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-trc-reveal="up"],
.trc-w,
.trc-social,
.trc-social a,
.trc-btn,
.trc-btn-ic {
transition: none;
}
.trc-card:not(:hover):not(:focus-within) .trc-social {
transform: none;
}
.trc-btn:hover .trc-btn-ic {
transform: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initTeamRevealCards(root: HTMLElement) {
if (root.dataset.trcReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.trcReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-trc-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.trcReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-trc-root]").forEach(initTeamRevealCards);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/TeamRevealCards.astro =====
===== FILE (required): public/team-reveal-cards/member-1.webp =====
[binary asset — download https://astro.baysixmedia.com/team-reveal-cards/member-1.webp and save it to public/team-reveal-cards/member-1.webp]
===== FILE (required): public/team-reveal-cards/member-2.webp =====
[binary asset — download https://astro.baysixmedia.com/team-reveal-cards/member-2.webp and save it to public/team-reveal-cards/member-2.webp]
===== FILE (required): public/team-reveal-cards/member-3.webp =====
[binary asset — download https://astro.baysixmedia.com/team-reveal-cards/member-3.webp and save it to public/team-reveal-cards/member-3.webp]
===== FILE (required): public/team-reveal-cards/member-4.webp =====
[binary asset — download https://astro.baysixmedia.com/team-reveal-cards/member-4.webp and save it to public/team-reveal-cards/member-4.webp]
===== FILE (required): src/components/BadgeImageCtaBand.astro =====
---
/**
* BadgeImageCtaBand
*
* A full-width photo band with a round notch cut from its top edge, where a
* spinning orange badge (circular text around an icon) sits half above the
* photo. An orange card overlaps the photo's lower edge — on the right from
* 810px, full width below — with dark ink for contrast: a heading, a hairline,
* check-marked points and a navy pill.
*
* The badge turns slowly and forever (stopped under reduced motion); the card
* lifts in the first time it scrolls into view.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface BadgeImageCtaBandProps {
/** Circular badge text. Empty (the default) drops the badge and the notch. */
badgeText?: string;
/** The card heading. Without one the component renders nothing. */
heading?: string;
headingLevel?: "h2" | "h3";
/** Check-marked points. Blank entries are dropped. */
points?: string[];
/** The pill. Needs both a label and an href, or it is left out. */
cta?: { label: string; href: string } | null;
/** No `src` renders a neutral labelled placeholder. */
image?: { src?: string; alt?: string };
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends BadgeImageCtaBandProps {}
const {
badgeText = "",
heading = "",
headingLevel = "h2",
points = [],
cta = null,
image: imageProp,
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* No demo photo: only what the caller passes. A src without alt is decorative. */
const image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const list = points.filter((p) => typeof p === "string" && p.trim() !== "");
const action = cta && cta.label && cta.href ? cta : null;
const Heading = headingLevel;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const pathId = `${id ?? "bicb"}-arc-${Math.random().toString(36).slice(2, 7)}`;
---
{heading && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{heading && (
<section class:list={["bicb", className, { "bicb--badge": badgeText }]} id={id} style={`--bicb-band:${background}`} data-bicb-root>
{
badgeText && (
<div class="bicb-badge" aria-hidden="true">
<svg class="bicb-ring" viewBox="0 0 136 136">
<defs>
<path id={pathId} d="M68 68m-50 0a50 50 0 1 1 100 0a50 50 0 1 1-100 0" />
</defs>
<text>
<textPath href={`#${pathId}`} textLength="312">
{badgeText.toUpperCase()}
</textPath>
</text>
</svg>
<span class="bicb-core">
<svg viewBox="0 0 24 24">
<path d="M12 3 21 12 12 21 3 12Z" fill="currentColor" />
<path d="M9.5 14.5 14.5 9.5M11 9.5h3.5V13" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</div>
)
}
<div class="bicb-media">
{
image.src ? (
<img src={image.src} alt={image.alt} width="1920" height="760" loading="lazy" decoding="async" />
) : (
<div class="bicb-ph" role="img" aria-label="Image placeholder">
<span>Image · 1920 × 760</span>
</div>
)
}
</div>
<div class="bicb-wrap">
<div class="bicb-card" data-bicb-reveal="up">
<Heading class="bicb-heading">{heading}</Heading>
{
list.length > 0 && (
<ul class="bicb-points">
{list.map((p) => (
<li>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 6" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{p}
</li>
))}
</ul>
)
}
{
action && (
<a class="bicb-btn" href={safeHref(action.href)}>
<span>{action.label}</span>
<span class="bicb-btn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
</div>
</section>
)}
<style>
.bicb {
--bicb-navy: var(--tw-navy, #0b4654);
--bicb-accent: var(--tw-accent, #ff6d45);
--bicb-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--bicb-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--bicb-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
--bicb-ease: cubic-bezier(0.44, 0, 0.56, 1);
--bicb-badge: 112px;
/* Dark ink on the orange card: white on #ff6d45 is 2.8:1 and fails. */
--bicb-ink: var(--tw-ink, #0a2227);
position: relative;
padding: 0 0 40px;
background: var(--bicb-band);
color: var(--bicb-ink);
font-family: var(--bicb-body);
}
.bicb *,
.bicb *::before,
.bicb *::after {
box-sizing: border-box;
}
.bicb--badge {
padding-top: calc(var(--bicb-badge) / 2 + 28px);
}
.bicb-badge {
position: absolute;
top: 16px;
left: 50%;
z-index: 2;
display: grid;
place-items: center;
width: var(--bicb-badge);
height: var(--bicb-badge);
border-radius: 50%;
background: var(--bicb-accent);
transform: translateX(-50%);
}
.bicb-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
fill: #fff;
font: 500 14px/1 var(--bicb-card-font);
letter-spacing: 1px;
animation: bicb-spin 28s linear infinite;
}
@keyframes bicb-spin {
to {
transform: rotate(360deg);
}
}
.bicb-core {
display: grid;
place-items: center;
width: 44%;
height: 44%;
border-radius: 50%;
background: #fff;
color: var(--bicb-accent);
}
.bicb-core svg {
width: 58%;
height: 58%;
}
.bicb-media {
position: relative;
height: 420px;
overflow: hidden;
border-radius: 20px 20px 0 0;
}
/* The round notch the badge sits in. */
.bicb--badge .bicb-media {
-webkit-mask-image: radial-gradient(circle calc(var(--bicb-badge) / 2 + 14px) at 50% -12px, transparent 98%, #000 100%);
mask-image: radial-gradient(circle calc(var(--bicb-badge) / 2 + 14px) at 50% -12px, transparent 98%, #000 100%);
}
.bicb-media img,
.bicb-ph {
width: 100%;
height: 100%;
object-fit: cover;
}
.bicb-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #e3eef1 0 12px, #edf5f7 12px 24px);
color: var(--bicb-navy);
font: 500 13px/1.4 var(--bicb-body);
}
.bicb-wrap {
max-width: 1440px;
margin: -56px auto 0;
padding: 0 16px;
position: relative;
z-index: 1;
}
.bicb-card {
margin-left: auto;
padding: 28px 26px 30px;
border-radius: 20px;
background: var(--bicb-accent);
color: var(--bicb-ink);
}
.bicb-heading {
margin: 0;
padding-bottom: 24px;
border-bottom: 1px solid rgb(10 34 39 / 0.22);
font: 700 28px/1.12 var(--bicb-display);
letter-spacing: -0.02em;
text-wrap: balance;
overflow-wrap: anywhere;
}
.bicb-points {
display: grid;
gap: 12px;
margin: 22px 0 0;
padding: 0;
list-style: none;
}
.bicb-points li {
display: flex;
align-items: flex-start;
gap: 12px;
font: 600 17px/1.45 var(--bicb-card-font);
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.bicb-points svg {
flex: none;
width: 18px;
height: 18px;
margin-top: 3px;
}
.bicb-btn {
display: inline-flex;
align-items: center;
gap: 14px;
min-height: 56px;
margin-top: 30px;
padding: 8px 8px 8px 26px;
border-radius: 999px;
background: var(--bicb-navy);
color: #fff;
font: 600 17px/1.3 var(--bicb-body);
text-decoration: none;
transition:
background-color 0.3s var(--bicb-ease),
color 0.3s var(--bicb-ease);
}
.bicb-btn-ic {
display: grid;
flex: none;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: #fff;
color: var(--bicb-accent);
transition:
background-color 0.3s var(--bicb-ease),
color 0.3s var(--bicb-ease);
}
.bicb-btn-ic svg {
width: 18px;
height: 18px;
}
/* The measured navy-button hover: it turns white with dark text and the
icon inverts to orange with a white arrow. */
.bicb-btn:hover {
background: #fff;
color: #000;
}
.bicb-btn:hover .bicb-btn-ic {
background: var(--bicb-accent);
color: #fff;
}
.bicb-btn:focus-visible {
outline: 3px solid var(--bicb-navy);
outline-offset: 3px;
}
[data-bicb-reveal="up"] {
transition:
opacity 0.8s var(--bicb-ease),
transform 0.8s var(--bicb-ease);
}
.bicb[data-bicb-ready] [data-bicb-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
@media (min-width: 810px) {
.bicb {
--bicb-badge: 136px;
}
.bicb-media {
height: 520px;
}
.bicb-wrap {
margin-top: -330px;
padding: 0 40px;
}
.bicb-card {
max-width: 480px;
padding: 32px;
}
}
@media (min-width: 1200px) {
.bicb-media {
height: 574px;
}
.bicb-wrap {
margin-top: -360px;
padding: 0 60px;
}
.bicb-card {
max-width: 500px;
padding: 38px 38px 40px;
}
.bicb-heading {
font-size: 36px;
}
.bicb-points li {
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.bicb-ring {
animation: none;
}
[data-bicb-reveal="up"],
.bicb-btn,
.bicb-btn-ic {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll for the card; torn down on astro:before-swap. The badge
spin is a CSS animation. */
function initBadgeImageCtaBand(root: HTMLElement) {
if (root.dataset.bicbReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.bicbReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1 },
);
root.querySelectorAll<HTMLElement>("[data-bicb-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.bicbReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-bicb-root]").forEach(initBadgeImageCtaBand);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/BadgeImageCtaBand.astro =====
===== FILE (required): public/badge-image-cta-band/port-ship.webp =====
[binary asset — download https://astro.baysixmedia.com/badge-image-cta-band/port-ship.webp and save it to public/badge-image-cta-band/port-ship.webp]
===== FILE (required): src/components/ScrollProgressTimeline.astro =====
---
/**
* ScrollProgressTimeline
*
* A history timeline: centred eyebrow and big uppercase heading, then a
* vertical rail down the middle with numbered markers and milestone cards
* alternating right and left. As the page scrolls, an orange fill runs down
* the rail, tracking the middle of the viewport, and each marker it passes
* turns orange.
*
* Without JavaScript, or under reduced motion, the rail is shown filled and
* every marker reached. On a phone the rail moves to the left edge and every
* card sits to its right.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface TimelineMilestone {
period: string;
title: string;
text: string;
}
export interface ScrollProgressTimelineProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
milestones?: TimelineMilestone[];
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ScrollProgressTimelineProps {}
const {
eyebrow = "Our History",
heading = "",
headingLevel = "h2",
/* No built-in history: every milestone comes from the caller. */
milestones = [],
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const pad = (n: number) => String(n).padStart(2, "0");
---
{milestones.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{milestones.length > 0 && (
<section class:list={["spt", className]} id={id} style={`--spt-band:${background}`} data-spt-root>
<div class="spt-inner">
{(eyebrow || words.length > 0) && (
<div class="spt-head">
{eyebrow && <p class="spt-eyebrow">{eyebrow}</p>}
{words.length > 0 && (
<Heading class="spt-heading" data-spt-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["spt-w", { "spt-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
</div>
)}
<div class="spt-stage">
<div class="spt-rail" aria-hidden="true">
<span class="spt-fill" data-spt-fill></span>
</div>
<ol class="spt-list">
{
milestones.map((m, i) => (
<li class:list={["spt-item", i % 2 === 0 ? "spt-item--right" : "spt-item--left"]} data-spt-item>
<span class="spt-marker" aria-hidden="true">
<span>{pad(i + 1)}</span>
</span>
<div class="spt-card">
<p class="spt-period">
<svg viewBox="0 0 12 12" aria-hidden="true">
<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1" />
<path d="M6 3.8V6l1.5 1" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" />
</svg>
{m.period}
</p>
<Title class="spt-title">{m.title}</Title>
<p class="spt-text">{m.text}</p>
</div>
</li>
))
}
</ol>
</div>
</div>
</section>
)}
<style>
.spt {
--spt-navy: var(--tw-navy, #0b4654);
--spt-accent: var(--tw-accent, #ff6d45);
--spt-muted: var(--tw-muted, #616161);
--spt-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--spt-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--spt-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
--spt-ease: cubic-bezier(0.44, 0, 0.56, 1);
--spt-rail-x: 20px;
background: var(--spt-band);
color: var(--spt-navy);
font-family: var(--spt-body);
}
.spt *,
.spt *::before,
.spt *::after {
box-sizing: border-box;
}
.spt-inner {
max-width: 1310px;
margin: 0 auto;
padding: 64px 16px;
}
.spt-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding-bottom: 44px;
border-bottom: 1px solid rgb(11 70 84 / 0.1);
text-align: center;
}
.spt-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--spt-accent);
border-radius: 999px;
color: var(--spt-accent);
font: 500 15px/24px var(--spt-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.spt-heading {
max-width: 640px;
margin: 0;
font: 700 28px/1.2 var(--spt-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.spt-w {
display: inline-block;
}
.spt-accent {
color: var(--spt-accent);
}
.spt-stage {
position: relative;
max-width: 1170px;
margin: 0 auto;
padding: 48px 0 56px;
border-bottom: 1px solid rgb(11 70 84 / 0.1);
}
.spt-rail {
position: absolute;
top: 0;
bottom: 56px;
left: var(--spt-rail-x);
width: 4px;
border-radius: 2px;
background: #e8eef0;
transform: translateX(-50%);
overflow: hidden;
}
.spt-fill {
position: absolute;
inset: 0;
background: var(--spt-accent);
transform-origin: top;
transform: scaleY(var(--spt-progress, 1));
}
.spt-list {
display: grid;
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
.spt-item {
position: relative;
padding-left: calc(var(--spt-rail-x) + 32px);
}
.spt-marker {
position: absolute;
top: 0;
left: var(--spt-rail-x);
display: grid;
place-items: center;
width: 41px;
height: 41px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 1px rgb(11 70 84 / 0.1);
transform: translateX(-50%);
}
.spt-marker span {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: #f3f6f7;
color: #18323f;
font: 800 12px/1 var(--spt-card-font);
transition:
background-color 0.35s var(--spt-ease),
color 0.35s var(--spt-ease);
}
.spt-item[data-reached] .spt-marker {
box-shadow: 0 0 0 1px var(--spt-accent);
}
.spt-item[data-reached] .spt-marker span {
background: var(--spt-accent);
color: #fff;
}
.spt-card {
padding: 26px 24px;
border-radius: 12px;
background: #f5f5f5;
}
.spt-period {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0;
padding: 5px 10px;
border-radius: 999px;
background: var(--spt-navy);
color: #fff;
font: 600 10px/12px var(--spt-card-font);
}
.spt-period svg {
width: 10px;
height: 10px;
}
.spt-title {
margin: 18px 0 0;
font: 600 21px/1.2 var(--spt-card-font);
letter-spacing: -0.84px;
}
.spt-text {
margin: 16px 0 0;
color: var(--spt-muted);
font: 400 16px/25.6px var(--spt-body);
letter-spacing: -0.32px;
}
.spt-w {
transition:
opacity 0.6s var(--spt-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--spt-ease) calc(var(--i) * 0.06s);
}
.spt[data-spt-ready] [data-spt-reveal="words"]:not([data-in]) .spt-w {
opacity: 0;
transform: translateY(10px);
}
/* No JS: rail filled, every marker reached. */
.spt:not([data-spt-ready]) .spt-marker {
box-shadow: 0 0 0 1px var(--spt-accent);
}
.spt:not([data-spt-ready]) .spt-marker span {
background: var(--spt-accent);
color: #fff;
}
@media (min-width: 810px) {
.spt-inner {
padding: 90px 40px;
}
.spt {
--spt-rail-x: 50%;
}
.spt-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.spt-list {
gap: 0;
}
.spt-item {
width: 50%;
min-height: 214px;
padding-left: 0;
}
.spt-item--right {
margin-left: 50%;
padding-left: 42px;
}
.spt-item--left {
padding-right: 42px;
}
.spt-item--right .spt-marker {
left: 0;
}
.spt-item--left .spt-marker {
left: 100%;
}
.spt-item + .spt-item {
margin-top: -16px;
}
}
@media (min-width: 1200px) {
.spt-inner {
padding: 100px 65px;
}
.spt-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.spt-head {
padding-bottom: 44px;
margin: 0 auto;
max-width: 880px;
}
.spt-stage {
padding: 48px 60px 54px;
}
.spt-item {
min-height: 214px;
}
.spt-item--right {
padding-left: 43px;
}
.spt-item--left {
padding-right: 43px;
}
.spt-card {
max-width: 470px;
min-height: 230px;
padding: 32px 32px 34px;
}
.spt-item--left .spt-card {
margin-left: auto;
}
.spt-title {
font-size: 24px;
line-height: 28.8px;
letter-spacing: -0.96px;
}
}
@media (prefers-reduced-motion: reduce) {
.spt-w,
.spt-marker span {
transition: none;
}
}
</style>
<script>
/*
* Scroll-linked progress. One passive scroll listener and one resize
* listener per instance, both on an AbortController aborted on
* astro:before-swap; work is batched into one requestAnimationFrame.
* Under reduced motion the rail stays filled and nothing listens.
*/
function initScrollProgressTimeline(root: HTMLElement) {
if (root.dataset.sptReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const rail = root.querySelector<HTMLElement>(".spt-rail");
const items = [...root.querySelectorAll<HTMLElement>("[data-spt-item]")];
if (!rail) return;
const controller = new AbortController();
const { signal } = controller;
root.dataset.sptReady = "";
let frame = 0;
const update = () => {
frame = 0;
const r = rail.getBoundingClientRect();
const probe = window.innerHeight * 0.55;
const progress = r.height > 0 ? Math.min(1, Math.max(0, (probe - r.top) / r.height)) : 1;
root.style.setProperty("--spt-progress", progress.toFixed(4));
for (const item of items) {
const marker = item.querySelector<HTMLElement>(".spt-marker");
if (!marker) continue;
const m = marker.getBoundingClientRect();
item.toggleAttribute("data-reached", m.top + m.height / 2 <= probe);
}
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", schedule, { passive: true, signal });
window.addEventListener("resize", schedule, { passive: true, signal });
if ("IntersectionObserver" in window) {
const heading = root.querySelector<HTMLElement>("[data-spt-reveal]");
if (heading) {
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
heading.dataset.in = "";
io.disconnect();
}
},
{ threshold: 0.1 },
);
io.observe(heading);
signal.addEventListener("abort", () => io.disconnect());
}
} else {
root.querySelectorAll<HTMLElement>("[data-spt-reveal]").forEach((h) => (h.dataset.in = ""));
}
signal.addEventListener("abort", () => {
if (frame) cancelAnimationFrame(frame);
});
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.sptReady;
},
{ once: true, signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-spt-root]").forEach(initScrollProgressTimeline);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ScrollProgressTimeline.astro =====
===== FILE (required): src/components/PortraitRatingTestimonials.astro =====
---
/**
* PortraitRatingTestimonials
*
* A centred testimonial section: outlined eyebrow and a big uppercase
* heading over three columns. The outer columns each hold two quote cards
* (orange quote mark, quote, avatar, name, role and a small star score); the
* middle column is a tall portrait photo fading to navy at the foot, carrying
* an overall rating with five stars and a review count.
*
* Heading words rise in and the cards lift in, staggered, the first time they
* scroll into view.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface PortraitTestimonial {
quote: string;
name: string;
role?: string;
/** Score shown in the pill, e.g. "5.0". Omit it (or `""`) and there is no pill. */
score?: string;
/** `src: ""` renders initials instead. */
avatar?: { src?: string; alt?: string };
}
export interface PortraitRatingTestimonialsProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** Four quotes: two left, two right. Extra ones are ignored. */
testimonials?: PortraitTestimonial[];
/** The centre photo. `src: ""` renders a labelled placeholder. */
portrait?: { src?: string; alt?: string };
/**
* The overall rating, from a real review source. Omit it (or pass `null`)
* and there is none: an aggregate rating and a review count are never
* supplied by the component. The stars follow the score's leading number.
*/
rating?: { score: string; count: string } | null;
background?: string;
/** Quote card fill. White on a pale band; pale on a white band. */
cardBackground?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends PortraitRatingTestimonialsProps {}
const {
eyebrow = "Testimonials",
heading = "What our *clients* say",
headingLevel = "h2",
/* No quotes of its own: they come from the caller or not at all. */
testimonials = [],
portrait: portraitProp,
rating = null,
background = "#f0f8fa",
cardBackground = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
/* Whole stars for a score like "4.9/5.0": its leading number, rounded, 0–5. */
const starsFor = (score: string) => {
const n = Number.parseFloat(score);
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), 0), 5) : 0;
};
/* No photo of its own: unset (or `src: ""`) renders the labelled placeholder. */
const portrait = { src: "", alt: "", ...(portraitProp ?? {}) };
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const left = testimonials.slice(0, 2);
const right = testimonials.slice(2, 4);
const initials = (name: string) =>
name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
---
{testimonials.length > 0 && (<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section
class:list={["prt", className]}
id={id}
style={`--prt-band:${background};--prt-card-bg:${cardBackground}`}
data-prt-root
>
<div class="prt-inner">
<div class="prt-head">
{eyebrow && <p class="prt-eyebrow">{eyebrow}</p>}
<Heading class="prt-heading" data-prt-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["prt-w", { "prt-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
</div>
<div class="prt-grid">
{
[left, right].map((col, c) => (
<ul class:list={["prt-col", c === 0 ? "prt-col--left" : "prt-col--right"]}>
{col.map((t, i) => (
<li class="prt-card" data-prt-reveal="up" style={`--d:${(c * 2 + i) * 0.1}s`}>
<svg class="prt-mark" viewBox="0 0 40 40" aria-hidden="true">
<path d="M4 34V22C4 12 8 7 17 5l1 4c-5 2-7 5-7 9h6v16Zm19 0V22c0-10 4-15 13-17l1 4c-5 2-7 5-7 9h6v16Z" fill="currentColor" />
</svg>
<blockquote class="prt-quote">
<p>{t.quote}</p>
</blockquote>
<div class="prt-person">
{t.avatar?.src ? (
<img class="prt-avatar" src={t.avatar.src} alt={t.avatar.alt ?? ""} width="40" height="40" loading="lazy" decoding="async" />
) : (
<span class="prt-avatar prt-avatar--initials" aria-hidden="true">
{initials(t.name)}
</span>
)}
<div class="prt-who">
<p class="prt-name">{t.name}</p>
{t.role && <p class="prt-role">{t.role}</p>}
</div>
{t.score && (
<p class="prt-score">
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="m10 1.8 2.5 5.3 5.8.7-4.3 4 1.1 5.7L10 14.7l-5.1 2.8L6 11.8l-4.3-4 5.8-.7Z" fill="currentColor" />
</svg>
<span class="prt-sr">Rated </span>
{t.score}
</p>
)}
</div>
</li>
))}
</ul>
))
}
<figure class="prt-portrait" data-prt-reveal="up" style="--d:0.05s">
{
portrait.src ? (
<img src={portrait.src} alt={portrait.alt} width="854" height="1254" loading="lazy" decoding="async" />
) : (
<div class="prt-ph" role="img" aria-label="Image placeholder: portrait of a site worker">
<span>Image · portrait · 854 × 1254</span>
</div>
)
}
{
rating && (
<figcaption class="prt-rating">
<span class="prt-rating-badge" aria-hidden="true">
<svg viewBox="0 0 20 20"><path d="m10 1.8 2.5 5.3 5.8.7-4.3 4 1.1 5.7L10 14.7l-5.1 2.8L6 11.8l-4.3-4 5.8-.7Z" fill="currentColor" /></svg>
</span>
<span>
<span class="prt-rating-score">{rating.score}</span>
<span class="prt-rating-line">
{starsFor(rating.score) > 0 && (
<span class="prt-stars" aria-hidden="true">{"★".repeat(starsFor(rating.score))}</span>
)}
<span class="prt-rating-count">{rating.count}</span>
</span>
</span>
</figcaption>
)
}
</figure>
</div>
</div>
</section>
</>)}
<style>
.prt {
--prt-navy: var(--tw-navy, #0b4654);
--prt-accent: var(--tw-accent, #ff6d45);
--prt-muted: var(--tw-muted, #616161);
--prt-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--prt-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--prt-ease: cubic-bezier(0.44, 0, 0.56, 1);
background: var(--prt-band);
color: var(--prt-navy);
font-family: var(--prt-body);
}
.prt *,
.prt *::before,
.prt *::after {
box-sizing: border-box;
}
.prt-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px;
}
.prt-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
text-align: center;
}
.prt-eyebrow {
margin: 0;
padding: 4px 14px;
border: 1px solid var(--prt-accent);
border-radius: 999px;
color: var(--prt-accent);
font: 500 15px/24px var(--prt-body);
letter-spacing: -0.3px;
text-transform: uppercase;
}
.prt-heading {
margin: 0;
font: 700 28px/1.2 var(--prt-display);
letter-spacing: -0.56px;
text-transform: uppercase;
}
.prt-w {
display: inline-block;
}
.prt-accent {
color: var(--prt-accent);
}
.prt-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 15px;
margin-top: 40px;
}
.prt-col {
display: grid;
gap: 15px;
margin: 0;
padding: 0;
list-style: none;
}
.prt-card {
display: flex;
flex-direction: column;
min-width: 0;
padding: 25px;
border-radius: 20px;
background: var(--prt-card-bg);
}
.prt-mark {
width: 36px;
height: 36px;
color: var(--prt-accent);
}
.prt-quote {
margin: 16px 0 0;
}
.prt-quote p {
margin: 0;
font: 500 16px/25.6px var(--prt-body);
letter-spacing: -0.32px;
/* A long unbroken word in a real review wraps rather than pushing the page sideways. */
overflow-wrap: anywhere;
}
.prt-person {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 24px;
}
.prt-avatar {
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.prt-avatar--initials {
display: grid;
place-items: center;
background: var(--prt-navy);
color: #fff;
font: 600 14px/1 var(--prt-body);
}
.prt-who {
min-width: 0;
margin-right: auto;
}
.prt-name {
margin: 0;
overflow: hidden;
font: 500 18px/25.2px var(--prt-body);
letter-spacing: -0.36px;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.prt-role {
margin: 0;
color: var(--prt-muted);
font: 400 15px/24px var(--prt-body);
letter-spacing: -0.3px;
overflow-wrap: anywhere;
}
.prt-score {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
margin: 0;
padding: 2px 10px;
border: 1px solid rgb(11 70 84 / 0.12);
border-radius: 999px;
color: var(--prt-muted);
font: 600 14px/22.4px var(--prt-body);
}
.prt-score svg {
width: 13px;
height: 13px;
color: var(--prt-accent);
}
.prt-portrait {
position: relative;
min-height: 420px;
margin: 0;
overflow: hidden;
border-radius: 20px;
background: var(--prt-accent);
}
.prt-portrait img,
.prt-ph {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
}
.prt-portrait::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgb(255 255 255 / 0) 40%, var(--prt-navy) 80%, #000 100%);
}
.prt-ph {
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, #ff8a67 0 12px, #ff7a53 12px 24px);
color: #fff;
font: 500 13px/1.4 var(--prt-body);
}
.prt-rating {
position: absolute;
left: 25px;
bottom: 30px;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
color: #fff;
}
.prt-rating-badge {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 50%;
background: #fff;
color: var(--prt-accent);
}
.prt-rating-badge svg {
width: 26px;
height: 26px;
}
.prt-rating-score {
display: block;
font: 600 30px/42px var(--prt-display);
letter-spacing: -0.6px;
text-transform: uppercase;
}
.prt-rating-line {
display: flex;
align-items: center;
gap: 8px;
}
.prt-stars {
color: var(--prt-accent);
font-size: 16px;
letter-spacing: 2px;
}
.prt-rating-count {
font: 600 14px/22.4px var(--prt-body);
letter-spacing: -0.28px;
text-transform: uppercase;
}
.prt-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
[data-prt-reveal="up"] {
transition:
opacity 0.8s var(--prt-ease) var(--d, 0s),
transform 0.8s var(--prt-ease) var(--d, 0s);
}
.prt[data-prt-ready] [data-prt-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.prt-w {
transition:
opacity 0.6s var(--prt-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--prt-ease) calc(var(--i) * 0.06s);
}
.prt[data-prt-ready] [data-prt-reveal="words"]:not([data-in]) .prt-w {
opacity: 0;
transform: translateY(10px);
}
/* Phone: left pair, portrait, right pair. */
.prt-col--left {
order: 1;
}
.prt-portrait {
order: 2;
}
.prt-col--right {
order: 3;
}
@media (min-width: 810px) {
.prt-inner {
padding: 90px 40px;
}
.prt-heading {
font-size: 38px;
letter-spacing: -0.76px;
}
.prt-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.prt-portrait {
grid-column: 1 / -1;
min-height: 520px;
}
}
@media (min-width: 1200px) {
.prt-inner {
padding: 100px 65px;
}
.prt-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.prt-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
margin-top: 50px;
}
.prt-col--left {
order: 1;
}
.prt-portrait {
order: 2;
grid-column: auto;
align-self: stretch;
min-height: 602px;
}
.prt-col--right {
order: 3;
padding-top: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-prt-reveal="up"],
.prt-w {
transition: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initPortraitRatingTestimonials(root: HTMLElement) {
if (root.dataset.prtReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.prtReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-prt-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.prtReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-prt-root]").forEach(initPortraitRatingTestimonials);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/PortraitRatingTestimonials.astro =====
===== FILE (required): public/portrait-rating-testimonials/portrait.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/portrait.webp and save it to public/portrait-rating-testimonials/portrait.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-1.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-1.webp and save it to public/portrait-rating-testimonials/avatar-1.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-2.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-2.webp and save it to public/portrait-rating-testimonials/avatar-2.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-3.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-3.webp and save it to public/portrait-rating-testimonials/avatar-3.webp]
===== FILE (required): public/portrait-rating-testimonials/avatar-4.webp =====
[binary asset — download https://astro.baysixmedia.com/portrait-rating-testimonials/avatar-4.webp and save it to public/portrait-rating-testimonials/avatar-4.webp]
===== FILE (required): src/components/ShipCtaFooter.astro =====
---
/**
* ShipCtaFooter
*
* A tall dark footer over a photo washed navy-to-black. On top, a big
* uppercase call to action with orange words and an orange pill; under a
* hairline, the brand, a tagline and an email sign-up on the left, and
* Services, Company and Contact columns split by hairlines on the right; at
* the foot, copyright, two legal links and round social buttons.
*
* The sign-up is a real <form> posting to `newsletter.action`; the button
* dims until the email field is valid, but it is never disabled.
*
* Heading syntax: wrap the orange words in asterisks.
*
* Nothing about the business is assumed: no name, heading, pill, link,
* contact line, sign-up form, copyright or photo is shown unless it is
* passed, a block with nothing in it is not drawn, and with no content at
* all nothing renders.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export type ShipFooterSocial = "facebook" | "x" | "instagram" | "linkedin" | "youtube";
export interface ShipFooterLink {
label: string;
href: string;
}
export interface ShipCtaFooterProps {
heading?: string;
/** Heading level for the call to action. */
headingLevel?: "h2" | "p";
cta?: ShipFooterLink | null;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark. Use a light version. */
logo?: { src: string; alt: string; width?: number; height?: number };
tagline?: string;
/** The sign-up form. Off by default; pass your handler as `action`. */
newsletter?: { action: string; label?: string; placeholder?: string; button?: string } | null;
columns?: { heading: string; links: ShipFooterLink[] }[];
contactHeading?: string;
contact?: ShipFooterLink[];
copyright?: string;
legalLinks?: ShipFooterLink[];
social?: { network: ShipFooterSocial; href: string; label?: string }[];
/** Background photo. None by default: the navy gradient alone. */
background?: { src?: string };
navLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ShipCtaFooterProps {}
const {
heading = "",
headingLevel = "h2",
cta = null,
brandName = "",
brandHref = "/",
logo,
tagline = "",
newsletter = null,
columns = [],
contactHeading = "Contact",
contact = [],
copyright,
legalLinks = [],
social = [],
background: bgProp,
navLabel = "Footer",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
class: className,
id,
} = Astro.props;
const background = { src: "", ...(bgProp ?? {}) };
const year = new Date().getFullYear();
/* No name, no copyright line: never "© 2026 . All rights reserved." */
const copy = copyright ?? (brandName ? `© ${year} ${brandName}. All rights reserved.` : "");
const words = heading
.split("*")
.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const hasCta = words.length > 0 || Boolean(cta);
const hasMark = Boolean(logo || brandName);
const hasCols = columns.length > 0 || contact.length > 0;
const hasMain = hasMark || Boolean(tagline) || Boolean(newsletter) || hasCols;
const hasBottom = Boolean(copy) || legalLinks.length > 0 || social.length > 0;
const hasContent = hasCta || hasMain || hasBottom;
const Heading = headingLevel;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const external = (href: string) => /^https?:/i.test(href);
const inputId = `${id ?? "scf"}-email-${Math.random().toString(36).slice(2, 7)}`;
const SOCIAL: Record<ShipFooterSocial, { label: string; path: string }> = {
facebook: { label: "Facebook", path: "M14 8h3V4h-3a4 4 0 0 0-4 4v2H8v4h2v6h4v-6h3l1-4h-4V8Z" },
x: { label: "X", path: "M5 4h4l10 16h-4Z M19 4l-6 7 M5 20l6-7" },
instagram: { label: "Instagram", path: "M8 4h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z M17 7h.01" },
linkedin: { label: "LinkedIn", path: "M5 9v10 M5 5v.01 M10 19v-6a3 3 0 0 1 6 0v6 M10 9v10" },
youtube: { label: "YouTube", path: "M3 12s0-4 1-5 8-1 8-1 7 0 8 1 1 5 1 5 0 4-1 5-8 1-8 1-7 0-8-1-1-5-1-5Z M10 9l5 3-5 3Z" },
};
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<footer class:list={["scf", className]} id={id} data-scf-root>
{background.src && <img class="scf-bg" src={background.src} alt="" width="1600" height="960" loading="lazy" decoding="async" />}
<div class="scf-wash" aria-hidden="true"></div>
<div class="scf-inner">
{hasCta && (
<div class="scf-cta">
{words.length > 0 && (
<Heading class="scf-heading" data-scf-reveal="words">
{
words.map((t, i) => (
<>
{i > 0 && " "}
<span class:list={["scf-w", { "scf-accent": t.accent }]} style={`--i:${i}`}>
{t.w}
</span>
</>
))
}
</Heading>
)}
{
cta && (
<a class="scf-btn" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="scf-btn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
)}
{hasMain && (
<div class="scf-main" data-scf-reveal="up">
<div class="scf-brand">
{hasMark && (
<a class="scf-logo" href={safeHref(brandHref)} aria-label={logo ? undefined : `${brandName} home`}>
{
logo ? (
<img src={logo.src} alt={logo.alt} width={logo.width ?? 124} height={logo.height ?? 32} />
) : (
<>
<svg viewBox="0 0 40 40" aria-hidden="true">
<path d="M6 21a14 14 0 0 1 28 0h-6a8 8 0 0 0-16 0Z" fill="currentColor" />
<path d="M20 17 11 30h6v7h6v-7h6Z" fill="currentColor" />
</svg>
<span>{brandName}</span>
</>
)
}
</a>
)}
{tagline && <p class="scf-tagline">{tagline}</p>}
{
newsletter && (
<form class="scf-form" action={safeHref(newsletter.action)} method="post">
<label class="scf-sr" for={inputId}>
{newsletter.label ?? "Email address"}
</label>
<input id={inputId} type="email" name="email" required autocomplete="email" placeholder={newsletter.placeholder ?? "you@company.com"} />
<button type="submit">{newsletter.button ?? "Submit"}</button>
</form>
)
}
</div>
{hasCols && (
<nav class="scf-cols" aria-label={navLabel}>
{
columns.map((col) => (
<div class="scf-col">
<h2 class="scf-col-title">{col.heading}</h2>
<ul>
{col.links.map((l) => (
<li>
<a href={safeHref(l.href)}>{l.label}</a>
</li>
))}
</ul>
</div>
))
}
{
contact.length > 0 && (
<div class="scf-col scf-col--contact">
<h2 class="scf-col-title">{contactHeading}</h2>
<ul>
{contact.map((l) => (
<li>
<a href={safeHref(l.href)} {...(external(l.href) ? { rel: "noopener" } : {})}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 2l12 6-12 6 3-6Z" fill="currentColor" />
</svg>
{l.label}
</a>
</li>
))}
</ul>
</div>
)
}
</nav>
)}
</div>
)}
{hasBottom && (
<div class="scf-bottom">
{copy && <p class="scf-copy">{copy}</p>}
{
legalLinks.length > 0 && (
<ul class="scf-legal">
{legalLinks.map((l) => (
<li>
<a href={safeHref(l.href)}>{l.label}</a>
</li>
))}
</ul>
)
}
{
social.length > 0 && (
<ul class="scf-social">
{social.map((s) => (
<li>
<a href={safeHref(s.href)} rel="noopener" aria-label={s.label ?? SOCIAL[s.network].label}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d={SOCIAL[s.network].path} fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</li>
))}
</ul>
)
}
</div>
)}
</div>
</footer>
)}
<style>
.scf {
--scf-navy: var(--tw-navy, #0b4654);
--scf-accent: var(--tw-accent, #ff6d45);
--scf-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--scf-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--scf-ease: cubic-bezier(0.44, 0, 0.56, 1);
--scf-line: rgb(255 255 255 / 0.1);
position: relative;
isolation: isolate;
overflow: hidden;
background: #07252c;
color: #fff;
font-family: var(--scf-body);
}
.scf *,
.scf *::before,
.scf *::after {
box-sizing: border-box;
}
.scf-bg,
.scf-wash {
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
}
.scf-bg {
object-fit: cover;
}
.scf-wash {
background: linear-gradient(180deg, rgb(11 70 84 / 0.88) 0%, rgb(8 45 54 / 0.9) 55%, rgb(2 10 12 / 0.97) 100%);
}
.scf-inner {
max-width: 1440px;
margin: 0 auto;
padding: 64px 16px 32px;
}
.scf-cta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 28px;
padding-bottom: 48px;
border-bottom: 1px solid var(--scf-line);
}
.scf-heading {
max-width: 680px;
margin: 0;
font: 900 30px/1.2 var(--scf-display);
letter-spacing: -0.6px;
text-transform: uppercase;
}
.scf-w {
display: inline-block;
}
.scf-accent {
color: var(--scf-accent);
}
.scf-btn {
display: inline-flex;
flex: none;
align-items: center;
gap: 12px;
height: 50px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--scf-accent);
color: #fff;
font: 400 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: background-color 0.3s var(--scf-ease);
}
.scf-btn:hover {
background: #052931;
}
.scf-btn-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--scf-accent);
transition: transform 0.3s var(--scf-ease);
}
.scf-btn-ic svg {
width: 18px;
height: 18px;
}
.scf-btn:hover .scf-btn-ic {
transform: rotate(-45deg);
}
.scf-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 48px;
padding: 56px 0;
}
.scf-logo {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
font: 500 26px/1 var(--scf-display);
letter-spacing: -0.5px;
text-decoration: none;
}
.scf-logo svg {
width: 30px;
height: 30px;
color: var(--scf-accent);
}
.scf-tagline {
max-width: 300px;
margin: 36px 0 0;
font: 400 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
}
.scf-form {
display: flex;
gap: 10px;
max-width: 400px;
margin-top: 40px;
}
.scf-form input {
flex: 1 1 auto;
min-width: 0;
height: 54px;
padding: 0 14px;
border: 0;
border-radius: 10px;
background: #fff;
color: var(--scf-navy);
font: 400 15px/1 var(--scf-body);
}
.scf-form button {
flex: none;
width: 115px;
height: 54px;
border: 0;
border-radius: 10px;
background: var(--scf-accent);
color: #fff;
font: 500 16px/1 var(--scf-body);
cursor: pointer;
transition:
opacity 0.3s var(--scf-ease),
background-color 0.3s var(--scf-ease);
}
.scf-form:has(input:invalid) button {
opacity: 0.5;
}
.scf-form button:hover {
background: #052931;
opacity: 1;
}
.scf-cols {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 36px 24px;
}
.scf-col--contact {
grid-column: 1 / -1;
}
.scf-col-title {
margin: 0 0 14px;
color: rgb(255 255 255 / 0.7);
font: 600 20px/32px var(--scf-body);
letter-spacing: -0.4px;
text-transform: uppercase;
}
.scf-col ul,
.scf-legal,
.scf-social {
margin: 0;
padding: 0;
list-style: none;
}
.scf-col li + li {
margin-top: 15px;
}
.scf-col a,
.scf-legal a {
display: inline-flex;
align-items: center;
gap: 10px;
color: #fff;
font: 500 16px/25.6px var(--scf-body);
letter-spacing: -0.32px;
text-decoration: none;
transition: color 0.3s var(--scf-ease);
}
.scf-col a svg {
flex: none;
width: 16px;
height: 16px;
color: var(--scf-accent);
}
.scf-col a:hover,
.scf-legal a:hover {
color: var(--scf-accent);
}
.scf-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px 24px;
padding-top: 28px;
border-top: 1px solid var(--scf-line);
}
.scf-copy {
margin: 0;
color: rgb(255 255 255 / 0.7);
font: 400 15px/24px var(--scf-body);
}
.scf-legal {
display: flex;
flex-wrap: wrap;
gap: 12px 40px;
}
.scf-legal a {
color: rgb(255 255 255 / 0.7);
}
.scf-social {
display: flex;
gap: 10px;
}
.scf-social a {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--scf-navy);
color: #fff;
transition: background-color 0.3s var(--scf-ease);
}
.scf-social a:hover {
background: var(--scf-accent);
}
.scf-social svg {
width: 18px;
height: 18px;
}
.scf a:focus-visible,
.scf button:focus-visible,
.scf input:focus-visible {
outline: 2px solid var(--scf-accent);
outline-offset: 3px;
}
.scf-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
[data-scf-reveal="up"] {
transition:
opacity 0.8s var(--scf-ease),
transform 0.8s var(--scf-ease);
}
.scf[data-scf-ready] [data-scf-reveal="up"]:not([data-in]) {
opacity: 0;
transform: translateY(80px);
}
.scf-w {
transition:
opacity 0.6s var(--scf-ease) calc(var(--i) * 0.06s),
transform 0.6s var(--scf-ease) calc(var(--i) * 0.06s);
}
.scf[data-scf-ready] [data-scf-reveal="words"]:not([data-in]) .scf-w {
opacity: 0;
transform: translateY(10px);
}
@media (min-width: 810px) {
.scf-inner {
padding: 90px 40px 36px;
}
.scf-cta {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-bottom: 56px;
}
.scf-heading {
font-size: 40px;
letter-spacing: -0.8px;
}
.scf-cols {
grid-template-columns: repeat(3, auto);
justify-content: start;
gap: 0;
}
.scf-col {
padding: 0 40px;
border-left: 1px solid var(--scf-line);
}
.scf-col:first-child {
padding-left: 0;
border-left: 0;
}
.scf-col--contact {
grid-column: auto;
}
}
@media (min-width: 1200px) {
.scf-inner {
padding: 100px 65px 36px;
}
.scf-heading {
font-size: 50px;
line-height: 60px;
letter-spacing: -1px;
}
.scf-main {
grid-template-columns: 485px minmax(0, 1fr);
gap: 0;
padding: 100px 0 90px;
}
.scf-cols {
justify-content: space-between;
padding-top: 20px;
}
.scf-col {
padding: 0 50px;
}
.scf-col:first-child {
padding-left: 50px;
border-left: 1px solid var(--scf-line);
}
.scf-col--contact {
padding-right: 0;
}
.scf-social a {
width: 35px;
height: 35px;
}
}
@media (prefers-reduced-motion: reduce) {
[data-scf-reveal="up"],
.scf-w,
.scf-btn,
.scf-btn-ic,
.scf-col a,
.scf-legal a,
.scf-social a,
.scf-form button {
transition: none;
}
.scf-btn:hover .scf-btn-ic {
transform: none;
}
}
</style>
<script>
/* Appear-on-scroll; one observer per instance, torn down on astro:before-swap. */
function initShipCtaFooter(root: HTMLElement) {
if (root.dataset.scfReady === "") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) return;
const controller = new AbortController();
root.dataset.scfReady = "";
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.in = "";
io.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -5% 0px" },
);
root.querySelectorAll<HTMLElement>("[data-scf-reveal]").forEach((t) => io.observe(t));
controller.signal.addEventListener("abort", () => io.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.scfReady;
},
{ once: true, signal: controller.signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-scf-root]").forEach(initShipCtaFooter);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ShipCtaFooter.astro =====
===== FILE (required): public/ship-cta-footer/ship-at-sea.webp =====
[binary asset — download https://astro.baysixmedia.com/ship-cta-footer/ship-at-sea.webp and save it to public/ship-cta-footer/ship-at-sea.webp]
--- SECTIONS ---
- Scroll pill header (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/scroll-pill-header/
- Tag title banner (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/tag-title-banner/
- About stats intro (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/about-stats-intro/
- Logo marquee strip (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/logo-marquee-strip/
- Checklist feature grid (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/checklist-feature-grid/
- Team reveal cards (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/team-reveal-cards/
- Badge image CTA band (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/badge-image-cta-band/
- Scroll progress timeline (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/scroll-progress-timeline/
- Portrait rating testimonials (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/portrait-rating-testimonials/
- Ship CTA footer (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/ship-cta-footer/
Each component's README — its full prop reference — is on its library page above, and is not repeated here. The library verified each component on its own. Nobody has verified this page in your project: test the assembled page yourself.