The 18 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: "Logistics homepage"
Library page: https://astro.baysixmedia.com/templates/logistics-homepage/
Page file: src/pages/logistics-homepage.astro
Files: 18 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/logistics-homepage.astro =====
---
/**
* Logistics homepage — a page template from the Astro Component Library.
* https://astro.baysixmedia.com/templates/logistics-homepage/
*
* 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 OverlayPillHeader from "../components/OverlayPillHeader.astro";
import ServiceTickerHero from "../components/ServiceTickerHero.astro";
import BorderedLogoGrid from "../components/BorderedLogoGrid.astro";
import MissionStatAbout from "../components/MissionStatAbout.astro";
import StackedServiceCards from "../components/StackedServiceCards.astro";
import ImageBentoFeatures from "../components/ImageBentoFeatures.astro";
import StickyProcessSteps from "../components/StickyProcessSteps.astro";
import ScrollZoomVideo from "../components/ScrollZoomVideo.astro";
import StackedReviewCards from "../components/StackedReviewCards.astro";
import QuoteRequestPanel from "../components/QuoteRequestPanel.astro";
import StickyImageFaq from "../components/StickyImageFaq.astro";
import ArticleCardRow from "../components/ArticleCardRow.astro";
import ImageCtaBand from "../components/ImageCtaBand.astro";
import WordmarkMarqueeFooter from "../components/WordmarkMarqueeFooter.astro";
const overlayPillHeaderProps = {
"items": [
{
"label": "Pages",
"children": [
{
"label": "Home",
"href": "/"
},
{
"label": "Company",
"href": "/about"
},
{
"label": "Services",
"href": "/services"
},
{
"label": "Cold-chain freight",
"href": "/services/cold-chain"
},
{
"label": "Our drivers",
"href": "/team"
},
{
"label": "Dispatch notes",
"href": "/journal"
},
{
"label": "Track a load",
"href": "/track"
}
]
},
{
"label": "Company",
"href": "/about"
},
{
"label": "Services",
"href": "/services"
},
{
"label": "Journal",
"href": "/journal"
},
{
"label": "Contact",
"href": "/contact"
}
],
"brandName": "Tidewater",
"cta": {
"label": "Get a quote",
"href": "/contact"
},
"currentPath": "/services"
};
const serviceTickerHeroProps = {
"background": {
"alt": "",
"hint": "Loaded semi truck on a city highway, skyline behind, motion blur, cool grey light"
},
"headingLines": [
"Freight that",
"keeps moving"
],
"services": [
{
"name": "Road freight",
"image": {
"alt": "",
"hint": "Box truck on an open road, side view"
},
"href": "#road-freight"
},
{
"name": "Air cargo",
"image": {
"alt": "",
"hint": "Cargo plane loading at dusk"
},
"href": "#air-cargo"
},
{
"name": "Ocean shipping",
"image": {
"alt": "",
"hint": "Container ship under a port crane"
},
"href": "#ocean-shipping"
},
{
"name": "Warehousing",
"image": {
"alt": "",
"hint": "Warehouse racking with a forklift"
},
"href": "#warehousing"
}
],
"cta": {
"label": "Let's talk",
"href": "#contact"
},
"avatars": [
{},
{},
{}
],
"partnerCount": "340+",
"partnerText": "Carrier partners"
};
const borderedLogoGridProps = {
"label": "Trusted by leading shippers",
"logos": [
{
"name": "Northgate"
},
{
"name": "Kestrel Air"
},
{
"name": "Blueharbor"
},
{
"name": "Ironline"
},
{
"name": "Summit Haul"
},
{
"name": "Meridian"
},
{
"name": "Pinecrest"
},
{
"name": "Coastway"
}
]
};
const missionStatAboutProps = {
"statement": "We move freight across North America with the planning, people and live tracking that keep your shipments on schedule.",
"button": {
"label": "Our story",
"href": "#about"
},
"image": {
"alt": "A Tidewater Freight trailer on an open highway at sunrise",
"hint": "Box truck on a rural highway at sunrise, motion-blurred fields"
},
"vision": {
"title": "Our vision",
"text": "A supply chain where every load is visible from dock to doorstep.",
"image": {
"alt": "",
"hint": "Cut-out tanker truck, side view"
}
},
"mission": {
"title": "Our mission",
"text": "To give every shipper a carrier they never have to chase: on time, in touch, and honest about the load."
},
"flushTop": true
};
const stackedServiceCardsProps = {
"image": {
"hint": "Box truck seen from behind on an empty highway at dusk, tail lights on, cool blue haze"
},
"ctaHref": "/services",
"services": [
{
"title": "Truckload freight",
"description": "Dedicated and shared trailers moving your cargo across regional lanes and coast to coast, on a schedule you can plan around.",
"features": [
"Full truckload (FTL) capacity",
"Partial and LTL consolidation"
],
"href": "/services/truckload"
},
{
"title": "Warehousing & fulfilment",
"description": "Secure, climate-aware storage in three hubs, with pick, pack and dispatch handled by the same team that moves it.",
"features": [
"Live inventory dashboards",
"Same-day order dispatch"
],
"href": "/services/warehousing"
},
{
"title": "Expedited air cargo",
"description": "When a delay costs more than the freight, we book the next flight out and hand-carry the paperwork through customs.",
"features": [
"Next-flight-out booking",
"Door-to-door chain of custody"
],
"href": "/services/air-cargo"
},
{
"title": "Ocean & port drayage",
"description": "Container moves from the berth to your dock, with booking, demurrage watch and chassis handled for you.",
"features": [
"FCL and LCL bookings",
"Port-to-warehouse drayage"
],
"href": "/services/ocean"
}
]
};
const imageBentoFeaturesProps = {
"heading": "Why shippers keep trusting Tidewater with every load",
"tiles": [
{
"tone": "navy",
"title": "Dependable delivery",
"text": "Loads leave on the day we promised and land inside the window we quoted.",
"stat": {
"value": "140+",
"label": "Dispatchers and drivers",
"avatars": [
{
"hint": "Head-and-shoulders portrait, driver or dispatcher, plain background"
},
{
"hint": "Head-and-shoulders portrait, driver or dispatcher, plain background"
},
{
"hint": "Head-and-shoulders portrait, driver or dispatcher, plain background"
}
]
}
},
{
"tone": "image",
"image": {
"hint": "Forklift in a bright, empty warehouse aisle, racking and ceiling lights receding"
}
},
{
"tone": "accent",
"title": "Live shipment tracking",
"text": "Every trailer reports its position, so you see the load move instead of asking where it is."
},
{
"tone": "image",
"image": {
"hint": "Container ship being loaded at a port, cranes against a blue sky, wet quay"
}
},
{
"tone": "image",
"image": {
"hint": "Semi truck head-on at night, headlights on, motion-blurred tunnel or highway lights"
},
"title": "Overnight linehaul",
"text": "Team drivers keep freight rolling through the night on our busiest lanes."
}
]
};
const stickyProcessStepsProps = {
"heading": "Four steps from quote to signed delivery",
"ctaLabel": "Get in touch",
"ctaHref": "/contact",
"steps": [
{
"title": "Tell us what's moving",
"text": "Send the lane, the pallet count and the date it has to land. A planner comes back with a firm rate the same business day.",
"image": {
"hint": "Customer at a bright logistics front desk talking a shipment through with a planner"
}
},
{
"title": "Book the pickup",
"text": "Pick a window that suits your dock. Our driver arrives with the paperwork ready and the trailer swept.",
"image": {
"hint": "Driver with a clipboard shaking hands with a shipper beside an open cargo van, daylight"
}
},
{
"title": "Follow it on the road",
"text": "Every load pings its position on the hour, and you get a message the moment an ETA changes — not after.",
"image": {
"hint": "Semi truck on a straight highway between warehouses, low sun, yellow centre lines"
}
},
{
"title": "Signed at the door",
"text": "Delivery is photographed and signed for, and the proof lands in your inbox before the truck pulls away.",
"image": {
"hint": "Close-up of a boxed parcel being handed over at a sunlit doorway, hands only"
}
}
],
"flushTop": true
};
const scrollZoomVideoProps = {
"poster": {
"hint": "Looping 10–20 s clip: semi truck on a highway at dusk, tracking shot, warm sky"
}
};
const stackedReviewCardsProps = {
"heading": "What shippers say about working with us",
"reviews": [
{
"quote": "We moved our entire West Coast replenishment to Tidewater last spring. Not one missed window since, and when a load does slip, we hear about it before our customer does.",
"name": "Marisol Reyes",
"role": "Operations director",
"company": "Harborline Goods",
"logoSrc": "/stacked-review-cards/mark-harborline.svg",
"image": {
"hint": "Two warehouse workers lifting a carton together beside orange pallet racking, natural light"
}
},
{
"quote": "They treat a two-pallet order like it matters as much as a full trailer. The tracking is honest, the drivers are courteous, and the invoices match the quote.",
"name": "Tunde Okafor",
"role": "Founder",
"company": "Summit Ridge Supply",
"logoSrc": "/stacked-review-cards/mark-summit.svg",
"image": {
"hint": "Courier in a navy hi-vis jacket handing a parcel to a smiling customer at a front door"
}
},
{
"quote": "Holiday peak used to mean a war room. This year it meant a shared dashboard and a dispatcher who picked up on the second ring. That is the whole review.",
"name": "Ingrid Lindqvist",
"role": "Supply chain lead",
"company": "Kestrel Outfitters",
"logoSrc": "/stacked-review-cards/mark-kestrel.svg",
"image": {
"hint": "Bike courier with an insulated delivery box riding through a city street, motion-blurred traffic"
}
}
]
};
const quoteRequestPanelProps = {
"serviceOptions": [
"Full truckload (FTL)",
"Less than truckload (LTL)",
"Refrigerated",
"Flatbed & oversize",
"Expedited / hot shot",
"Warehousing & cross-dock"
],
"heading": "Tell us what's moving and we'll price the lane",
"phone": "+1 (916) 555-0148",
"email": "ops@tidewater.example",
"photo": {
"hint": "White semi truck on a highway at dusk, side-on, motion blur, cab facing right"
}
};
const stickyImageFaqProps = {
"heading": "Still have a question? Our dispatch team answers every email.",
"ctaLabel": "Book a pickup",
"ctaHref": "#quote",
"items": [
{
"question": "How fast will I get a freight quote?",
"answer": "Most quotes go out within two business hours. Send the pickup and delivery cities, the freight type and a rough weight, and a coordinator will come back with a rate and a pickup window."
},
{
"question": "Can I follow my load while it's on the road?",
"answer": "Yes. Every truck reports its position every fifteen minutes, and you get a tracking link by text and email the moment the driver checks in at pickup."
},
{
"question": "What kinds of freight do you haul?",
"answer": "Palletised dry goods, refrigerated food, building materials on flatbeds and oversize machinery with permits. We do not carry hazardous materials above placard thresholds."
},
{
"question": "Do you store goods between moves?",
"answer": "We run two cross-dock warehouses, in West Sacramento and Reno, with short-term racking for up to thirty days and same-day transfer to outbound trucks."
},
{
"question": "Do you ship across the border?",
"answer": "We move freight into Canada and Mexico through bonded partners at the major crossings and handle the customs paperwork on your behalf."
},
{
"question": "How long does a typical delivery take?",
"answer": "Regional lanes on the West Coast are usually next day. Coast-to-coast full truckloads take four to five days with a team driver, a little longer for partial loads."
},
{
"question": "Is my cargo covered while it's with you?",
"answer": "Every load carries $100,000 of cargo insurance as standard. Higher-value shipments can be covered in full for a small surcharge; ask when you book."
}
],
"photo": {
"hint": "Two warehouse workers in hi-vis vests loading cardboard boxes into the back of a white van, daylight"
}
};
const articleCardRowProps = {
"heading": "Field notes from the Tidewater dispatch desk",
"articles": [
{
"title": "How smarter lane planning trims empty miles off every route",
"href": "/blog/lane-planning",
"date": "Apr 14, 2026",
"datetime": "2026-04-14",
"image": {
"hint": "Aerial view of a container yard, stacked coloured boxes, one worker in hi-vis for scale"
}
},
{
"title": "What a last-mile ramp delivery really costs a shipper",
"href": "/blog/last-mile-costs",
"date": "Jun 3, 2026",
"datetime": "2026-06-03",
"image": {
"hint": "Driver wheeling a hand cart up the ramp into a box truck, leafy street, daylight"
}
},
{
"title": "Five dockside habits that keep containers moving on time",
"href": "/blog/dockside-habits",
"date": "Jul 21, 2026",
"datetime": "2026-07-21",
"image": {
"hint": "Dockside walkway with yellow railings, puddle reflections, gantry cranes behind"
}
}
],
"viewAll": {
"label": "View all",
"href": "/blog"
}
};
const imageCtaBandProps = {
"eyebrow": "Book a lane",
"heading": "Your next load could leave the dock tonight",
"cta": {
"label": "Get a quote",
"href": "/contact"
},
"image": {
"hint": "Driver handing a boxed parcel to a warehouse worker at the open back of a delivery van, daylight"
}
};
const wordmarkMarqueeFooterProps = {
"brandName": "Tidewater",
"blurb": "Dry van, reefer and flatbed freight out of the Port of Oakland since 2009 — tracked from the first scan to the signed delivery.",
"social": [
{
"label": "Dispatch journal feed",
"href": "/journal/rss.xml",
"icon": "rss"
},
{
"label": "Video yard tours",
"href": "/videos",
"icon": "play"
},
{
"label": "Driver community",
"href": "/community",
"icon": "chat"
},
{
"label": "Newsletter",
"href": "mailto:news@tidewater.example",
"icon": "mail"
}
],
"columns": [
{
"heading": "Quick links",
"links": [
{
"label": "Home",
"href": "/"
},
{
"label": "Company",
"href": "/about"
},
{
"label": "Services",
"href": "/services"
},
{
"label": "Journal",
"href": "/journal"
},
{
"label": "Contact",
"href": "/contact"
}
]
},
{
"heading": "Services",
"links": [
{
"label": "Full truckload",
"href": "/services/full-truckload"
},
{
"label": "Cold-chain freight",
"href": "/services/cold-chain"
},
{
"label": "Port drayage",
"href": "/services/drayage"
},
{
"label": "Cross-dock storage",
"href": "/services/cross-dock"
},
{
"label": "Final-mile delivery",
"href": "/services/final-mile"
}
]
},
{
"heading": "Reach us",
"links": [
{
"label": "1800 Maritime Street, Oakland, CA 94607",
"href": "/contact#depot"
},
{
"label": "+1 (510) 555-0147",
"href": "tel:+15105550147"
},
{
"label": "ops@tidewater.example",
"href": "mailto:ops@tidewater.example"
}
]
}
],
"legalLinks": [
{
"label": "Privacy policy",
"href": "/privacy"
},
{
"label": "Terms of carriage",
"href": "/terms"
}
],
"wordmark": "TIDEWATER",
"image": {
"hint": "Loaded semi truck on an open highway at sunset, low side angle, motion blur"
}
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>[Business name] — [Freight service] across [Region]</title>
<meta name="description" content="[One-sentence description of the carrier, its lanes and its offer.]" />
<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>
<!-- Overlay pill header -->
<OverlayPillHeader {...overlayPillHeaderProps} />
<main id="main">
<!-- Service ticker hero -->
<ServiceTickerHero {...serviceTickerHeroProps} />
<!-- Bordered logo grid -->
<BorderedLogoGrid {...borderedLogoGridProps} />
<!-- Mission stat about -->
<MissionStatAbout {...missionStatAboutProps} />
<!-- Stacked service cards -->
<StackedServiceCards {...stackedServiceCardsProps} />
<!-- Image bento features -->
<ImageBentoFeatures {...imageBentoFeaturesProps} />
<!-- Sticky process steps -->
<StickyProcessSteps {...stickyProcessStepsProps} />
<!-- Scroll zoom video -->
<ScrollZoomVideo {...scrollZoomVideoProps} />
<!-- Stacked review cards -->
<StackedReviewCards {...stackedReviewCardsProps} />
<!-- Quote request panel -->
<QuoteRequestPanel {...quoteRequestPanelProps} />
<!-- Sticky image FAQ -->
<StickyImageFaq {...stickyImageFaqProps} />
<!-- Article card row -->
<ArticleCardRow {...articleCardRowProps} />
<!-- Image CTA band -->
<ImageCtaBand {...imageCtaBandProps} />
</main>
<!-- Wordmark marquee footer -->
<WordmarkMarqueeFooter {...wordmarkMarqueeFooterProps} />
</body>
</html>
===== END src/pages/logistics-homepage.astro =====
===== FILE (required): src/components/OverlayPillHeader.astro =====
---
/**
* OverlayPillHeader
*
* A site header that sits over the top of a hero: brand on the left, a
* translucent glass pill of links in the middle (one of which can open a
* dropdown), and a white pill call-to-action with a navy arrow disc on the
* right. Below 1024px the pill and the CTA fold into a menu button and a
* white card that drops under the bar.
*
* `overlay` (default true) positions the root absolutely at the top of its
* containing block, so it scrolls away with the page over whatever the page
* opens with. `overlay={false}` puts it back in the flow on a navy band.
*
* Reads --lx-* design tokens from any ancestor, with fallbacks, so it works
* alone and re-themes with a page.
*/
export interface OverlayPillHeaderLink {
label: string;
href: string;
}
export interface OverlayPillHeaderItem {
label: string;
/** Ignored when `children` is present: the item becomes a dropdown trigger. */
href?: string;
/** Renders the item as a real <button> that opens a single-column panel. */
children?: OverlayPillHeaderLink[];
}
export interface OverlayPillHeaderProps {
items?: OverlayPillHeaderItem[];
/** Pass Astro.url.pathname. Matching links get aria-current="page". */
currentPath?: string;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark with an image. */
logo?: { src: string; alt: string; width?: number; height?: number };
cta?: { label: string; href: string } | null;
/** Absolute over the hero (true) or in the flow on a navy band (false). */
overlay?: boolean;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends OverlayPillHeaderProps {}
/* 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 = [],
currentPath,
brandName,
brandHref = "/",
logo,
cta = null,
overlay = true,
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
/* Ids for aria-controls. Seeded from `id` when given, random otherwise, so a
second instance never collides with the first. */
const seed = id ? id : `oph-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) =>
Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: OverlayPillHeaderItem) =>
Boolean(item.children?.some((c) => isCurrent(c.href)));
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasContent = hasBrand || hasItems || Boolean(cta);
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
class:list={["oph", overlay ? "oph--overlay" : "oph--flow"]}
id={id}
data-header-root
data-oph-root
>
<div class="oph-bar">
{hasBrand && (
<a class="oph-brand" href={brandHref} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<img
class="oph-logo-img"
src={logo.src}
alt={logo.alt}
width={logo.width ?? 114}
height={logo.height ?? 34}
/>
) : (
<>
<svg class="oph-mark" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
<rect width="40" height="40" rx="11" fill="var(--lx-accent, #ff5f00)" />
<path
d="M7 17.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4"
fill="none"
stroke="#fff"
stroke-width="3.4"
stroke-linecap="round"
/>
<path
d="M7 25.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4"
fill="none"
stroke="var(--lx-navy, #0a2540)"
stroke-width="3.4"
stroke-linecap="round"
/>
</svg>
<span class="oph-wordmark" aria-hidden="true">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="oph-nav" aria-label={navLabel}>
<ul class="oph-pill" role="list">
{
items.map((item, i) => {
if (item.children && item.children.length > 0) {
const panelId = `${seed}-panel-${i}`;
return (
<li class="oph-item oph-item--dd" data-oph-dd>
<button
type="button"
class="oph-link oph-trigger"
aria-expanded="false"
aria-controls={panelId}
data-oph-trigger
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="oph-chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="oph-panel" id={panelId} data-oph-panel>
<ul role="list">
{item.children.map((child) => (
<li>
<a
class="oph-panel-link"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</div>
</li>
);
}
return (
<li class="oph-item">
<a
class="oph-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
);
})
}
</ul>
</nav>
)}
{
cta && (
<a class="oph-cta" href={cta.href}>
<span>{cta.label}</span>
<span class="oph-cta-disc" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</a>
)
}
{(hasItems || cta) && (
<button
type="button"
class="oph-burger"
aria-expanded="false"
aria-controls={menuId}
data-oph-burger
>
<span class="oph-sr">{menuLabel}</span>
<span class="oph-burger-lines" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
)}
</div>
{(hasItems || cta) && (
<nav class="oph-menu" id={menuId} aria-label={navLabel} data-oph-menu>
{hasItems && (
<ul class="oph-menu-list" role="list">
{
items.map((item) =>
item.children && item.children.length > 0 ? (
<li class="oph-menu-group">
<span class="oph-menu-label">{item.label}</span>
<ul role="list">
{item.children.map((child) => (
<li>
<a
class="oph-menu-link"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</li>
) : (
<li>
<a
class="oph-menu-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
),
)
}
</ul>
)}
{
cta && (
<a class="oph-cta oph-cta--menu" href={cta.href}>
<span>{cta.label}</span>
<span class="oph-cta-disc" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</a>
)
}
</nav>
)}
</header>
)}
<style>
.oph {
--oph-navy: var(--lx-navy, #0a2540);
--oph-accent: var(--lx-accent, #ff5f00);
--oph-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--oph-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--oph-dur: 220ms;
box-sizing: border-box;
width: 100%;
padding: 24px 30px;
font-family: var(--oph-body);
color: #fff;
z-index: 30;
}
.oph *,
.oph *::before,
.oph *::after {
box-sizing: border-box;
}
.oph ul {
margin: 0;
padding: 0;
list-style: none;
}
.oph--overlay {
position: absolute;
inset: 0 0 auto 0;
background: transparent;
}
.oph--flow {
position: relative;
background: var(--oph-navy);
}
.oph-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
max-width: 1200px;
min-height: 50px;
margin: 0 auto;
}
/* ---- brand ---- */
.oph-brand {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
color: #fff;
text-decoration: none;
border-radius: 8px;
flex: none;
}
.oph-mark {
width: 34px;
height: 34px;
flex: none;
}
.oph-wordmark {
font-family: var(--oph-display);
font-size: 34px;
line-height: 1;
letter-spacing: -0.02em;
text-transform: uppercase;
padding-top: 3px;
}
.oph-logo-img {
display: block;
height: 34px;
width: auto;
}
/* ---- the glass pill ---- */
.oph-nav {
display: none;
}
/* `.oph .oph-pill`, not `.oph-pill`: the `.oph ul` list reset would
otherwise outrank it and zero the pill's padding. */
.oph .oph-pill {
display: flex;
align-items: center;
gap: 40px;
height: 50px;
padding: 0 24px;
border-radius: 100px;
background: rgb(255 255 255 / 0.1);
border: 1px solid rgb(255 255 255 / 0.16);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.oph-item {
position: relative;
}
.oph-link {
display: inline-flex;
align-items: center;
gap: 4px;
/* 14px type in a 44px target; the negative margin gives the space back
so the 40px rhythm between words is the reference's. */
padding: 15px 8px;
margin: -15px -8px;
border: 0;
border-radius: 100px;
background: none;
color: #fff;
font: 600 14px/14px var(--oph-body);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
transition: color var(--oph-dur) ease;
}
.oph-link:hover,
.oph-link[aria-current="page"],
.oph-trigger[data-current] {
color: var(--oph-accent);
}
.oph-chev {
width: 14px;
height: 14px;
transition: transform var(--oph-dur) ease;
}
.oph-trigger[aria-expanded="true"] .oph-chev {
transform: rotate(180deg);
}
/* ---- dropdown panel ----
Without script the panel opens on hover and on focus-within, so every
link stays reachable. Once the controller has booted it owns the state,
and a closed panel is visibility: hidden — out of the tab order. */
.oph-panel {
position: absolute;
top: calc(100% + 26px);
left: -24px;
min-width: 220px;
padding: 20px;
border-radius: 10px;
background: #fff;
box-shadow: 0 18px 40px rgb(10 37 64 / 0.18);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition:
opacity var(--oph-dur) ease,
transform var(--oph-dur) ease,
visibility 0s linear var(--oph-dur);
z-index: 2;
}
/* A bridge over the gap between trigger and panel, so pointer travel
from one to the other does not cross dead space. */
.oph-panel::before {
content: "";
position: absolute;
inset: -26px 0 100% 0;
}
.oph-panel ul {
display: flex;
flex-direction: column;
gap: 4px;
}
.oph:not([data-oph-ready]) .oph-item--dd:hover .oph-panel,
.oph:not([data-oph-ready]) .oph-item--dd:focus-within .oph-panel,
.oph[data-oph-ready] .oph-item--dd[data-open] .oph-panel {
opacity: 1;
transform: none;
pointer-events: auto;
visibility: visible;
transition:
opacity var(--oph-dur) ease,
transform var(--oph-dur) ease,
visibility 0s;
}
.oph[data-oph-ready] .oph-item--dd:not([data-open]) .oph-panel {
visibility: hidden;
}
.oph-panel-link {
display: flex;
align-items: center;
min-height: 36px;
padding: 0 10px;
margin: 0 -10px;
border-radius: 6px;
color: var(--oph-navy);
font: 600 14px/14px var(--oph-body);
text-decoration: none;
white-space: nowrap;
transition: color var(--oph-dur) ease, background-color var(--oph-dur) ease;
}
.oph-panel-link:hover {
background: var(--lx-surface, #f6f6f6);
color: var(--oph-accent);
}
.oph-panel-link[aria-current="page"] {
color: var(--oph-accent);
}
/* ---- CTA ---- */
.oph-cta {
display: none;
align-items: center;
gap: 24px;
height: 50px;
padding: 5px 5px 5px 19px;
border-radius: 100px;
background: #fff;
color: var(--oph-navy);
font: 600 14px/14px var(--oph-body);
text-decoration: none;
white-space: nowrap;
flex: none;
}
.oph-cta-disc {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--oph-navy);
color: #fff;
overflow: hidden;
transition: background-color var(--oph-dur) ease;
}
.oph-cta-disc svg {
width: 24px;
height: 24px;
transition: transform var(--oph-dur) ease;
}
.oph-cta:hover .oph-cta-disc {
background: var(--oph-accent);
}
.oph-cta:hover .oph-cta-disc svg {
transform: rotate(-45deg);
}
/* ---- burger ---- */
.oph-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin-right: -7px;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: #fff;
cursor: pointer;
}
/* No script, no toggle: the menu card is simply shown open below the bar,
so a button that could do nothing is not offered. */
.oph:not([data-oph-ready]) .oph-burger {
display: none;
}
.oph-burger-lines {
position: relative;
display: block;
width: 22px;
height: 14px;
}
.oph-burger-lines span {
position: absolute;
left: 0;
width: 22px;
height: 2px;
border-radius: 1px;
background: currentColor;
transition: transform var(--oph-dur) ease, opacity var(--oph-dur) ease;
}
.oph-burger-lines span:nth-child(1) { top: 0; }
.oph-burger-lines span:nth-child(2) { top: 6px; }
.oph-burger-lines span:nth-child(3) { top: 12px; }
.oph[data-oph-open] .oph-burger-lines span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.oph[data-oph-open] .oph-burger-lines span:nth-child(2) { opacity: 0; }
.oph[data-oph-open] .oph-burger-lines span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
/* ---- mobile menu card ---- */
.oph-menu {
max-width: 1200px;
margin: 24px auto 0;
padding: 20px;
border-radius: 10px;
background: #fff;
color: var(--oph-navy);
box-shadow: 0 18px 40px rgb(10 37 64 / 0.18);
transition:
opacity var(--oph-dur) ease,
transform var(--oph-dur) ease,
visibility 0s linear var(--oph-dur);
}
/* With script, the card hangs under the bar out of the flow, so opening
it covers the hero rather than pushing the page down. */
.oph[data-oph-ready] .oph-menu {
position: absolute;
top: calc(100% - 6px);
left: 30px;
right: 30px;
margin: 0;
}
.oph[data-oph-ready]:not([data-oph-open]) .oph-menu {
opacity: 0;
transform: translateY(-8px);
visibility: hidden;
pointer-events: none;
}
.oph[data-oph-ready][data-oph-open] .oph-menu {
transition:
opacity var(--oph-dur) ease,
transform var(--oph-dur) ease,
visibility 0s;
}
.oph-menu-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.oph-menu-group {
padding-bottom: 8px;
margin-bottom: 6px;
border-bottom: 1px solid rgb(10 37 64 / 0.14);
}
.oph-menu-label {
display: block;
padding: 6px 0 4px;
color: #555;
font: 500 12px/14px var(--oph-body);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.oph-menu-link {
display: flex;
align-items: center;
min-height: 44px;
color: var(--oph-navy);
font: 600 14px/14px var(--oph-body);
text-decoration: none;
border-radius: 6px;
}
.oph-menu-link[aria-current="page"] {
color: var(--oph-accent);
}
.oph-cta--menu {
display: flex;
justify-content: space-between;
margin-top: 14px;
background: var(--oph-navy);
color: #fff;
}
.oph-cta--menu .oph-cta-disc {
background: #fff;
color: var(--oph-navy);
}
/* ---- focus ---- */
.oph a:focus-visible,
.oph button:focus-visible {
outline: 2px solid var(--oph-accent);
outline-offset: 2px;
}
.oph-cta:focus-visible {
outline-color: #fff;
outline-offset: 3px;
}
.oph-menu a:focus-visible,
.oph-panel a:focus-visible {
outline-color: var(--oph-accent);
}
.oph-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- phone ---- */
@media (max-width: 809.98px) {
.oph {
padding: 20px;
}
.oph-mark {
width: 33px;
height: 33px;
}
.oph-wordmark {
font-size: 32px;
}
.oph-menu {
margin-top: 20px;
}
.oph[data-oph-ready] .oph-menu {
left: 20px;
right: 20px;
}
}
/* ---- desktop: the pill and CTA appear, the menu goes ---- */
@media (min-width: 1024px) {
.oph-nav {
display: block;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.oph-cta {
display: inline-flex;
}
.oph-burger,
.oph .oph-menu {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.oph {
--oph-dur: 1ms;
}
}
@media (forced-colors: active) {
.oph-pill,
.oph-panel,
.oph-menu {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* OverlayPillHeader controller.
*
* One closure per instance, every lookup scoped to that instance's root,
* every listener on an AbortController that is aborted before a
* client-side navigation swaps the document. No body scroll lock: the
* mobile menu is a card under the bar, not a sheet over the page.
*/
function initOverlayPillHeader(root: HTMLElement) {
/* Idempotent: a second boot over the same element is a no-op. */
if (root.dataset.ophReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-oph-burger]");
const menu = root.querySelector<HTMLElement>("[data-oph-menu]");
const dropdowns = [...root.querySelectorAll<HTMLElement>("[data-oph-dd]")];
root.dataset.ophReady = "";
const controller = new AbortController();
const { signal } = controller;
const desktop = window.matchMedia("(min-width: 1024px)");
/* ---- dropdowns: one open at a time, per instance ---- */
let openDd: HTMLElement | null = null;
const triggerOf = (dd: HTMLElement) =>
dd.querySelector<HTMLButtonElement>("[data-oph-trigger]");
function closeDropdown(returnFocus = false) {
if (!openDd) return;
const dd = openDd;
openDd = null;
dd.removeAttribute("data-open");
const trigger = triggerOf(dd);
trigger?.setAttribute("aria-expanded", "false");
if (returnFocus) trigger?.focus({ preventScroll: true });
}
function openDropdown(dd: HTMLElement) {
if (openDd === dd) return;
closeDropdown();
openDd = dd;
dd.setAttribute("data-open", "");
triggerOf(dd)?.setAttribute("aria-expanded", "true");
}
dropdowns.forEach((dd) => {
const trigger = triggerOf(dd);
trigger?.addEventListener(
"click",
() => (openDd === dd ? closeDropdown() : openDropdown(dd)),
{ signal },
);
/* Focus leaving the item entirely closes it, so Tab past the last
link does not leave a panel hanging open behind the focus. */
dd.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (openDd === dd && next && !dd.contains(next)) closeDropdown();
},
{ signal },
);
});
/* ---- mobile menu ---- */
let menuOpen = false;
function setMenu(next: boolean, returnFocus = false) {
if (!burger || !menu || menuOpen === next) return;
menuOpen = next;
root.toggleAttribute("data-oph-open", next);
burger.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger.focus({ preventScroll: true });
}
burger?.addEventListener("click", () => setMenu(!menuOpen), { signal });
/* Choosing a route closes the menu. */
menu?.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
/* Escape is bound to the document, not the root: after a client-side
navigation focus sits outside the header, and Escape must still work. */
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape") return;
if (openDd) {
event.preventDefault();
const inside = openDd.contains(document.activeElement);
closeDropdown(inside || document.activeElement === document.body);
} else if (menuOpen) {
event.preventDefault();
setMenu(false, true);
}
},
{ signal },
);
/* Outside click closes whichever is open. */
document.addEventListener(
"pointerdown",
(event) => {
const target = event.target as Node;
if (openDd && !openDd.contains(target)) closeDropdown();
if (menuOpen && !root.contains(target)) setMenu(false);
},
{ signal },
);
/* Crossing the breakpoint resets both: a menu open at phone width must
not be waiting open when the viewport narrows again later, and a
dropdown has no business staying open once its pill is hidden. */
desktop.addEventListener(
"change",
() => {
setMenu(false);
closeDropdown();
},
{ signal },
);
function destroy() {
closeDropdown();
setMenu(false);
controller.abort();
delete root.dataset.ophReady;
}
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-oph-root]").forEach(initOverlayPillHeader);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Registered once per module, so it cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/OverlayPillHeader.astro =====
===== FILE (required): src/components/ServiceTickerHero.astro =====
---
/**
* ServiceTickerHero
*
* Full-bleed photo hero with a navy wash, a partner badge and a two-line
* display heading bottom-left, and a white "Services" card bottom-right that
* ticks through a list of services on a progress line.
*
* Script-free, it is a finished hero showing the first service. The script
* adds the ticker: a CSS animation on the progress line whose `animationend`
* advances the card, so pausing is just `animation-play-state` and resuming
* picks up exactly where it stopped.
*/
/**
* A photo slot. With `src` it renders the <img>; without it, a labelled
* striped placeholder of the same box, describing the photo to find.
*/
interface ImageSlot {
src?: string;
/** Empty marks the image decorative. */
alt?: string;
/** What the photo should show. Used by the placeholder only. */
hint?: string;
}
interface HeroService {
name: string;
/** The card's media strip. Omit entirely for an empty strip. */
image?: ImageSlot;
/** Makes the service name a link. */
href?: string;
}
interface HeroCta {
label: string;
href: string;
}
interface Props {
id?: string;
/** Full-bleed background photo. Decorative by default (empty alt). */
background?: ImageSlot;
/** Small round portraits in the badge. Decorative, so alt defaults to "". */
avatars?: ImageSlot[];
/** The accented part of the badge, e.g. "120+". Empty drops it. */
partnerCount?: string;
partnerText?: string;
/** One entry per line on tablet and desktop; they run together on phones. */
headingLines?: string[];
headingLevel?: "h1" | "h2";
cardLabel?: string;
services?: HeroService[];
cta?: HeroCta;
/** Milliseconds each service is shown for while autoplaying. */
interval?: number;
autoplay?: boolean;
/** At least the viewport tall from 810px up. Off, it sizes to content. */
fullHeight?: boolean;
fontStylesheet?: string;
}
const {
id,
/* No copy, services or link of its own: the catalog demo passes them
through the registry's props, so a copied hero never ships an invented
business. */
background = {},
avatars = [],
partnerCount = "",
partnerText = "",
headingLines = [],
headingLevel = "h1",
cardLabel = "Services",
services: servicesProp = [],
cta,
interval = 3500,
autoplay = true,
fullHeight = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;
const Heading = headingLevel;
/* A service with no name has nothing to show: it is not a slide. */
const services = servicesProp.filter((service) => !!service?.name);
const count = services.length;
const pad = (n: number) => String(n).padStart(2, "0");
/* Constant per slot, from the reference measurements: what to pick a photo against. */
const BG_SPEC = "16:9 · 1920 × 1080 px";
const MEDIA_SPEC = "~5:1 · 800 × 160 px";
const bgHint = background?.hint ?? "Hero background photo";
const safeInterval = Number.isFinite(interval) && interval >= 1000 ? Math.round(interval) : 3500;
/* A photo alone is not a hero: with no badge, heading or services, render nothing. */
const hasPartner = avatars.length > 0 || !!partnerText || !!partnerCount;
const hasHeading = headingLines.length > 0;
const hasContent = hasPartner || hasHeading || count > 0;
---
{hasContent && (
<section
class="sth"
id={id}
data-sth-root
data-sth-autoplay={autoplay && count > 1 ? "1" : "0"}
data-full-height={fullHeight ? "" : undefined}
style={`--sth-interval: ${safeInterval}ms;`}
>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="sth-bg">
{
background?.src ? (
<img
class="sth-bg-img"
src={background.src}
alt={background.alt ?? ""}
width="1440"
height="900"
loading="eager"
fetchpriority="high"
decoding="async"
/>
) : (
<div class="sth-ph sth-ph--dark sth-bg-img" role="img" aria-label={`Image placeholder: ${bgHint}`} />
)
}
<div class="sth-overlay" aria-hidden="true"></div>
{
!background?.src && (
<p class="sth-bg-ph-text" aria-hidden="true">
<span class="sth-ph-label">Image</span>
<span class="sth-ph-hint">{bgHint}</span>
<span class="sth-ph-spec">{BG_SPEC}</span>
</p>
)
}
</div>
<div class="sth-container">
{(hasPartner || hasHeading) && (
<div class="sth-left">
{
hasPartner && (
<p class="sth-partner">
{avatars.length > 0 && (
<span class="sth-avatars">
{avatars.map((avatar) =>
avatar.src ? (
<img class="sth-avatar" src={avatar.src} alt={avatar.alt ?? ""} width="40" height="40" loading="eager" decoding="async" />
) : (
<span class="sth-avatar sth-ph sth-ph--dark sth-ph--avatar" aria-hidden="true" />
),
)}
</span>
)}
<span class="sth-partner-text">
{partnerCount && <span class="sth-partner-count">{partnerCount}</span>}
{partnerCount && partnerText ? " " : ""}
{partnerText}
</span>
</p>
)
}
{hasHeading && (
<Heading class="sth-heading">
{headingLines.map((line, i) => (
<Fragment>
{i > 0 ? " " : ""}
<span class="sth-line">{line}</span>
</Fragment>
))}
</Heading>
)}
</div>
)}
{
count > 0 && (
<div class="sth-card" role="region" aria-roledescription="carousel" aria-label={cardLabel} data-sth-card>
<div class="sth-card-top">
<p class="sth-card-label" aria-hidden="true">{cardLabel}</p>
{count > 1 && (
<div class="sth-controls" data-sth-controls hidden>
<button type="button" class="sth-ctl" data-sth-prev aria-label="Previous service">
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" focusable="false"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
</button>
<button type="button" class="sth-ctl" data-sth-toggle aria-label="Pause service rotation" hidden>
<svg class="sth-icon-pause" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" focusable="false"><path d="M5 3.5v9M11 3.5v9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>
<svg class="sth-icon-play" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" focusable="false"><path d="M5 3v10l8-5z" fill="currentColor" /></svg>
</button>
<button type="button" class="sth-ctl" data-sth-next aria-label="Next service">
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" focusable="false"><path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
</button>
</div>
)}
<p class="sth-counter" aria-hidden="true">
<span class="sth-counter-current" data-sth-current>01</span><span class="sth-counter-slash">/</span><span class="sth-counter-total">{pad(count)}</span>
</p>
</div>
<div class="sth-slides">
{services.map((service, i) => (
<div
class:list={["sth-slide", { "is-active": i === 0 }]}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${count}`}
data-sth-slide
hidden={i !== 0}
style={`--sth-pos: ${(i + 1) / count};`}
>
<div class="sth-media">
{service.image?.src ? (
<img src={service.image.src} alt={service.image.alt ?? ""} width="800" height="160" loading={i === 0 ? "eager" : "lazy"} decoding="async" />
) : service.image ? (
<div class="sth-ph" role="img" aria-label={`Image placeholder: ${service.image.hint ?? service.name}`}>
<span class="sth-ph-text">
<span class="sth-ph-label">Image</span>
<span class="sth-ph-hint">{service.image.hint ?? service.name}</span>
<span class="sth-ph-spec">{MEDIA_SPEC}</span>
</span>
</div>
) : null}
</div>
<div class="sth-line-track" aria-hidden="true">
<span class="sth-line-fill" data-sth-fill />
</div>
<p class="sth-name">
{service.href ? <a href={service.href}>{service.name}</a> : service.name}
</p>
</div>
))}
</div>
{cta?.href && (
<a class="sth-cta" href={cta.href}>
<span class="sth-cta-label">{cta.label}</span>
<span class="sth-cta-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" focusable="false"><path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>
</a>
)}
<p class="sth-sr-only" aria-live="polite" aria-atomic="true" data-sth-live></p>
</div>
)
}
</div>
</section>
)}
<style>
.sth {
--sth-navy: var(--lx-navy, #0a2540);
--sth-accent: var(--lx-accent, #ff5f00);
--sth-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--sth-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--sth-hairline: rgba(10, 37, 64, 0.14);
--sth-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
isolation: isolate;
display: flex;
justify-content: center;
align-items: flex-end;
box-sizing: border-box;
overflow: clip;
/* Top padding clears a 98px overlay header with room to spare; the
hero reserves no space for it, the header simply sits over this. */
padding: 140px 20px 60px;
background: var(--sth-navy);
color: #fff;
font-family: var(--sth-body);
}
.sth *,
.sth *::before,
.sth *::after {
box-sizing: border-box;
}
.sth-bg {
position: absolute;
inset: 0;
z-index: -1;
}
.sth-bg-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
/* Favour the right: the overlay darkens the left, where the copy is. */
object-position: 70% 50%;
}
/* ---------- image placeholders ---------- */
.sth-ph {
display: grid;
place-items: center;
width: 100%;
height: 100%;
padding: 16px;
container-type: size;
border: 1px dashed rgba(10, 37, 64, 0.22);
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgba(10, 37, 64, 0.62);
font-family: var(--sth-body);
text-align: center;
}
.sth-ph--dark {
border-color: rgba(255, 255, 255, 0.25);
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0 12px, rgba(255, 255, 255, 0.1) 12px 24px);
color: rgba(255, 255, 255, 0.72);
}
/* Too small to label: stripes only. Decorative, like the real avatars. */
.sth-ph--avatar {
padding: 0;
border-radius: 50%;
background-color: #16324f;
}
.sth-ph-text,
.sth-bg-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 28ch;
}
.sth-ph-label {
font: 500 11px/1 var(--sth-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sth-ph-hint {
font: 500 13px/1.35 var(--sth-body);
}
.sth-ph-spec {
font: 400 12px/1.2 var(--sth-body);
}
/* Lines drop as the box shrinks: the label first, then the spec. */
@container (max-height: 120px) {
.sth-ph-label {
display: none;
}
}
@container (max-height: 110px) {
.sth-ph-spec {
display: none;
}
}
@container (max-height: 36px) {
.sth-ph-text {
display: none;
}
}
/* The background's label sits above the overlay, in the band between an
overlay header and the content, so nothing covers it. */
.sth-bg-ph-text {
position: absolute;
top: 82px;
left: 50%;
width: calc(100% - 40px);
max-width: 44ch;
margin: 0;
gap: 4px;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.72);
text-align: center;
}
.sth-bg-ph-text .sth-ph-spec {
display: none;
}
.sth-overlay {
position: absolute;
inset: 0;
background: linear-gradient(90deg, var(--sth-navy) 0%, rgba(10, 37, 64, 0.3) 78%);
}
.sth-container {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 40px;
width: 100%;
max-width: 1200px;
}
.sth-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
min-width: 0;
}
/* ---------- partner badge ---------- */
.sth-partner {
display: inline-flex;
align-items: center;
gap: 10px;
max-width: 100%;
margin: 0;
padding: 5px 12px 5px 5px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 100px;
background: rgba(10, 37, 64, 0.16);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.sth-avatars {
display: flex;
flex: none;
}
.sth-avatar {
display: block;
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 0 2px rgba(10, 37, 64, 0.5);
}
.sth-avatar + .sth-avatar {
margin-left: -8px;
}
.sth-partner-text {
font: 500 14px/14px var(--sth-body);
text-transform: uppercase;
color: #fff;
}
.sth-partner-count {
color: var(--sth-accent);
}
/* ---------- heading ---------- */
.sth-heading {
margin: 0;
font: 400 40px/32px var(--sth-display);
letter-spacing: -0.02em;
text-transform: uppercase;
color: #fff;
/* Bebas' cap height sits high in its box; this keeps descender-less
lines from clipping at tight line-heights. */
padding-top: 0.06em;
}
/* ---------- card ---------- */
.sth-card {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
padding: 20px;
border-radius: 10px;
background: #fff;
color: var(--sth-navy);
}
.sth-card-top {
display: flex;
align-items: center;
gap: 12px;
min-height: 28px;
}
.sth-card-label {
margin: 0;
margin-right: auto;
font: 600 14px/14px var(--sth-body);
}
.sth-controls {
display: flex;
gap: 6px;
}
.sth-controls[hidden] {
display: none;
}
.sth-ctl {
position: relative;
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--sth-hairline);
border-radius: 50%;
background: #fff;
color: var(--sth-navy);
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
/* 44px hit area around a 28px disc. */
.sth-ctl::after {
content: "";
position: absolute;
inset: -8px;
}
.sth-ctl[hidden] {
display: none;
}
.sth-ctl:hover {
border-color: var(--sth-navy);
background: var(--sth-navy);
color: #fff;
}
.sth-ctl:focus-visible {
outline: 2px solid var(--sth-accent);
outline-offset: 2px;
}
.sth-ctl .sth-icon-play,
.sth-ctl[data-paused] .sth-icon-pause {
display: none;
}
.sth-ctl[data-paused] .sth-icon-play {
display: block;
}
.sth-counter {
display: flex;
margin: 0;
font: 600 14px/14px var(--sth-body);
font-variant-numeric: tabular-nums;
}
.sth-counter-total {
color: var(--sth-hairline);
}
.sth-slides {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.sth-slide {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.sth-slide[hidden] {
display: none;
}
/* On phones the reference card has no picture, just air above the line. */
.sth-media {
flex: 1;
min-height: 40px;
margin-block: 0;
overflow: hidden;
border-radius: 6px;
}
.sth-media .sth-ph {
display: none;
}
.sth-media img {
display: none;
width: 100%;
height: 100%;
object-fit: cover;
}
.sth-line-track {
position: relative;
height: 1px;
overflow: hidden;
background: var(--sth-hairline);
}
.sth-line-fill {
position: absolute;
inset: 0;
background: var(--sth-accent);
transform-origin: 0 50%;
/* Script-free and under reduced motion the line is a plain position
indicator: one quarter for the first of four, and so on. */
transform: scaleX(var(--sth-pos, 0));
}
.sth-name {
display: flex;
align-items: center;
min-height: 50px;
margin: 20px 0 0;
/* Room for the CTA, which sits in the same row. */
padding-right: 152px;
font: 400 18px/18px var(--sth-display);
letter-spacing: -0.02em;
text-transform: uppercase;
color: var(--sth-navy);
}
.sth-name a {
color: inherit;
text-decoration: none;
border-radius: 2px;
}
.sth-name a:hover {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.sth-name a:focus-visible {
outline: 2px solid var(--sth-accent);
outline-offset: 3px;
}
.sth-cta {
position: absolute;
right: 20px;
bottom: 20px;
display: inline-flex;
align-items: center;
gap: 24px;
min-height: 50px;
padding: 5px 5px 5px 19px;
border-radius: 100px;
background: var(--sth-navy);
color: #fff;
font: 600 14px/14px var(--sth-body);
text-decoration: none;
white-space: nowrap;
transition: background-color 0.25s ease;
}
.sth-cta-icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: #fff;
color: var(--sth-navy);
}
.sth-cta-icon svg {
transition: transform 0.3s var(--sth-ease);
}
.sth-cta:hover {
background: #13355a;
}
.sth-cta:hover .sth-cta-icon svg {
transform: translateX(3px);
}
.sth-cta:focus-visible {
outline: 2px solid var(--sth-accent);
outline-offset: 3px;
}
.sth-sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------- scripted ticker ---------- */
.sth[data-sth-ticking] .sth-slide[data-sth-run] .sth-line-fill {
transform: scaleX(0);
animation: sth-fill var(--sth-interval) linear forwards;
animation-play-state: paused;
}
.sth[data-sth-running] .sth-slide[data-sth-run] .sth-line-fill {
animation-play-state: running;
}
@keyframes sth-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.sth[data-sth-ready] .sth-slide[data-sth-enter] .sth-media,
.sth[data-sth-ready] .sth-slide[data-sth-enter] .sth-name {
animation: sth-in 0.5s var(--sth-ease) both;
}
.sth[data-sth-ready] .sth-slide[data-sth-enter] .sth-name {
animation-delay: 0.05s;
}
.sth[data-sth-ready] .sth-counter-current[data-sth-enter] {
animation: sth-in 0.4s var(--sth-ease) both;
}
@keyframes sth-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
/* ---------- tablet ---------- */
@media (min-width: 810px) {
.sth {
padding: 200px 30px 70px;
}
.sth[data-full-height] {
min-height: 100vh;
min-height: 100svh;
}
.sth-left {
gap: 30px;
}
.sth-partner {
padding: 5px 16px 5px 5px;
}
.sth-avatar {
width: 40px;
height: 40px;
}
.sth-avatar + .sth-avatar {
margin-left: -10px;
}
.sth-heading {
font-size: 88px;
line-height: 72px;
}
.sth-line {
display: block;
}
.sth-card {
align-self: flex-end;
width: 416px;
max-width: 100%;
height: 240px;
}
.sth-media {
margin-block: 12px;
}
.sth-media .sth-ph {
border-radius: inherit;
}
.sth-media img {
display: block;
}
.sth-media .sth-ph {
display: grid;
padding: 6px 12px;
}
.sth-bg-ph-text {
top: 124px;
gap: 6px;
}
.sth-bg-ph-text .sth-ph-spec {
display: block;
}
.sth-name {
font-size: 24px;
line-height: 24px;
}
}
/* ---------- desktop ---------- */
@media (min-width: 1200px) {
.sth {
padding: 240px 30px 90px;
}
.sth-container {
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
gap: 40px;
}
.sth-left {
padding-bottom: 10px;
}
.sth-heading {
font-size: 120px;
line-height: 96px;
}
.sth-card {
flex: none;
}
}
@media (max-width: 379px) {
.sth-name {
padding-right: 0;
align-items: flex-start;
min-height: 0;
margin-bottom: 64px;
}
}
@media (prefers-reduced-motion: reduce) {
.sth-ctl,
.sth-cta,
.sth-cta-icon svg {
transition: none;
}
.sth[data-sth-ready] .sth-slide[data-sth-enter] .sth-media,
.sth[data-sth-ready] .sth-slide[data-sth-enter] .sth-name,
.sth[data-sth-ready] .sth-counter-current[data-sth-enter] {
animation: none;
}
}
@media (forced-colors: active) {
.sth-card,
.sth-partner,
.sth-cta {
border: 1px solid CanvasText;
}
.sth-line-fill {
background: Highlight;
}
}
</style>
<script>
/**
* ServiceTickerHero controller.
*
* One closure per instance, every lookup scoped to its root, every
* listener and observer on one AbortController, torn down before a
* client-side navigation swaps the document.
*
* Timing lives in CSS: the active slide's progress line runs a keyframe
* animation for `--sth-interval`, and its `animationend` advances the
* card. Pausing flips `animation-play-state`, so a paused line holds its
* place and resumes from there, and there is no timer to drift or leak.
*/
interface Instance {
destroy(): void;
}
function initServiceTickerHero(root: HTMLElement): Instance | undefined {
/* Idempotent: booting twice is a no-op, not a second set of listeners. */
if (root.dataset.sthReady === "") return undefined;
const card = root.querySelector<HTMLElement>("[data-sth-card]");
const slides = [...root.querySelectorAll<HTMLElement>("[data-sth-slide]")];
if (!card || slides.length === 0) return undefined;
root.dataset.sthReady = "";
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const wantsAutoplay = root.dataset.sthAutoplay === "1" && slides.length > 1;
const controls = root.querySelector<HTMLElement>("[data-sth-controls]");
const prev = root.querySelector<HTMLButtonElement>("[data-sth-prev]");
const next = root.querySelector<HTMLButtonElement>("[data-sth-next]");
const toggle = root.querySelector<HTMLButtonElement>("[data-sth-toggle]");
const current = root.querySelector<HTMLElement>("[data-sth-current]");
const live = root.querySelector<HTMLElement>("[data-sth-live]");
let index = 0;
let hovering = false;
let focused = false;
let inView = true;
let manuallyPaused = false;
const pad = (n: number) => String(n).padStart(2, "0");
const autoplayAllowed = () => wantsAutoplay && !reduced.matches;
function canRun(): boolean {
return autoplayAllowed() && !manuallyPaused && !document.hidden && !hovering && !focused && inView;
}
/* Restart a CSS animation on an element by removing its trigger
attribute, flushing style, and putting it back. */
function retrigger(el: Element | null, attr: string) {
if (!el) return;
el.removeAttribute(attr);
void (el as HTMLElement).offsetWidth;
el.setAttribute(attr, "");
}
function syncTicking() {
root.toggleAttribute("data-sth-ticking", autoplayAllowed());
root.toggleAttribute("data-sth-running", canRun());
}
function syncToggle() {
if (!toggle) return;
toggle.hidden = !autoplayAllowed();
toggle.toggleAttribute("data-paused", manuallyPaused);
toggle.setAttribute("aria-label", manuallyPaused ? "Play service rotation" : "Pause service rotation");
}
function show(target: number, announce: boolean) {
const n = slides.length;
const nextIndex = ((target % n) + n) % n;
const outgoing = slides[index];
const incoming = slides[nextIndex];
if (nextIndex !== index) {
outgoing.hidden = true;
outgoing.classList.remove("is-active");
outgoing.removeAttribute("data-sth-run");
outgoing.removeAttribute("data-sth-enter");
incoming.hidden = false;
incoming.classList.add("is-active");
retrigger(incoming, "data-sth-enter");
index = nextIndex;
if (current) {
current.textContent = pad(index + 1);
retrigger(current, "data-sth-enter");
}
}
/* A fresh line for the new slide, or a restarted one for the same. */
retrigger(incoming, "data-sth-run");
/* Only a person asking for a change hears about it. The automatic
advance stays silent, or a screen reader would be interrupted
every few seconds. */
if (announce && live) {
const name = incoming.querySelector(".sth-name")?.textContent?.trim() ?? "";
live.textContent = `${name}, ${index + 1} of ${n}`;
}
}
const refresh = () => {
syncTicking();
syncToggle();
};
/* ---- controls ---- */
if (controls) controls.hidden = false;
prev?.addEventListener("click", () => show(index - 1, true), { signal });
next?.addEventListener("click", () => show(index + 1, true), { signal });
toggle?.addEventListener(
"click",
() => {
manuallyPaused = !manuallyPaused;
refresh();
if (live) live.textContent = manuallyPaused ? "Rotation paused" : "Rotation playing";
},
{ signal },
);
card.addEventListener(
"keydown",
(event) => {
const target = event.target as HTMLElement;
if (!target.closest("[data-sth-controls]")) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
show(index - 1, true);
} else if (event.key === "ArrowRight") {
event.preventDefault();
show(index + 1, true);
}
},
{ signal },
);
/* ---- the tick ---- */
root.addEventListener(
"animationend",
(event) => {
if (event.animationName !== "sth-fill") return;
if (!(event.target as HTMLElement).matches("[data-sth-fill]")) return;
if (!canRun()) return;
show(index + 1, false);
},
{ signal },
);
/* ---- pause conditions ---- */
card.addEventListener("pointerenter", () => { hovering = true; refresh(); }, { signal });
card.addEventListener("pointerleave", () => { hovering = false; refresh(); }, { signal });
card.addEventListener("focusin", () => { focused = true; refresh(); }, { signal });
card.addEventListener(
"focusout",
(event) => {
if (!card.contains(event.relatedTarget as Node)) {
focused = false;
refresh();
}
},
{ signal },
);
document.addEventListener("visibilitychange", refresh, { signal });
reduced.addEventListener("change", refresh, { signal });
let observer: IntersectionObserver | undefined;
if ("IntersectionObserver" in window) {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) inView = entry.isIntersecting;
refresh();
},
{ threshold: 0.15 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer?.disconnect(), { once: true });
}
slides.forEach((slide, i) => {
slide.hidden = i !== 0;
slide.classList.toggle("is-active", i === 0);
});
slides[0].setAttribute("data-sth-run", "");
refresh();
function destroy() {
controller.abort();
slides.forEach((slide, i) => {
slide.hidden = i !== 0;
slide.classList.toggle("is-active", i === 0);
slide.removeAttribute("data-sth-run");
slide.removeAttribute("data-sth-enter");
});
if (current) {
current.textContent = "01";
current.removeAttribute("data-sth-enter");
}
if (controls) controls.hidden = true;
if (live) live.textContent = "";
root.removeAttribute("data-sth-ticking");
root.removeAttribute("data-sth-running");
delete root.dataset.sthReady;
}
/* Astro replaces the document on a client-side navigation: put the
served state back and detach everything. */
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
return { destroy };
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-sth-root]").forEach(initServiceTickerHero);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Fires on every view transition under <ClientRouter />; registered once
per module, so it cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ServiceTickerHero.astro =====
===== FILE (required): src/components/BorderedLogoGrid.astro =====
---
/**
* BorderedLogoGrid — an editorial proof wall.
*
* Client logos in a ruled wall: hairline-divided cells with no cards, no
* fills and no shadows, the label set as a real heading. In the default
* "staggered" layout the heading takes the wall's first cell and the logos
* run on after it, so the marks start one cell in; "grid" puts the heading
* above an unbroken wall.
*
* The wall is laid out for the number of logos it is given. The column count
* (and how many cells the heading spans) is chosen so rows come out full, and
* where no arrangement can avoid a short last row the remainder is closed
* with blank ruled cells rather than left ragged. Each mark is sized for
* equal visual weight from its aspect ratio (`width` at 40px tall), so a wide
* wordmark and a square mark read as the same size.
*
* No script. Type is inherited from the page; nothing is fetched unless a
* `fontStylesheet` is passed. Each cell is a link only when its logo has an
* `href`.
*/
interface BrandLogo {
/** The cell's caption, and its accessible name. */
name: string;
/** The mark. Unset renders a hatched 3:1 placeholder. */
src?: string;
/** The mark's width when drawn 40px tall — its aspect ratio, used for optical sizing and to reserve space. */
width?: number;
/** Empty by default: the caption already says who it is. */
alt?: string;
/** What the logo should be. Used by the placeholder only. */
hint?: string;
href?: string;
}
interface Props {
id?: string;
/** The heading over (or inside) the wall. Empty drops it. */
label?: string;
labelLevel?: "h2" | "h3" | "p";
logos?: BrandLogo[];
/** "staggered": the heading takes the wall's first cell. "grid": the heading sits above an unbroken wall. */
layout?: "staggered" | "grid";
/** A closing hairline under the section. */
divider?: boolean;
/** Optional stylesheet URL. None by default: the component inherits the page's type. */
fontStylesheet?: string;
}
const { id, label = "", labelLevel = "h2", logos: rawLogos = [], layout = "staggered", divider = true, fontStylesheet = "" } = Astro.props;
const Label = labelLevel;
/* A logo without a name has nothing to caption it: dropped. No logos, no section (a label alone is a claim). */
const logos = rawLogos.filter((logo) => logo?.name);
const n = logos.length;
const inWall = layout === "staggered" && Boolean(label);
/*
* Desktop arrangement. For each column count and heading span, the number of
* blank cells needed to close the last row; the least wins, then the column
* count nearest five, then the narrower heading. Deterministic from `n`.
*/
function arrange(count: number, heading: boolean) {
let best = { cols: Math.min(Math.max(count, 3), 5), span: heading ? 1 : 0, fill: 0, score: Infinity };
for (const cols of [3, 4, 5, 6]) {
for (const span of heading ? [1, 2, 3] : [0]) {
if (span >= cols) continue;
const cells = count + span;
if (cells < cols && cols > 3) continue; /* never a single ragged row wider than its content */
const fill = (cols - (cells % cols)) % cols;
const rows = Math.ceil(cells / cols);
const score = fill * 10 + Math.abs(cols - 5) + (rows > 3 ? 4 : 0) + (span === 3 ? 1 : 0) + (span >= 2 && cols < 5 ? 2 : 0);
if (score < best.score) best = { cols, span, fill, score };
}
}
return best;
}
const desk = arrange(n, inWall);
/* Tablet: three columns; the heading takes a cell or the whole first row, whichever closes the wall. */
const tabSpan = inWall ? ((3 - ((n + 1) % 3)) % 3 <= (3 - (n % 3)) % 3 ? 1 : 3) : 0;
const tabFill = (3 - ((n + (tabSpan === 1 ? 1 : 0)) % 3)) % 3;
/* Phone: two columns, the heading a full row. */
const phoneFill = n % 2;
const fillers = Math.max(desk.fill, tabFill, phoneFill);
/*
* Optical sizing: every mark gets about the area of a 3:1 wordmark drawn 37px
* tall (≈ 4100px²), so a wide wordmark and a square badge carry the same
* weight. Clamped so a tall mark never towers and a very wide one never
* shrinks below legibility.
*/
const markHeight = (width?: number) => {
if (!width || width <= 0) return undefined;
const ratio = width / 40;
return Math.round(Math.min(46, Math.max(22, Math.sqrt(4100 / ratio))));
};
const LOGO_SPEC = "3:1 · SVG";
const LOGO_HINT = "Client logo, single colour";
const style = `--blg-cols:${desk.cols};--blg-span:${desk.span};--blg-tab-span:${tabSpan}`;
---
{n > 0 && (
<section class="blg" id={id} data-layout={inWall ? "staggered" : "grid"} data-divider={divider ? "" : undefined} style={style}>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="blg-container">
{label && !inWall && <Label class="blg-label">{label}</Label>}
<ul class="blg-wall" role="list">
{inWall && (
<li class="blg-head">
<Label class="blg-label">{label}</Label>
</li>
)}
{logos.map((logo) => {
const h = markHeight(logo.width);
const inner = (
<Fragment>
<span class="blg-stage">
{logo.src ? (
<img
class="blg-mark"
src={logo.src}
alt={logo.alt ?? ""}
width={logo.width}
height="40"
style={h ? `--blg-h:${h}px` : undefined}
loading="lazy"
decoding="async"
/>
) : (
<span class="blg-ph" role="img" aria-label={`Image placeholder: ${logo.hint ?? LOGO_HINT}`}>
<span class="blg-ph-spec">{LOGO_SPEC}</span>
</span>
)}
</span>
<span class:list={["blg-name", logo.src && "blg-name--sr"]}>{logo.name}</span>
</Fragment>
);
return (
<li class="blg-item">
{logo.href ? (
<a class="blg-cell" href={logo.href}>
{inner}
</a>
) : (
<div class="blg-cell">{inner}</div>
)}
</li>
);
})}
{Array.from({ length: fillers }, (_, i) => (
<li
class="blg-fill"
aria-hidden="true"
data-d={i < desk.fill ? "" : undefined}
data-t={i < tabFill ? "" : undefined}
data-m={i < phoneFill ? "" : undefined}
/>
))}
</ul>
</div>
</section>
)}
<style>
.blg {
--blg-ink: var(--lx-ink, #16181d);
--blg-muted: var(--lx-muted, #5d636e);
--blg-rule: var(--lx-rule, rgb(22 24 29 / 0.14));
--blg-surface: var(--lx-surface-page, #ffffff);
--blg-accent: var(--lx-accent, #16181d);
container: blg / inline-size;
box-sizing: border-box;
display: flex;
justify-content: center;
padding: clamp(56px, 8vw, 112px) clamp(20px, 4vw, 40px);
background: var(--blg-surface);
color: var(--blg-ink);
font-family: inherit;
}
.blg *,
.blg *::before,
.blg *::after {
box-sizing: border-box;
}
.blg-container {
width: 100%;
max-width: 1200px;
}
/* The closing hairline is the wall's own bottom edge; without the divider the wall ends open. */
.blg:not([data-divider]) .blg-wall {
border-block-end: 0;
}
.blg-label {
margin: 0;
max-width: 24ch;
font-size: clamp(1.375rem, 1.1rem + 1.1vw, 1.875rem);
font-weight: 600;
line-height: 1.12;
letter-spacing: -0.022em;
text-wrap: balance;
color: var(--blg-ink);
}
/* Heading above the wall (grid layout): a short rule, then the heading. */
.blg[data-layout="grid"] .blg-container > .blg-label {
margin-bottom: clamp(28px, 4vw, 44px);
}
.blg[data-layout="grid"] .blg-container > .blg-label::before,
.blg-head .blg-label::before {
content: "";
display: block;
width: 28px;
height: 2px;
margin-bottom: 18px;
background: var(--blg-accent);
}
/*
* The wall. The hairlines are the wall's own colour showing through a 1px
* gap between opaque cells, so every internal rule is exactly one pixel
* and meets its neighbours cleanly at any column count. Only the top and
* bottom edges carry a border; the sides stay open.
*/
.blg-wall {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
margin: 0;
padding: 0;
list-style: none;
background: var(--blg-rule);
border-block: 1px solid var(--blg-rule);
}
.blg-wall > li {
min-width: 0;
background: var(--blg-surface);
}
.blg-head {
grid-column: 1 / -1;
display: flex;
align-items: flex-end;
padding: 28px 14px 26px;
}
.blg-item {
display: flex;
}
.blg-cell {
position: relative;
display: grid;
grid-template-rows: 1fr auto;
align-items: center;
width: 100%;
min-height: 132px;
padding: 18px 14px 14px;
color: inherit;
text-decoration: none;
}
.blg-stage {
display: grid;
place-items: center;
min-height: 64px;
}
.blg-mark {
display: block;
width: auto;
height: var(--blg-h, 40px);
max-width: min(78%, 180px);
object-fit: contain;
opacity: 0.86;
transition: opacity 180ms ease;
}
/* A 3:1 plate at the size real marks are drawn, carrying the spec only; the hint is in its accessible name. */
.blg-ph {
display: grid;
place-items: center;
width: min(120px, 78%);
aspect-ratio: 3 / 1;
border: 1px dashed rgb(22 24 29 / 0.28);
background: repeating-linear-gradient(135deg, rgb(22 24 29 / 0.045) 0 6px, transparent 6px 12px);
color: var(--blg-muted);
}
.blg-ph-spec {
font-size: 0.6875rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
/* A caption only where it carries information (a placeholder); a real mark's name stays in its accessible name. */
.blg-name--sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.blg-name {
justify-self: start;
max-width: 100%;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.3;
letter-spacing: 0.01em;
color: var(--blg-muted);
overflow-wrap: anywhere;
transition: color 180ms ease;
}
/* Blank cells that close a short last row; each shows only at the width it is needed for. */
.blg-fill {
display: none;
}
.blg-fill[data-m] {
display: block;
}
a.blg-cell:hover .blg-mark {
opacity: 1;
}
a.blg-cell:hover .blg-name {
color: var(--blg-ink);
}
a.blg-cell:focus-visible {
outline: 2px solid var(--blg-accent);
outline-offset: -4px;
}
a.blg-cell:focus-visible .blg-name {
color: var(--blg-ink);
}
@container blg (min-width: 560px) {
.blg-wall {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.blg-head {
grid-column: span var(--blg-tab-span);
padding: 26px 22px 22px 18px;
}
.blg-cell {
min-height: 148px;
padding: 22px 18px 16px;
}
.blg-fill[data-m] {
display: none;
}
.blg-fill[data-t] {
display: block;
}
}
@container blg (min-width: 960px) {
.blg-wall {
grid-template-columns: repeat(var(--blg-cols), minmax(0, 1fr));
}
.blg-head {
grid-column: span var(--blg-span);
padding: 30px 32px 28px 22px;
}
/* The heading grows with the cells it spans, so a wide heading cell is filled by type, not left empty. */
.blg-head .blg-label {
max-width: none;
font-size: clamp(1.5rem, calc(0.55rem + var(--blg-span) * 0.95cqw), 2.625rem);
line-height: 1.06;
letter-spacing: -0.028em;
}
.blg-cell {
min-height: 168px;
padding: 26px 22px 18px;
}
.blg-stage {
min-height: 88px;
}
.blg-fill[data-t] {
display: none;
}
.blg-fill[data-d] {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.blg-mark,
.blg-name {
transition: none;
}
}
@media (forced-colors: active) {
/* The hairlines are the wall's background showing through 1px gaps; keep that background, or the rules vanish. */
.blg-wall {
forced-color-adjust: none;
background: CanvasText;
}
.blg-wall > li {
background: Canvas;
}
a.blg-cell:focus-visible {
outline-color: Highlight;
}
}
</style>
===== END src/components/BorderedLogoGrid.astro =====
===== FILE (required): src/components/MissionStatAbout.astro =====
---
/**
* MissionStatAbout
*
* About section: an eyebrow pill on the left, a large display statement and
* an outline button on the right, then a row of three cards: a photo, a navy
* "mission" card with award badges, and an outlined "vision" card led by a
* stat that counts up once when it scrolls into view.
*
* The real figure is in the HTML. The script only rewinds it to zero once it
* knows it can animate it back, so without JavaScript, under reduced motion
* and to a screen reader the number is always the final one.
*/
/**
* A photo slot. With `src` it renders the <img>; without it, a labelled
* striped placeholder of the same box, describing the photo to find.
*/
interface ImageSlot {
src?: string;
/** Empty marks the image decorative. */
alt?: string;
/** What the photo should show. Used by the placeholder only. */
hint?: string;
}
interface Badge {
src?: string;
/** Badges carry information (the award's name), so give them real alt. */
alt: string;
/** Width at 40px tall. */
width?: number;
hint?: string;
}
interface Props {
id?: string;
eyebrow?: string;
statement?: string;
headingLevel?: "h2" | "h3";
/** Omit (the default) or pass an empty href to drop the button. */
button?: { label: string; href: string };
/** Omitted (the default): no photo card. Passed without `src`: a labelled placeholder. */
image?: ImageSlot;
/** Omitted (the default): no mission card. */
mission?: { title: string; text: string; badges?: Badge[] };
stat?: {
value: number;
prefix?: string;
suffix?: string;
label: string;
};
/** Omitted (the default): no vision copy. */
vision?: { title: string; text: string; image?: ImageSlot };
/** How long the count-up runs, in milliseconds. */
countDuration?: number;
/** Drops the top padding, for when the section above already closes with space (as in the reference). */
flushTop?: boolean;
fontStylesheet?: string;
}
/*
* The statement, button, photo, mission, stat and vision are the caller's:
* nothing about a business is filled in here. "About us" is a generic label
* and never renders the section on its own. The catalog demo passes its
* sample content through the registry's `props`.
*/
const {
id,
eyebrow = "About us",
statement = "",
headingLevel = "h2",
button,
image,
mission,
stat,
vision,
countDuration = 1600,
flushTop = false,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;
/* A figure only with a real number: a partial stat (a suffix, a label) renders nothing, never "undefined". */
const figure = stat && typeof stat.value === "number" && Number.isFinite(stat.value) ? stat : undefined;
const Heading = headingLevel;
const Sub = headingLevel === "h2" ? "h3" : "h4";
const statText = figure ? `${figure.prefix ?? ""}${figure.value}${figure.suffix ?? ""}` : "";
/* Constant per slot, from the reference measurements: what to pick a photo against. */
const PHOTO_SPEC = "~4:3 · 1200 × 900 px";
const VISION_SPEC = "~7:5 · 400 × 280 px, transparent PNG";
const decimals = figure ? (String(figure.value).split(".")[1] ?? "").length : 0;
const hasLead = Boolean(statement || button?.href);
const hasRow = Boolean(image || mission || figure || vision);
/* Nothing to say → nothing rendered: the generic eyebrow alone is not a section. */
const hasContent = hasLead || hasRow;
---
{hasContent && (
<section class="msa" id={id} data-msa-root data-flush-top={flushTop ? "" : undefined}>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="msa-container">
{(eyebrow || hasLead) && (
<div class="msa-top">
{eyebrow && <p class="msa-eyebrow">{eyebrow}</p>}
{hasLead && (
<div class="msa-lead">
{statement && <Heading class="msa-statement">{statement}</Heading>}
{
button?.href && (
<a class="msa-button" href={button.href}>
<span>{button.label}</span>
<span class="msa-button-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" focusable="false"><path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>
</a>
)
}
</div>
)}
</div>
)}
{hasRow && (
<div class="msa-row">
{
image && !image.src && (
<div class="msa-photo msa-ph" role="img" aria-label={`Image placeholder: ${image.hint ?? "About section photo"}`}>
<span class="msa-ph-text">
<span class="msa-ph-label">Image</span>
<span class="msa-ph-hint">{image.hint ?? "About section photo"}</span>
<span class="msa-ph-spec">{PHOTO_SPEC}</span>
</span>
</div>
)
}
{
image?.src && (
<div class="msa-photo">
<img src={image.src} alt={image.alt ?? ""} width="1200" height="900" loading="lazy" decoding="async" />
</div>
)
}
{
mission && (
<div class="msa-mission">
<div class="msa-copy">
<Sub class="msa-card-title">{mission.title}</Sub>
<p class="msa-card-text">{mission.text}</p>
</div>
{mission.badges && mission.badges.length > 0 && (
<ul class="msa-badges" role="list">
{mission.badges.map((badge) => (
<li>
{badge.src ? (
<img src={badge.src} alt={badge.alt} height="40" width={badge.width} loading="lazy" decoding="async" />
) : (
/* Too small to label: stripes only. */
<span
class="msa-ph msa-ph--dark msa-badge-ph"
role="img"
aria-label={`Image placeholder: ${badge.hint ?? "Award badge"} (${badge.alt})`}
style={`width: ${badge.width ?? 60}px;`}
/>
)}
</li>
))}
</ul>
)}
</div>
)
}
{
(figure || vision) && (
<div class="msa-vision">
{figure && (
<div class="msa-stat">
<p class="msa-stat-value">
<span class="msa-sr-only">{statText}</span>
<span
aria-hidden="true"
data-msa-count
data-value={figure.value}
data-decimals={decimals}
data-prefix={figure.prefix ?? ""}
data-suffix={figure.suffix ?? ""}
data-duration={countDuration}
>
{statText}
</span>
</p>
<p class="msa-stat-label">{figure.label}</p>
</div>
)}
{vision && (
<div class="msa-copy msa-vision-copy">
<Sub class="msa-card-title">{vision.title}</Sub>
<p class="msa-card-text">{vision.text}</p>
</div>
)}
{vision?.image?.src && (
<img class="msa-vision-img" src={vision.image.src} alt={vision.image.alt ?? ""} width="400" height="280" loading="lazy" decoding="async" />
)}
{vision?.image && !vision.image.src && (
<div class="msa-vision-img msa-ph msa-vision-ph" role="img" aria-label={`Image placeholder: ${vision.image.hint ?? "Vision card picture"}`}>
<span class="msa-ph-text">
<span class="msa-ph-label">Image</span>
<span class="msa-ph-hint">{vision.image.hint ?? "Vision card picture"}</span>
<span class="msa-ph-spec">{VISION_SPEC}</span>
</span>
</div>
)}
</div>
)
}
</div>
)}
</div>
</section>
)}
<style>
.msa {
--msa-navy: var(--lx-navy, #0a2540);
--msa-accent: var(--lx-accent, #ff5f00);
--msa-surface: var(--lx-surface, #f6f6f6);
--msa-ink-soft: var(--lx-ink-soft, #555555);
--msa-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--msa-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--msa-hairline: rgba(10, 37, 64, 0.14);
display: flex;
justify-content: center;
box-sizing: border-box;
padding: 60px 20px;
background: #fff;
color: var(--msa-navy);
font-family: var(--msa-body);
}
.msa[data-flush-top] {
padding-top: 0;
}
.msa *,
.msa *::before,
.msa *::after {
box-sizing: border-box;
}
.msa-container {
display: flex;
flex-direction: column;
gap: 40px;
width: 100%;
max-width: 1200px;
}
/* ---------- top ---------- */
.msa-top {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.msa-eyebrow {
flex: none;
margin: 0;
padding: 7px 15px;
border: 1px solid var(--msa-hairline);
border-radius: 100px;
font: 500 14px/14px var(--msa-body);
text-transform: uppercase;
color: var(--msa-navy);
}
.msa-lead {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
min-width: 0;
}
.msa-statement {
margin: 0;
font: 400 30px/30px var(--msa-display);
letter-spacing: -0.02em;
text-transform: uppercase;
color: var(--msa-navy);
text-wrap: pretty;
}
.msa-button {
display: inline-flex;
align-items: center;
gap: 24px;
min-height: 50px;
padding: 4px 4px 4px 18px;
border: 1px solid var(--msa-hairline);
border-radius: 100px;
background: #fff;
color: var(--msa-navy);
font: 600 14px/14px var(--msa-body);
text-decoration: none;
transition:
background-color 0.25s ease,
border-color 0.25s ease;
}
.msa-button-icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--msa-navy);
color: #fff;
}
.msa-button-icon svg {
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.msa-button:hover {
border-color: rgba(10, 37, 64, 0.3);
background: var(--msa-surface);
}
.msa-button:hover .msa-button-icon svg {
transform: translateX(3px);
}
.msa-button:focus-visible {
outline: 2px solid var(--msa-accent);
outline-offset: 3px;
}
/* ---------- cards ---------- */
.msa-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
}
.msa-photo {
overflow: hidden;
border-radius: 10px;
aspect-ratio: 335 / 191;
background: var(--msa-surface);
}
.msa-photo img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.msa-mission {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 40px;
padding: 20px;
border-radius: 10px;
background: var(--msa-navy);
color: #fff;
}
.msa-copy {
display: flex;
flex-direction: column;
gap: 12px;
}
.msa-card-title {
margin: 0;
font: 400 20px/20px var(--msa-display);
letter-spacing: -0.02em;
text-transform: uppercase;
color: inherit;
}
.msa-card-text {
margin: 0;
font: 400 16px/25.6px var(--msa-body);
color: var(--msa-ink-soft);
}
.msa-mission .msa-card-text {
color: rgba(255, 255, 255, 0.8);
}
.msa-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.msa-badges img {
display: block;
width: auto;
height: 40px;
}
.msa-vision {
position: relative;
display: flex;
flex-direction: column;
gap: 24px;
overflow: hidden;
padding: 10px 10px 90px;
border: 1px solid var(--msa-hairline);
border-radius: 10px;
background: #fff;
color: var(--msa-navy);
}
.msa-stat {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border-radius: 10px;
background: var(--msa-surface);
}
.msa-stat-value {
margin: 0;
font: 400 34px/34px var(--msa-display);
letter-spacing: -0.02em;
color: var(--msa-navy);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.msa-stat-label {
max-width: 110px;
margin: 0;
font: 500 14px/14px var(--msa-body);
text-transform: uppercase;
color: var(--msa-ink-soft);
}
.msa-vision-copy {
position: relative;
z-index: 1;
padding: 0 10px;
}
/* Hangs off the card's right edge and is clipped by it, as in the
reference. */
.msa-vision-img {
position: absolute;
right: -8px;
bottom: -2px;
z-index: 0;
width: 140px;
height: auto;
}
/* ---------- image placeholders ---------- */
.msa-ph {
display: grid;
place-items: center;
padding: 16px;
container-type: size;
border: 1px dashed rgba(10, 37, 64, 0.22);
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgba(10, 37, 64, 0.62);
font-family: var(--msa-body);
text-align: center;
}
.msa-ph--dark {
border-color: rgba(255, 255, 255, 0.25);
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0 12px, rgba(255, 255, 255, 0.1) 12px 24px);
color: rgba(255, 255, 255, 0.72);
}
.msa-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 28ch;
}
.msa-ph-label {
font: 500 11px/1 var(--msa-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.msa-ph-hint {
font: 500 13px/1.35 var(--msa-body);
}
.msa-ph-spec {
font: 400 12px/1.2 var(--msa-body);
}
@container (max-height: 100px) {
.msa-ph-label {
display: none;
}
}
@container (max-height: 80px) {
.msa-ph-spec {
display: none;
}
}
.msa-badge-ph {
display: block;
height: 40px;
padding: 0;
border-radius: 4px;
}
/* The picture box hangs off the card's corner, so the placeholder's text
is nudged into the part that stays visible. */
.msa-vision-ph {
bottom: -12px;
aspect-ratio: 400 / 280;
padding: 6px 14px 16px 6px;
border-radius: 6px 0 0 0;
}
.msa-sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------- tablet ---------- */
@media (min-width: 810px) {
.msa {
padding: 90px 30px;
}
.msa-container {
gap: 60px;
}
.msa-statement {
font-size: 40px;
line-height: 40px;
}
.msa-lead {
gap: 40px;
}
.msa-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.msa-photo {
grid-column: 1 / -1;
aspect-ratio: 16 / 7;
}
.msa-mission,
.msa-vision {
min-height: 340px;
}
.msa-mission {
padding: 30px;
}
.msa-vision {
gap: 30px;
padding: 10px 10px 125px;
}
.msa-card-title {
font-size: 32px;
line-height: 32px;
}
.msa-stat {
padding: 16px 20px;
}
.msa-stat-value {
font-size: 48px;
line-height: 48px;
}
.msa-vision-copy {
padding: 0 20px;
}
.msa-vision-img {
right: -12px;
bottom: -4px;
width: 197px;
}
/* Same box as the reference picture: 20px of it below the card. */
.msa-vision-ph {
bottom: -20px;
padding: 8px 20px 28px 8px;
}
}
/* ---------- desktop ---------- */
@media (min-width: 1200px) {
.msa {
padding: 120px 30px;
}
.msa-top {
flex-direction: row;
justify-content: space-between;
gap: 40px;
}
.msa-lead {
flex: 0 1 690px;
gap: 60px;
}
.msa-statement {
font-size: 48px;
line-height: 48px;
}
/* 460 / 350 / 350 at 1200. */
.msa-row {
grid-template-columns: minmax(0, 46fr) minmax(0, 35fr) minmax(0, 35fr);
height: 340px;
}
.msa-photo {
grid-column: auto;
aspect-ratio: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.msa-button,
.msa-button-icon svg {
transition: none;
}
}
@media (forced-colors: active) {
.msa-mission,
.msa-stat,
.msa-button {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* MissionStatAbout controller: the count-up, and nothing else.
*
* One closure per instance, lookups scoped to its root, the observer and
* listeners on one AbortController, torn down before a client-side
* navigation swaps the document. The served markup already holds the final
* number; this rewinds it to zero only when it can play it forward, and
* puts the final number back if it is torn down mid-count.
*/
interface Instance {
destroy(): void;
}
function initMissionStatAbout(root: HTMLElement): Instance | undefined {
/* Idempotent: booting twice is a no-op. */
if (root.dataset.msaReady === "") return undefined;
const counter = root.querySelector<HTMLElement>("[data-msa-count]");
if (!counter) return undefined;
const value = Number(counter.dataset.value);
const decimals = Number(counter.dataset.decimals) || 0;
const prefix = counter.dataset.prefix ?? "";
const suffix = counter.dataset.suffix ?? "";
const duration = Math.max(200, Number(counter.dataset.duration) || 1600);
const finalText = counter.textContent?.trim() ?? "";
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Nothing to animate, or not allowed to: the served number stands. */
if (!Number.isFinite(value) || reduced || !("IntersectionObserver" in window)) return undefined;
root.dataset.msaReady = "";
const controller = new AbortController();
const { signal } = controller;
let frame = 0;
const format = (n: number) => `${prefix}${n.toFixed(decimals)}${suffix}`;
counter.textContent = format(0);
function run() {
const start = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
counter!.textContent = t < 1 ? format(value * eased) : finalText;
if (t < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
run();
}
},
{ threshold: 0.5 },
);
observer.observe(counter);
function destroy() {
observer.disconnect();
cancelAnimationFrame(frame);
counter!.textContent = finalText;
controller.abort();
delete root.dataset.msaReady;
}
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
return { destroy };
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-msa-root]").forEach(initMissionStatAbout);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/MissionStatAbout.astro =====
===== FILE (required): src/components/StackedServiceCards.astro =====
---
/**
* Stacked service cards
*
* A navy services band. On the left, a sticky image card with a huge display
* heading and a pill link. On the right, one card per service, each sticky at
* the same offset so they pile on top of one another as the page scrolls; the
* cards already covered shrink slightly as the next one slides over them.
*
* No script. The pile is `position: sticky`. The shrink is a CSS
* scroll-driven animation: each card's scale runs on a named view timeline
* belonging to the card after it, so it tracks that card's approach. Browsers
* without scroll-driven animations keep the pile and skip the shrink; reduced
* motion skips it too. Below 1024px the section becomes a plain column with no
* sticking, which is what the phone reference does.
*
* The sticky card's picture is a prop. Unset, it renders a labelled striped
* placeholder saying what photo belongs there and at what size.
*
* Self-contained: no imports, no packages, no global stylesheet. Colours and
* fonts read the page's `--lx-*` tokens with fallbacks, so it also works alone.
*/
export interface ImageSlot {
/** Image URL. Unset renders the labelled placeholder. */
src?: string;
/** Alt text. Decorative ("") by default. */
alt?: string;
/** What the photo should show. Printed on the placeholder. */
hint?: string;
}
export interface ServiceCard {
/** Card heading. */
title: string;
/** One or two sentences under it. */
description: string;
/** The check list. Two or three short lines reads best. */
features?: string[];
/** Where "View details" goes. Omit to drop the link. */
href?: string;
/** Card surface. Defaults alternate light, accent, light, accent. */
tone?: "light" | "accent";
}
interface Props {
/** Root id. */
id?: string;
/** The display heading in the sticky card. */
heading?: string;
/** Heading level for the section heading; cards use the next level down. */
headingLevel?: "h2" | "h3";
/**
* Background of the sticky card, under a 60% black overlay. `null` gives a
* plain navy-tint card with no picture and no placeholder.
*/
image?: ImageSlot | null;
/** Pill link under the heading. `""` label drops it. */
ctaLabel?: string;
ctaHref?: string;
/** The small uppercase label above each check list. */
featuresLabel?: string;
/** Text of each card's link. The card title is appended for screen readers. */
detailLabel?: string;
/** The cards, in order. */
services?: ServiceCard[];
/** Pile the cards with sticky positioning (desktop only). */
stack?: boolean;
/** Shrink covered cards while scrolling. Needs `stack`. */
shrink?: boolean;
/** Webfont stylesheet. `""` if the page already loads Bebas Neue + Poppins. */
fontStylesheet?: string;
}
const {
id,
heading = "Our services",
headingLevel = "h2",
image = {},
ctaLabel = "View all services",
ctaHref = "",
featuresLabel = "What's included",
detailLabel = "View details",
services = [],
stack = true,
shrink = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;
const Heading = headingLevel;
/* The slot's shape at desktop: 588 x 702 in the reference. */
const IMAGE_SPEC = "~5:6 · 1200 × 1440 px";
/* No hint by default: the placeholder says "Image" and the size, nothing about
a particular business. Pass `image.hint` to describe the photo wanted. */
const imageHint = image?.hint ?? "";
const CardHeading = headingLevel === "h2" ? "h3" : "h4";
/* A card needs a title and a description; one missing either is dropped rather
than drawn half-empty. No cards, no section: the heading alone never shows. */
const list = services
.filter((service) => service?.title && service?.description)
.map((service) => ({ ...service, features: service.features?.filter(Boolean) }));
/*
* Each card owns a named view timeline; the card before it animates on that
* timeline. The names only have to be unique inside one list, because
* `timeline-scope` on the list stops the lookup there — two instances on a
* page never see each other's timelines.
*/
const names = list.map((_, i) => `--ssc-card-${i + 1}`);
const listStyle = names.length > 1 ? `timeline-scope: ${names.join(", ")};` : undefined;
const pad = (n: number) => String(n).padStart(2, "0");
---
{list.length > 0 && (
<section
class="ssc"
id={id}
data-ssc-root
data-stack={stack ? "" : undefined}
data-shrink={stack && shrink ? "" : undefined}
>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="ssc__inner">
<div class:list={["ssc__intro", { "ssc__intro--photo": Boolean(image?.src) }]}>
{image?.src && <img class="ssc__intro-img" src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" />}
{
image && !image.src && (
<div class="ssc-ph" role="img" aria-label={imageHint ? `Image placeholder: ${imageHint}` : "Image placeholder"}>
<span class="ssc-ph__text">
<span class="ssc-ph__label">Image</span>
{imageHint && <span class="ssc-ph__hint">{imageHint}</span>}
<span class="ssc-ph__spec">{IMAGE_SPEC}</span>
</span>
</div>
)
}
<div class="ssc__intro-text">
<Heading class="ssc__heading">{heading}</Heading>
{
ctaLabel && ctaHref && (
<a class="ssc__pill" href={ctaHref}>
<span>{ctaLabel}</span>
<span class="ssc__pill-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</span>
</a>
)
}
</div>
</div>
<ol class="ssc__list" style={listStyle}>
{
list.map((service, i) => {
const tone = service.tone ?? (i % 2 === 0 ? "light" : "accent");
const next = names[i + 1];
return (
<li
class:list={["ssc__item", { "ssc__item--covered": Boolean(next) }]}
style={`--ssc-self: ${names[i]};${next ? ` --ssc-next: ${next};` : ""}`}
>
<article class:list={["ssc__card", `ssc__card--${tone}`]}>
<div class="ssc__body">
<p class="ssc__num" aria-hidden="true">{pad(i + 1)}</p>
<div class="ssc__content">
<div class="ssc__text">
<CardHeading class="ssc__title">{service.title}</CardHeading>
<p class="ssc__desc">{service.description}</p>
</div>
{service.features && service.features.length > 0 && (
<div class="ssc__features">
{featuresLabel && <p class="ssc__label">{featuresLabel}</p>}
<ul class="ssc__checks">
{service.features.map((feature) => (
<li>
<svg class="ssc__check" viewBox="0 0 20 20" width="19" height="19" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<rect x="2.5" y="2.5" width="15" height="15" rx="3" />
<path d="M6.5 10.2l2.4 2.4 4.8-5" />
</svg>
<span>{feature}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
{service.href && (
<a class="ssc__link" href={service.href}>
<span>
{detailLabel}
<span class="ssc__sr">: {service.title}</span>
</span>
<span class="ssc__link-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</span>
</a>
)}
</article>
</li>
);
})
}
</ol>
</div>
</section>
)}
<style>
.ssc {
--ssc-navy: var(--lx-navy, #0a2540);
--ssc-accent: var(--lx-accent, #ff5f00);
--ssc-ink-soft: var(--lx-ink-soft, #555555);
--ssc-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--ssc-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--ssc-top: 50px;
box-sizing: border-box;
background: var(--ssc-navy);
padding: 60px 20px;
font-family: var(--ssc-body);
overflow-x: clip;
}
.ssc *,
.ssc *::before,
.ssc *::after {
box-sizing: border-box;
}
.ssc__inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 32px;
}
/* ---- sticky intro card ---- */
.ssc__intro {
position: relative;
isolation: isolate;
overflow: hidden;
border-radius: 10px;
padding: 24px;
background: #133a5e;
}
.ssc__intro-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: -2;
}
/*
* The measured overlay: black at 60%, over a real photo only. The
* placeholder is already a dark stripe, and dimming it further would make
* its label unreadable.
*/
.ssc__intro--photo::before {
content: "";
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: -1;
}
/* ---- image placeholder (dark context) ---- */
.ssc-ph {
position: absolute;
inset: 0;
z-index: -2;
display: grid;
/* The heading and pill sit at the top; the label sits in the empty
lower part of the card so the two never overlap. */
align-items: end;
justify-items: center;
padding: 16px;
border: 1px dashed rgba(255, 255, 255, 0.25);
border-radius: inherit;
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0 12px, rgba(255, 255, 255, 0.1) 12px 24px);
color: rgba(255, 255, 255, 0.72);
text-align: center;
font-family: var(--ssc-body);
}
/* On phones and tablets the card is a short strip filled by the heading,
so the placeholder keeps its stripes and drops its words. */
.ssc-ph__text {
display: none;
flex-direction: column;
align-items: center;
gap: 8px;
max-width: 28ch;
padding-bottom: 64px;
}
.ssc-ph__label {
font: 500 11px/1 var(--ssc-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ssc-ph__hint {
font: 500 14px/1.4 var(--ssc-body);
}
.ssc-ph__spec {
font: 400 12px/1.2 var(--ssc-body);
}
.ssc__intro-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.ssc__heading {
margin: 0;
color: #fff;
font-family: var(--ssc-display);
font-weight: 400;
font-size: 40px;
line-height: 0.8;
letter-spacing: -0.02em;
text-transform: uppercase;
}
/* ---- pills ---- */
.ssc__pill,
.ssc__link {
display: inline-flex;
align-items: center;
gap: 16px;
min-height: 50px;
padding: 5px 5px 5px 19px;
border-radius: 100px;
font: 600 14px/1 var(--ssc-body);
text-decoration: none;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.ssc__pill {
background: var(--ssc-accent);
color: #fff;
}
.ssc__pill-icon,
.ssc__link-icon {
display: grid;
place-items: center;
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
transition: transform 0.25s ease;
}
.ssc__pill-icon {
background: #fff;
color: var(--ssc-accent);
}
.ssc__pill:hover .ssc__pill-icon,
.ssc__link:hover .ssc__link-icon {
transform: translateX(3px);
}
.ssc__pill:focus-visible,
.ssc__link:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
.ssc__card--light .ssc__link:focus-visible {
outline-color: var(--ssc-navy);
}
/* ---- cards ---- */
.ssc__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 32px;
}
.ssc__card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
padding: 24px;
border-radius: 10px;
transform-origin: 50% 50%;
}
.ssc__card--light {
background: #fff;
--ssc-c-num: rgba(10, 37, 64, 0.3);
--ssc-c-title: var(--ssc-navy);
--ssc-c-text: var(--ssc-ink-soft);
--ssc-c-strong: var(--ssc-navy);
}
.ssc__card--accent {
background: var(--ssc-accent);
--ssc-c-num: rgba(255, 255, 255, 0.6);
--ssc-c-title: #fff;
--ssc-c-text: rgba(255, 255, 255, 0.85);
--ssc-c-strong: #fff;
}
.ssc__body {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.ssc__num {
margin: 0;
color: var(--ssc-c-num);
font: 400 48px/0.8 var(--ssc-display);
letter-spacing: -0.02em;
}
.ssc__content {
display: flex;
flex-direction: column;
gap: 24px;
}
.ssc__text {
display: flex;
flex-direction: column;
gap: 10px;
}
.ssc__title {
margin: 0;
color: var(--ssc-c-title);
font: 400 32px/1 var(--ssc-display);
letter-spacing: -0.02em;
text-transform: uppercase;
}
.ssc__desc,
.ssc__checks {
margin: 0;
color: var(--ssc-c-text);
font: 400 16px/1.6 var(--ssc-body);
}
.ssc__features {
display: flex;
flex-direction: column;
gap: 20px;
}
.ssc__label {
margin: 0;
color: var(--ssc-c-strong);
font: 500 14px/1 var(--ssc-body);
text-transform: uppercase;
}
.ssc__checks {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.ssc__checks li {
display: flex;
align-items: flex-start;
gap: 6px;
}
.ssc__check {
flex: none;
margin-top: 3px;
color: var(--ssc-c-strong);
}
.ssc__link {
border: 1px solid;
}
.ssc__card--light .ssc__link {
background: #fff;
border-color: rgba(10, 37, 64, 0.14);
color: var(--ssc-navy);
}
.ssc__card--light .ssc__link-icon {
background: var(--ssc-navy);
color: #fff;
}
.ssc__card--accent .ssc__link {
background: transparent;
border-color: rgba(255, 255, 255, 0.45);
color: #fff;
}
.ssc__card--accent .ssc__link-icon {
background: #fff;
color: var(--ssc-accent);
}
.ssc__card--light .ssc__link:hover {
border-color: var(--ssc-navy);
}
.ssc__card--accent .ssc__link:hover {
background: rgba(255, 255, 255, 0.12);
}
.ssc__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- tablet: same column, more room ---- */
@media (min-width: 810px) {
.ssc {
padding: 100px 30px;
}
.ssc__intro {
padding: 48px;
}
.ssc__heading {
font-size: 72px;
}
.ssc__card {
padding: 48px;
gap: 40px;
}
.ssc__num {
font-size: 96px;
}
.ssc__title {
font-size: 40px;
}
}
/* ---- desktop: two columns, sticky pile ---- */
@media (min-width: 1024px) {
.ssc {
padding: 120px 30px;
}
.ssc__inner {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: 24px;
}
.ssc__intro {
padding: clamp(48px, 5.5vw, 80px) clamp(48px, 5.5vw, 80px) 320px;
}
.ssc__intro-text {
gap: 60px;
}
.ssc-ph__text {
display: flex;
}
.ssc__heading {
font-size: clamp(64px, 8.4vw, 120px);
}
.ssc__list {
gap: 40px;
}
.ssc__card {
padding: clamp(48px, 5.5vw, 80px);
gap: 60px;
}
.ssc__body {
gap: 24px;
}
.ssc__content {
gap: 40px;
}
.ssc__features {
gap: 24px;
}
.ssc__num {
font-size: 120px;
}
.ssc__title {
font-size: 48px;
}
[data-stack] .ssc__intro,
[data-stack] .ssc__item {
position: sticky;
top: var(--ssc-top);
}
}
/*
* The shrink. Card N scales on the view timeline of card N+1: it starts as
* N+1's top edge enters the viewport and finishes 400px later, well before
* N+1 reaches its sticky offset, so a covered card is always at rest. Where
* the browser lacks scroll-driven animations or `timeline-scope`, the cards
* still pile — they just don't shrink. Reduced motion never shrinks.
*/
@supports (animation-timeline: view()) and (timeline-scope: --ssc-a) {
@media (min-width: 1024px) and (prefers-reduced-motion: no-preference) {
[data-stack] .ssc__item {
view-timeline-name: var(--ssc-self);
}
[data-shrink] .ssc__item--covered .ssc__card {
animation: ssc-shrink linear both;
animation-timeline: var(--ssc-next);
animation-range: entry 0% entry 400px;
}
}
}
@keyframes ssc-shrink {
from {
transform: scale(1);
}
to {
transform: scale(0.966);
}
}
@media (prefers-reduced-motion: reduce) {
.ssc__pill,
.ssc__link,
.ssc__pill-icon,
.ssc__link-icon {
transition: none;
}
.ssc__pill:hover .ssc__pill-icon,
.ssc__link:hover .ssc__link-icon {
transform: none;
}
}
</style>
===== END src/components/StackedServiceCards.astro =====
===== FILE (required): src/components/ImageBentoFeatures.astro =====
---
/**
* Image bento features
*
* An eyebrow pill and a centred display heading over a five-tile bento: a
* wide navy text tile, a wide orange text tile, two square photo tiles and a
* tall photo tile with its caption set over a dark fade. The arrangement is
* the measured one — a 2×2 of wide/square tiles on the left, the tall tile
* spanning both rows on the right — and it restacks to one column on phones.
*
* Static: no script. Tiles are content, not controls, so nothing in the
* section is focusable unless the caller puts a link in it.
*
* Theming reads `--lx-*` custom properties with fallbacks, so a page can
* re-theme every logistics section from one ancestor and this one still
* works on its own.
*/
/**
* A photo slot. With `src` it is an `<img>`; without, a labelled striped
* placeholder that says what the photo should show and what size to get.
*/
export interface BentoImage {
src?: string;
/** Alt text for the photo. "" (the default) marks it decorative. */
alt?: string;
/** What the photo should show: the brief on the placeholder. */
hint?: string;
}
export interface BentoTile {
/**
* `navy` and `accent` are text tiles on a solid fill. `image` is a photo
* tile; give it a title and it gets the dark fade and a caption, leave the
* title out and it is the picture alone.
*/
tone: "navy" | "accent" | "image";
title?: string;
text?: string;
/** The photo for an `image` tile. Ignored on text tiles. */
image?: BentoImage;
/**
* The footer row on a navy tile: a stack of faces, a small accent figure
* and a label, behind a hairline. Only drawn on `navy` tiles.
*/
stat?: { value: string; label: string; avatars?: BentoImage[] };
/**
* The faint globe on a navy tile, or the map-and-pin pattern on an accent
* tile. On by default; purely decorative either way.
*/
pattern?: boolean;
}
interface Props {
/** Small uppercase label in the pill. `""` hides the pill. */
eyebrow?: string;
heading?: string;
/** The tiles take the next level down. */
headingLevel?: "h2" | "h3";
/**
* Laid out in reading order for a phone: the first five take the measured
* desktop positions (wide navy, square, wide accent, square, tall).
* A sixth onward flows into extra rows below.
*/
tiles?: BentoTile[];
/** `""` when the page already loads Bebas Neue and Poppins. */
fontStylesheet?: string;
id?: string;
}
/* No built-in tiles, figures or photo briefs: every tile comes from the caller. */
const {
eyebrow = "Why choose us",
heading = "",
headingLevel = "h2",
tiles = [] as BentoTile[],
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
const Heading = headingLevel;
const TileHeading = headingLevel === "h2" ? "h3" : "h4";
const uid = id ?? `ibf-${Math.random().toString(36).slice(2, 9)}`;
/*
* What to shoot for each slot, from the measured desktop boxes: the wide
* slots are 527x260, the squares 260x260 (cropped to 335x287 on phones), the
* tall one 393x529. Sizes are roughly 2-3x the largest rendered box.
*/
const specFor = (index: number) =>
index === 4
? "3:4 · 1200 × 1600 px"
: index === 0 || index === 2
? "2:1 · 1600 × 800 px"
: "~1:1 · 1000 × 1000 px";
---
{tiles.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{tiles.length > 0 && (
<section class="ibf" id={id} aria-labelledby={heading ? `${uid}-heading` : undefined}>
<div class="ibf-inner">
{(eyebrow || heading) && (
<div class="ibf-head">
{eyebrow && <p class="ibf-eyebrow">{eyebrow}</p>}
{heading && <Heading class="ibf-heading" id={`${uid}-heading`}>{heading}</Heading>}
</div>
)}
<ul class="ibf-grid" role="list">
{tiles.map((tile, index) => {
const isImage = tile.tone === "image";
const hasCopy = Boolean(tile.title || tile.text);
const pattern = tile.pattern !== false;
return (
<li
class:list={[
"ibf-tile",
`ibf-tile--${tile.tone}`,
isImage && hasCopy && "ibf-tile--captioned",
]}
>
{isImage && tile.image?.src && (
<img class="ibf-photo" src={tile.image.src} alt={tile.image.alt ?? ""} loading="lazy" decoding="async" />
)}
{isImage && !tile.image?.src && (
<div
class:list={["ibf-ph", hasCopy && "ibf-ph--dark"]}
role="img"
aria-label={`Image placeholder: ${tile.image?.hint ?? "photo"}`}
>
<span class="ibf-ph-text">
<span class="ibf-ph-label">Image</span>
{tile.image?.hint && <span class="ibf-ph-hint">{tile.image.hint}</span>}
<span class="ibf-ph-spec">{specFor(index)}</span>
</span>
</div>
)}
{isImage && hasCopy && <span class="ibf-fade" aria-hidden="true" />}
{/* Faint globe, measured at 7% opacity behind the navy tile's copy. */}
{tile.tone === "navy" && pattern && (
<svg class="ibf-globe" viewBox="0 0 110 118" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor" stroke-width="2">
<circle cx="55" cy="52" r="48" />
<ellipse cx="55" cy="52" rx="22" ry="48" />
<path d="M7 52h96M14 28h82M14 76h82" />
<path d="M40 104l-8 12 22-8" />
</g>
</svg>
)}
{/* Street-map lines and a location pin, full bleed behind the copy. */}
{tile.tone === "accent" && pattern && (
<svg class="ibf-map" viewBox="0 0 440 260" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M-10 40 L120 90 L180 -10 M60 270 L120 90 L300 150 L440 60 M300 150 L260 270 M200 120 L230 -10 M-10 180 L160 200 L200 270 M160 200 L220 130 M300 150 L450 210 M340 -10 L380 110 L450 130" />
<path d="M0 120 L90 140 M380 110 L330 270 M250 40 L380 20" />
</g>
<g fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M370 150 C340 118 322 96 322 74 a48 48 0 0 1 96 0 C418 96 400 118 370 150 Z" />
<path d="M348 66 l22 -12 22 12 v24 l-22 12 -22 -12 Z M348 66 l22 12 22 -12 M370 78 v24" />
</g>
</svg>
)}
{hasCopy && (
<div class="ibf-copy">
{tile.title && <TileHeading class="ibf-title">{tile.title}</TileHeading>}
{tile.text && <p class="ibf-text">{tile.text}</p>}
</div>
)}
{tile.tone === "navy" && tile.stat && (
<div class="ibf-stat">
{tile.stat.avatars && tile.stat.avatars.length > 0 && (
<span class="ibf-faces">
{tile.stat.avatars.map((face) =>
face.src ? (
<img class="ibf-face" src={face.src} alt={face.alt ?? ""} width="40" height="40" loading="lazy" decoding="async" />
) : (
<span
class="ibf-face ibf-face--ph"
role="img"
aria-label={`Image placeholder: ${face.hint ?? "portrait"} (1:1, 160 × 160 px)`}
/>
),
)}
</span>
)}
<span class="ibf-stat-copy">
<span class="ibf-stat-value">{tile.stat.value}</span>
<span class="ibf-stat-label">{tile.stat.label}</span>
</span>
</div>
)}
</li>
);
})}
</ul>
</div>
</section>
)}
<style>
.ibf {
container-type: inline-size;
box-sizing: border-box;
background: #ffffff;
color: var(--lx-navy, #0a2540);
font-family: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
}
.ibf *,
.ibf *::before,
.ibf *::after {
box-sizing: border-box;
}
/* Phone first: 60px block, 20px inline, 40px between head and grid. */
.ibf-inner {
max-width: calc(1200px + 60px);
margin-inline: auto;
padding: 60px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
}
.ibf-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
text-align: center;
}
.ibf-eyebrow {
margin: 0;
padding: 8px 16px;
border-radius: 100px;
/* Inset, so the measured 30px pill height is not grown by a border. */
box-shadow: inset 0 0 0 1px var(--lx-hairline, rgba(10, 37, 64, 0.14));
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: var(--lx-navy, #0a2540);
}
.ibf-heading {
margin: 0;
max-width: 26rem;
font-family: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
font-size: 30px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.02em;
color: var(--lx-navy, #0a2540);
text-wrap: balance;
}
/* ---- the grid -------------------------------------------------------- */
.ibf-grid {
width: 100%;
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.ibf-tile {
position: relative;
isolation: isolate;
overflow: hidden;
border-radius: 10px;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-width: 0;
color: #ffffff;
}
.ibf-tile--navy {
background: var(--lx-navy, #0a2540);
justify-content: space-between;
gap: 40px;
padding: 20px;
}
.ibf-tile--accent {
background: var(--lx-accent, #ff5f00);
padding: 100px 20px 20px;
}
/* A bare photo tile takes its shape from the measured phone crop, 335×287. */
.ibf-tile--image {
aspect-ratio: 335 / 287;
background: var(--lx-surface, #f6f6f6);
}
.ibf-tile--captioned {
aspect-ratio: auto;
padding: 200px 20px 20px;
background: #111111;
}
.ibf-photo {
position: absolute;
inset: 0;
z-index: -2;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ---- photo placeholder ----------------------------------------------- */
.ibf-ph {
position: absolute;
inset: 0;
z-index: -2;
display: grid;
place-items: center;
padding: 16px;
text-align: center;
border-radius: inherit;
--ibf-ph-ink: rgba(10, 37, 64, 0.62);
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: var(--ibf-ph-ink);
border: 1px dashed color-mix(in srgb, var(--ibf-ph-ink) 35%, transparent);
}
/* Under a caption and its fade: dark stripes, and the brief sits at the
top so the caption at the bottom never covers it. */
.ibf-ph--dark {
--ibf-ph-ink: rgba(255, 255, 255, 0.72);
place-items: start center;
padding-top: 28px;
background:
repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0 12px, rgba(255, 255, 255, 0.1) 12px 24px),
#1b2530;
}
.ibf-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 28ch;
font-family: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
}
.ibf-ph-label {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ibf-ph-hint {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
}
.ibf-ph-spec {
font-size: 12px;
font-weight: 400;
line-height: 1.3;
}
/* A face is too small to read a brief: stripes only; the hint rides in
the accessible label. */
.ibf-face--ph {
display: block;
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.14) 0 5px, rgba(255, 255, 255, 0.24) 5px 10px), var(--lx-navy, #0a2540);
border: 1px dashed rgba(255, 255, 255, 0.35);
}
.ibf-fade {
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(rgba(17, 17, 17, 0) 51%, rgb(17, 17, 17) 100%);
}
.ibf-globe {
position: absolute;
z-index: -1;
right: 20px;
bottom: 16px;
width: 72px;
height: auto;
color: #ffffff;
opacity: 0.07;
}
.ibf-map {
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
color: #ffffff;
opacity: 0.22;
}
.ibf-copy {
display: flex;
flex-direction: column;
gap: 12px;
}
.ibf-title {
margin: 0;
font-family: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
font-size: 20px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.02em;
color: #ffffff;
}
.ibf-text {
margin: 0;
max-width: 21rem;
font-size: 16px;
line-height: 25.6px;
color: rgba(255, 255, 255, 0.8);
}
/* ---- navy tile footer ------------------------------------------------ */
.ibf-stat {
display: flex;
align-items: center;
gap: 10px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
}
.ibf-faces {
display: flex;
flex: none;
--ibf-face: 32px;
}
/* Each face tucks a quarter of itself under the one before, as measured. */
.ibf-face {
width: var(--ibf-face);
height: var(--ibf-face);
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 0 2px var(--lx-navy, #0a2540);
}
.ibf-face + .ibf-face {
margin-left: calc(var(--ibf-face) * -0.25);
}
.ibf-stat-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ibf-stat-value {
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: var(--lx-accent, #ff5f00);
}
.ibf-stat-label {
font-size: 16px;
line-height: 25.6px;
color: #ffffff;
}
/* ---- tablet: 2-up, wide tiles span both columns ---------------------- */
@container (min-width: 795px) {
.ibf-inner {
padding: 90px 30px;
gap: 56px;
}
.ibf-heading {
font-size: 40px;
}
.ibf-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(260px, auto);
}
.ibf-tile--navy {
padding: 30px 30px 20px;
}
.ibf-tile--accent {
padding: 134px 30px 30px;
}
.ibf-tile--image {
aspect-ratio: auto;
}
.ibf-tile--captioned {
padding: 180px 30px 40px;
}
.ibf-title {
font-size: 32px;
}
.ibf-faces {
--ibf-face: 40px;
}
.ibf-globe {
right: 30px;
bottom: auto;
top: 103px;
width: 110px;
}
/* DOM order is the phone order: navy, photo, accent, photo, tall. */
.ibf-tile:nth-child(1) { grid-column: 1 / -1; grid-row: 1; }
.ibf-tile:nth-child(2) { grid-column: 1; grid-row: 2; }
.ibf-tile:nth-child(4) { grid-column: 2; grid-row: 2; }
.ibf-tile:nth-child(3) { grid-column: 1 / -1; grid-row: 3; }
.ibf-tile:nth-child(5) { grid-column: 1 / -1; grid-row: 4; min-height: 420px; }
}
/* ---- desktop: the measured bento ------------------------------------- */
@container (min-width: 1180px) {
.ibf-inner {
padding: 120px 30px;
gap: 70px;
}
.ibf-heading {
font-size: 48px;
}
/*
* 260 · 257 · 260 · 393 with 10px gaps is exactly 1200. The wide tiles
* (527) cross the middle track from opposite sides, which is what makes
* the left block read as a staggered 2×2 rather than a plain grid.
*/
.ibf-grid {
grid-template-columns: 260fr 257fr 260fr 393fr;
grid-template-rows: 260px 259px;
grid-auto-rows: 260px;
}
/* A captioned photo in a 260px slot only has room for the copy; the
measured 134px top padding belongs to the tall slot. */
.ibf-tile--captioned {
padding: 30px;
}
.ibf-tile--captioned:nth-child(5) {
padding: 134px 30px 40px;
}
.ibf-tile:nth-child(1) { grid-column: 1 / 3; grid-row: 1; }
.ibf-tile:nth-child(2) { grid-column: 3; grid-row: 1; }
.ibf-tile:nth-child(3) { grid-column: 2 / 4; grid-row: 2; }
.ibf-tile:nth-child(4) { grid-column: 1; grid-row: 2; }
.ibf-tile:nth-child(5) { grid-column: 4; grid-row: 1 / 3; min-height: 0; }
.ibf-tile:nth-child(n + 6) { grid-column: auto; grid-row: auto; }
}
@media (forced-colors: active) {
.ibf-tile {
border: 1px solid CanvasText;
}
}
</style>
===== END src/components/ImageBentoFeatures.astro =====
===== FILE (required): src/components/StickyProcessSteps.astro =====
---
/**
* Sticky process steps
*
* A process section: on the left, a sticky block with an eyebrow, a display
* heading and a pill link; on the right, numbered steps, each an image with a
* title and a line of text, threaded on a vertical line that fills as the
* section scrolls past the middle of the viewport.
*
* No script. The fill is a CSS scroll-driven animation on the line's own view
* timeline, inside `@supports`. Without support — and under reduced motion —
* the line is simply drawn full, which is the finished state. Below 1024px the
* sticky block becomes an ordinary header above the steps.
*
* Step images are props. Unset, each renders a labelled striped placeholder
* with its size, and the step's `hint` when one is passed.
*
* No demo content: steps, heading and the pill link are props. With no steps
* (or none with a title) the component renders nothing.
*
* Self-contained: no imports, no packages, no global stylesheet. Colours and
* fonts read the page's `--lx-*` tokens with fallbacks, so it also works alone.
*/
export interface ImageSlot {
/** Image URL. Unset renders the placeholder. */
src?: string;
/** Alt text. Decorative ("") by default — the step title carries the meaning. */
alt?: string;
/** What the photo should show. Printed on the placeholder. No default. */
hint?: string;
}
export interface ProcessStep {
/** Required: a step without a title is dropped. */
title: string;
text?: string;
/** Step image. Unset shows the placeholder; `null` drops the image. */
image?: ImageSlot | null;
}
interface Props {
/** Root id. */
id?: string;
/** The pill above the heading. `""` drops it. */
eyebrow?: string;
/** Section heading. No default; `""` drops it. */
heading?: string;
headingLevel?: "h2" | "h3";
/** The pill link. Drawn only when both label and href are set. */
ctaLabel?: string;
ctaHref?: string;
/** The steps. Empty (the default) renders nothing. */
steps?: ProcessStep[];
/** Colour of the progress fill on the line. The reference measures navy. */
fill?: "accent" | "navy";
/** Fill the line with scroll. `false` draws it full and still. */
animate?: boolean;
/** Drop the top padding, for a section that follows one with its own. */
flushTop?: boolean;
/** Webfont stylesheet. `""` if the page already loads Bebas Neue + Poppins. */
fontStylesheet?: string;
}
const {
id,
eyebrow = "Process",
heading = "",
headingLevel = "h2",
ctaLabel = "",
ctaHref = "",
steps = [],
fill = "navy",
animate = true,
flushTop = false,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;
const Heading = headingLevel;
/* The step image at desktop: 457 x 235 in the reference. */
const IMAGE_SPEC = "~2:1 · 1400 × 720 px";
const StepHeading = headingLevel === "h2" ? "h3" : "h4";
const pad = (n: number) => String(n).padStart(2, "0");
/* A step with no title has nothing to show, so it is dropped rather than
rendered as an empty row. No steps, no section. */
const items = (steps ?? []).filter((step) => step?.title);
---
{items.length > 0 && (
<section
class:list={["sps", `sps--fill-${fill}`, { "sps--flush": flushTop }]}
id={id}
data-sps-root
data-animate={animate ? "" : undefined}
>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="sps__inner">
<div class="sps__intro">
{eyebrow && <p class="sps__eyebrow">{eyebrow}</p>}
<div class="sps__title-group">
{heading && <Heading class="sps__heading">{heading}</Heading>}
{
ctaLabel && ctaHref && (
<a class="sps__pill" href={ctaHref}>
<span>{ctaLabel}</span>
<span class="sps__pill-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</span>
</a>
)
}
</div>
</div>
<div class="sps__steps">
<div class="sps__track" aria-hidden="true">
<span class="sps__fill"></span>
</div>
<ol class="sps__list">
{
items.map((step, i) => {
const image = step.image === undefined ? {} : step.image;
const hint = image?.hint ?? "";
return (
<li class="sps__step">
<span class="sps__badge" aria-hidden="true">{pad(i + 1)}</span>
<div class="sps__body">
{image && (
<div class="sps__media">
{image.src ? (
<img src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" />
) : (
<div class="sps-ph" role="img" aria-label={hint ? `Image placeholder: ${hint}` : "Image placeholder"}>
<span class="sps-ph__label">Image</span>
{hint && <span class="sps-ph__hint">{hint}</span>}
<span class="sps-ph__spec">{IMAGE_SPEC}</span>
</div>
)}
</div>
)}
<div class="sps__copy">
<StepHeading class="sps__step-title">{step.title}</StepHeading>
{step.text && <p class="sps__step-text">{step.text}</p>}
</div>
</div>
</li>
);
})
}
</ol>
</div>
</div>
</section>
)}
<style>
.sps {
--sps-navy: var(--lx-navy, #0a2540);
--sps-accent: var(--lx-accent, #ff5f00);
--sps-ink-soft: var(--lx-ink-soft, #555555);
--sps-hairline: rgba(10, 37, 64, 0.14);
--sps-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--sps-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--sps-badge: 40px;
--sps-top: 100px;
box-sizing: border-box;
background: #fff;
padding: 60px 20px;
font-family: var(--sps-body);
color: var(--sps-navy);
overflow-x: clip;
}
.sps--fill-accent {
--sps-fill: var(--sps-accent);
}
.sps--fill-navy {
--sps-fill: var(--sps-navy);
}
.sps--flush {
padding-top: 0;
}
.sps *,
.sps *::before,
.sps *::after {
box-sizing: border-box;
}
.sps__inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 40px;
}
/* ---- intro ---- */
.sps__intro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.sps__eyebrow {
margin: 0;
padding: 7px 15px;
border: 1px solid var(--sps-hairline);
border-radius: 100px;
font: 500 14px/14px var(--sps-body);
text-transform: uppercase;
}
.sps__title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.sps__heading {
margin: 0;
font: 400 30px/1 var(--sps-display);
letter-spacing: -0.02em;
text-transform: uppercase;
}
.sps__pill {
display: inline-flex;
align-items: center;
gap: 16px;
min-height: 50px;
padding: 4px 4px 4px 18px;
border: 1px solid var(--sps-hairline);
border-radius: 100px;
background: #fff;
color: var(--sps-navy);
font: 600 14px/1 var(--sps-body);
text-decoration: none;
transition: border-color 0.2s ease;
}
.sps__pill-icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--sps-navy);
color: #fff;
transition: transform 0.25s ease;
}
.sps__pill:hover {
border-color: var(--sps-navy);
}
.sps__pill:hover .sps__pill-icon {
transform: translateX(3px);
}
.sps__pill:focus-visible {
outline: 2px solid var(--sps-navy);
outline-offset: 3px;
}
/* ---- steps ---- */
.sps__steps {
position: relative;
}
/*
* The line runs through the badge centres, from just under the first
* badge's top to the bottom of the list. It sits behind the badges, which
* have a white face, so it reads as joining them.
*/
.sps__track {
position: absolute;
top: 5px;
bottom: 3px;
left: calc(var(--sps-badge) / 2);
width: 1px;
background: var(--sps-hairline);
overflow: hidden;
}
/* Default: drawn full. This is the no-support and reduced-motion state. */
.sps__fill {
position: absolute;
inset: 0;
background: var(--sps-fill);
transform-origin: 50% 0;
}
.sps__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 32px;
}
.sps__step {
display: grid;
grid-template-columns: var(--sps-badge) minmax(0, 1fr);
align-items: start;
gap: 20px;
}
.sps__badge {
display: grid;
place-items: center;
width: var(--sps-badge);
height: var(--sps-badge);
margin-top: 3px;
border: 1px solid var(--sps-hairline);
border-radius: 50%;
background: #fff;
color: var(--sps-accent);
font: 400 20px/1 var(--sps-display);
letter-spacing: -0.02em;
/* Bebas sits high in its box; nudge the digits onto the optical centre. */
padding-top: 2px;
}
.sps__body {
display: flex;
flex-direction: column;
gap: 24px;
}
.sps__media {
overflow: hidden;
border-radius: 10px;
aspect-ratio: 457 / 235;
background: #eef2f6;
}
.sps__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ---- image placeholder (light context) ---- */
.sps-ph {
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
padding: 16px;
border: 1px dashed rgba(10, 37, 64, 0.22);
border-radius: inherit;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgba(10, 37, 64, 0.62);
text-align: center;
font-family: var(--sps-body);
}
.sps-ph__label {
font: 500 11px/1 var(--sps-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sps-ph__hint {
max-width: 28ch;
font: 500 13px/1.35 var(--sps-body);
}
.sps-ph__spec {
font: 400 12px/1.2 var(--sps-body);
}
/* Narrow phones: the slot gets short, so shed the label, then the spec.
The hint always stays, and the aria-label carries it too. */
.sps__media {
container-type: inline-size;
}
@container (max-width: 300px) {
.sps-ph {
gap: 4px;
padding: 10px;
}
.sps-ph__label {
display: none;
}
.sps-ph__hint {
font-size: 12px;
}
}
@container (max-width: 260px) {
.sps-ph__spec {
display: none;
}
}
.sps__copy {
display: flex;
flex-direction: column;
gap: 12px;
}
.sps__step-title {
margin: 0;
font: 400 24px/1 var(--sps-display);
letter-spacing: -0.02em;
text-transform: uppercase;
}
.sps__step-text {
margin: 0;
color: var(--sps-ink-soft);
font: 400 16px/1.6 var(--sps-body);
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.sps {
--sps-badge: 50px;
padding: 100px 30px;
}
.sps--flush {
padding-top: 0;
}
.sps__inner {
gap: 56px;
}
.sps__title-group {
gap: 40px;
}
.sps__heading {
font-size: 40px;
}
.sps__list {
gap: 54px;
}
.sps__step {
gap: 36px;
}
.sps__badge {
font-size: 24px;
}
.sps__body {
gap: 30px;
}
.sps__step-title {
font-size: 32px;
}
}
/* ---- desktop: sticky intro beside the steps ---- */
@media (min-width: 1024px) {
.sps {
padding: 120px 30px;
}
.sps--flush {
padding-top: 0;
}
.sps__inner {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 40px;
}
.sps__intro {
position: sticky;
top: var(--sps-top);
flex: 0 1 394px;
}
.sps__title-group {
gap: 60px;
}
.sps__heading {
font-size: 48px;
}
.sps__steps {
flex: 0 1 543px;
}
}
/*
* The fill. The track owns a view timeline whose viewport is inset to a
* band around the middle of the screen, so the fill's leading edge follows
* roughly the middle of the viewport: empty while the list is below it,
* full once the list has passed it.
*/
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
[data-animate] .sps__track {
view-timeline-name: --sps-line;
view-timeline-axis: block;
view-timeline-inset: 40% 50%;
}
[data-animate] .sps__fill {
animation: sps-fill linear both;
animation-timeline: --sps-line;
animation-range: cover 0% cover 100%;
}
}
}
@keyframes sps-fill {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.sps__pill,
.sps__pill-icon {
transition: none;
}
.sps__pill:hover .sps__pill-icon {
transform: none;
}
}
</style>
===== END src/components/StickyProcessSteps.astro =====
===== FILE (required): src/components/ScrollZoomVideo.astro =====
---
/**
* Scroll zoom video
*
* A band as tall as its media — 1200×700 at desktop, 335×195 on a phone —
* holding one rounded video (or still) that starts at half size and grows to
* fill the band as the band scrolls through the viewport.
*
* The growth is a CSS scroll-driven animation (`animation-timeline`), inside
* `@supports`, so no scroll listener ever runs. A browser without it, and
* anyone who asks for reduced motion, sees the media at full size.
*
* The one script only decides whether a supplied video may autoplay. It is
* served paused with native controls; the script swaps those for muted
* autoplay plus a pause button, and only when reduced motion is off.
*/
/** A still, or a video's poster frame. No `src` renders a labelled placeholder. */
export interface ZoomPoster {
src?: string;
/** Alt text for the still, and the video's accessible name. "" = decorative. */
alt?: string;
/** What the clip should show: the brief on the placeholder. */
hint?: string;
}
interface Props {
/**
* An mp4/webm to loop. Omit it and the band shows `poster` as a still, or
* a labelled placeholder when the poster has only a `hint`. With neither,
* the component renders nothing.
*/
videoSrc?: string;
/** Still image, and the video's poster frame. */
poster?: ZoomPoster;
/** Turn the scroll-linked growth off and show the media at full size. */
zoom?: boolean;
/**
* `""` when the page already loads Bebas Neue and Poppins. Only the
* placeholder sets text; the prop exists so every section has one API.
*/
fontStylesheet?: string;
id?: string;
}
const {
videoSrc,
poster = {},
zoom = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
const label = poster.alt ?? "";
/* The measured box is 1200×700 (12:7); spec for a 1.6× master. */
const spec = "12:7 · 1920 × 1120 px, MP4/H.264, muted";
/*
* Nothing to show, nothing rendered: the band appears for a video, a still, or
* a poster that explicitly asks for the placeholder (by giving it a hint).
* Omitted entirely, a production page gets no empty "Video" box.
*/
const hasMedia = Boolean(videoSrc || poster?.src || poster?.hint);
---
{hasMedia && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="szv" id={id} data-szv data-szv-zoom={zoom ? "" : undefined}>
<div class="szv-inner">
<div class="szv-scale">
<div class="szv-frame">
{videoSrc ? (
<>
{/*
* Served with controls and no `autoplay`: the server cannot
* know the reader's motion preference, so the safe state is
* the one that never moves on its own. The script upgrades it.
*/}
<video
class="szv-media"
src={videoSrc}
poster={poster.src}
muted
loop
playsinline
controls
preload="metadata"
aria-label={label || undefined}
data-szv-video
/>
<button class="szv-toggle" type="button" hidden data-szv-toggle aria-label="Pause video">
<svg class="szv-icon szv-icon--pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor" />
</svg>
<svg class="szv-icon szv-icon--play" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M8 5l11 7-11 7z" fill="currentColor" />
</svg>
</button>
</>
) : (
poster.src ? (
<img class="szv-media" src={poster.src} alt={label} loading="lazy" decoding="async" />
) : (
<div class="szv-media szv-ph" role="img" aria-label={`Video placeholder: ${poster.hint ?? "looping clip"}`}>
<span class="szv-ph-text">
<span class="szv-ph-label">Video</span>
{poster.hint && <span class="szv-ph-hint">{poster.hint}</span>}
<span class="szv-ph-spec">{spec}</span>
</span>
</div>
)
)}
</div>
</div>
</div>
</div>
</>
)}
<script>
/*
* Autoplay gate for ScrollZoomVideo.
*
* Nothing here touches the zoom — that is pure CSS. This only upgrades a
* served-paused video to muted autoplay when the reader has not asked for
* reduced motion, and gives it a pause button (WCAG 2.2.2) in place of the
* native controls it loses. If the preference flips while the page is open,
* it goes back to paused-with-controls.
*/
function initScrollZoomVideo(root: HTMLElement): void {
if (root.dataset.szvReady === "") return;
const video = root.querySelector<HTMLVideoElement>("[data-szv-video]");
const toggle = root.querySelector<HTMLButtonElement>("[data-szv-toggle]");
if (!video || !toggle) return;
root.dataset.szvReady = "";
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => {
const playing = !video.paused;
toggle.setAttribute("aria-label", playing ? "Pause video" : "Play video");
toggle.dataset.state = playing ? "playing" : "paused";
};
const apply = () => {
if (reduced.matches) {
video.pause();
video.controls = true;
toggle.hidden = true;
return;
}
video.controls = false;
toggle.hidden = false;
video.muted = true;
/* A rejected play() (data saver, a browser policy) leaves the
poster up and the button offering Play — nothing to recover. */
video.play().catch(() => sync());
};
video.addEventListener("play", sync, { signal });
video.addEventListener("pause", sync, { signal });
toggle.addEventListener(
"click",
() => {
if (video.paused) video.play().catch(() => sync());
else video.pause();
},
{ signal },
);
reduced.addEventListener("change", apply, { signal });
apply();
sync();
document.addEventListener(
"astro:before-swap",
() => {
video.pause();
video.controls = true;
toggle.hidden = true;
delete root.dataset.szvReady;
controller.abort();
},
{ once: true, signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-szv]").forEach(initScrollZoomVideo);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
<style>
.szv {
box-sizing: border-box;
overflow: clip;
font-family: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
}
/* The band is exactly as tall as the full-size media: 700 at a 1200 container, 195 at 335. */
.szv-inner {
box-sizing: border-box;
max-width: calc(1200px + 60px);
margin-inline: auto;
padding-inline: 20px;
}
@media (min-width: 810px) {
.szv-inner {
padding-inline: 30px;
}
}
.szv-scale {
transform-origin: 50% 50%;
}
.szv-frame {
position: relative;
aspect-ratio: 1200 / 700;
overflow: hidden;
/* On the scaled element, so it reads 5px at half size — as measured. */
border-radius: 10px;
background: var(--lx-surface, #f6f6f6);
}
.szv-media {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ---- placeholder (no video, no poster src) --------------------------- */
.szv-ph {
display: grid;
place-items: center;
padding: 16px;
text-align: center;
border-radius: inherit;
--szv-ph-ink: rgba(10, 37, 64, 0.62);
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: var(--szv-ph-ink);
border: 1px dashed color-mix(in srgb, var(--szv-ph-ink) 35%, transparent);
}
.szv-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 28ch;
}
.szv-ph-label {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.szv-ph-hint {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
}
.szv-ph-spec {
font-size: 12px;
font-weight: 400;
line-height: 1.3;
}
.szv-toggle {
position: absolute;
right: 12px;
bottom: 12px;
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(10, 37, 64, 0.72);
color: #ffffff;
cursor: pointer;
}
.szv-toggle[hidden] {
display: none;
}
.szv-toggle:hover {
background: var(--lx-navy, #0a2540);
}
.szv-toggle:focus-visible {
outline: 3px solid var(--lx-accent, #ff5f00);
outline-offset: 2px;
}
.szv-icon {
width: 20px;
height: 20px;
}
.szv-toggle:not([data-state="paused"]) .szv-icon--play,
.szv-toggle[data-state="paused"] .szv-icon--pause {
display: none;
}
/*
* Scroll-linked growth, 0.5 → 1.
*
* The named view timeline sits on the band, so progress is measured from
* the band's layout box rather than the scaled one. It finishes when the
* band's centre reaches the viewport's centre. Everything is inside
* `@supports` and a motion query, so the resting state everywhere else
* is full size.
*/
@supports (animation-timeline: view()) {
.szv[data-szv-zoom] {
view-timeline-name: --szv-band;
view-timeline-axis: block;
}
@media (prefers-reduced-motion: no-preference) {
.szv[data-szv-zoom] .szv-scale {
animation: szv-grow linear both;
animation-timeline: --szv-band;
animation-range: entry 0% cover 50%;
}
}
}
@keyframes szv-grow {
from {
transform: scale(0.5);
}
to {
transform: scale(1);
}
}
@media (forced-colors: active) {
.szv-frame {
border: 1px solid CanvasText;
}
}
</style>
===== END src/components/ScrollZoomVideo.astro =====
===== FILE (required): src/components/StackedReviewCards.astro =====
---
/**
* Stacked review cards
*
* A white testimonials section: a centred eyebrow and display heading, then
* one full-width card per review. On tablet and desktop every card is sticky
* at the same offset, so each new card slides up over the last as the page
* scrolls. A card is a photo on the left and, on the right, a quote mark, the
* quote in the display face and a person row with avatar, name, role and a
* company mark.
*
* No script at all: the pile is `position: sticky`, nothing is animated. On
* phones the cards simply follow one another, photo on top.
*
* Photos and avatars are props. Unset, a photo renders a labelled striped
* placeholder saying what belongs there and at what size; an avatar renders
* the stripes with the person's initials.
*
* Self-contained: no imports, no packages, no global stylesheet. Colours and
* fonts read the page's `--lx-*` tokens with fallbacks, so it also works alone.
*/
export interface ImageSlot {
/** Image URL. Unset renders the placeholder. */
src?: string;
/** Alt text. Decorative ("") by default. */
alt?: string;
/** What the photo should show. Printed on the placeholder. */
hint?: string;
}
export interface ReviewCard {
/** The quote, without quotation marks — they are added. */
quote: string;
name: string;
/** Job title. Shown in small caps under the name. */
role?: string;
/** Company name. Becomes the logo's alt text. */
company?: string;
/**
* Portrait, 54px round. Always decorative — the name is right beside it.
* Unset shows striped initials; `null` drops it.
*/
avatar?: ImageSlot | null;
/** Company mark. Omit to drop it. */
logoSrc?: string;
/** The large photo. Unset shows the placeholder; `null` drops the panel. */
image?: ImageSlot | null;
}
interface Props {
/** Root id. */
id?: string;
/** The pill above the heading. `""` drops it. */
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
reviews?: ReviewCard[];
/** Pile the cards with sticky positioning (810px and up). */
stack?: boolean;
/** Webfont stylesheet. `""` if the page already loads Bebas Neue + Poppins. */
fontStylesheet?: string;
}
const {
id,
eyebrow = "Testimonials",
heading = "What our clients say",
headingLevel = "h2",
/* No reviews of its own: they come from the caller or not at all. */
reviews = [],
stack = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;
const Heading = headingLevel;
/* The photo panel at desktop: 566 x 541 in the reference. */
const PHOTO_SPEC = "~1:1 · 1200 × 1150 px";
const DEFAULT_PHOTO_HINT = "Candid photo of the customer's team at work, shot in their own warehouse or storefront";
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]!.toUpperCase())
.join("");
---
{reviews.length > 0 && (
<section class="src" id={id} data-src-root data-stack={stack ? "" : undefined}>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="src__inner">
<header class="src__head">
{eyebrow && <p class="src__eyebrow">{eyebrow}</p>}
<Heading class="src__heading">{heading}</Heading>
</header>
<ul class="src__list">
{
reviews.map((review) => {
const image = review.image === undefined ? {} : review.image;
const avatar = review.avatar === undefined ? {} : review.avatar;
const hint = image?.hint ?? DEFAULT_PHOTO_HINT;
return (
<li class="src__item">
<figure class:list={["src__card", { "src__card--text": !image }]}>
{image && (
<div class="src__media">
{image.src ? (
<img src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" />
) : (
<div class="src-ph" role="img" aria-label={`Image placeholder: ${hint}`}>
<span class="src-ph__label">Image</span>
<span class="src-ph__hint">{hint}</span>
<span class="src-ph__spec">{PHOTO_SPEC}</span>
</div>
)}
</div>
)}
<div class="src__content">
<div class="src__quote">
<svg class="src__mark" viewBox="0 0 65 44" aria-hidden="true">
<path d="M14 0a14 14 0 0 1 14 14c0 13-7 24-19 30l-3-4c6-4 9-9 10-14A14 14 0 1 1 14 0Zm37 0a14 14 0 0 1 14 14c0 13-7 24-19 30l-3-4c6-4 9-9 10-14A14 14 0 1 1 51 0Z" fill="currentColor" />
</svg>
<blockquote class="src__text">
<p>“{review.quote}”</p>
</blockquote>
</div>
<figcaption class="src__author">
<span class="src__person">
{avatar && avatar.src && (
<img class="src__avatar" src={avatar.src} alt="" width="54" height="54" loading="lazy" decoding="async" />
)}
{avatar && !avatar.src && (
<span class="src__avatar src-ph src-ph--avatar" aria-hidden="true">{initials(review.name)}</span>
)}
<span class="src__who">
<span class="src__name">{review.name}</span>
{(review.role || (review.company && !review.logoSrc)) && (
<span class="src__role">
{review.role}
{review.company && !review.logoSrc && (review.role ? `, ${review.company}` : review.company)}
</span>
)}
</span>
</span>
{review.logoSrc && (
<img class="src__logo" src={review.logoSrc} alt={review.company ?? ""} loading="lazy" decoding="async" />
)}
</figcaption>
</div>
</figure>
</li>
);
})
}
</ul>
</div>
</section>
)}
<style>
.src {
--src-navy: var(--lx-navy, #0a2540);
--src-surface: var(--lx-surface, #f6f6f6);
--src-ink-soft: var(--lx-ink-soft, #555555);
--src-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--src-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--src-top: 50px;
box-sizing: border-box;
background: #fff;
padding: 60px 20px;
font-family: var(--src-body);
color: var(--src-navy);
overflow-x: clip;
}
.src *,
.src *::before,
.src *::after {
box-sizing: border-box;
}
.src__inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 40px;
}
/* ---- heading ---- */
.src__head {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
text-align: center;
}
.src__eyebrow {
margin: 0;
padding: 7px 15px;
border: 1px solid rgba(10, 37, 64, 0.14);
border-radius: 100px;
font: 500 14px/14px var(--src-body);
text-transform: uppercase;
}
.src__heading {
margin: 0;
max-width: 420px;
font: 400 30px/1 var(--src-display);
letter-spacing: -0.02em;
text-transform: uppercase;
text-wrap: balance;
}
/* ---- cards ---- */
.src__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 24px;
}
.src__card {
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
border-radius: 10px;
background: var(--src-surface);
}
.src__media {
overflow: hidden;
border-radius: 10px;
aspect-ratio: 566 / 541;
background: #dfe6ee;
}
.src__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ---- image placeholder (light context) ---- */
.src-ph {
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
padding: 16px;
border: 1px dashed rgba(10, 37, 64, 0.22);
border-radius: inherit;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgba(10, 37, 64, 0.62);
text-align: center;
font-family: var(--src-body);
}
.src-ph__label {
font: 500 11px/1 var(--src-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.src-ph__hint {
max-width: 28ch;
font: 500 14px/1.4 var(--src-body);
}
.src-ph__spec {
font: 400 12px/1.2 var(--src-body);
}
/* Too small for words: stripes and initials only. */
.src-ph--avatar {
padding: 0;
gap: 0;
border-radius: 50%;
font: 600 13px/1 var(--src-body);
letter-spacing: 0.04em;
}
.src__content {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 32px;
padding: 12px;
border-radius: 10px;
background: #fff;
}
.src__quote {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
padding: 20px 10px 0;
}
.src__mark {
width: 40px;
height: auto;
color: var(--src-navy);
}
.src__text {
margin: 0;
}
.src__text p {
margin: 0;
font: 400 20px/1 var(--src-display);
letter-spacing: -0.02em;
text-transform: uppercase;
}
.src__author {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-radius: 10px;
background: var(--src-surface);
}
.src__person {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.src__avatar {
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.src__who {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.src__name {
font: 400 16px/1 var(--src-display);
letter-spacing: -0.02em;
text-transform: uppercase;
}
.src__role {
color: var(--src-ink-soft);
font: 500 12px/1.2 var(--src-body);
text-transform: uppercase;
}
.src__logo {
flex: none;
width: auto;
height: 36px;
max-width: 96px;
object-fit: contain;
}
/* ---- tablet and up: side by side, sticky pile ---- */
@media (min-width: 810px) {
.src {
padding: 100px 30px;
}
.src__inner {
gap: 56px;
}
.src__heading {
font-size: 40px;
}
.src__list {
gap: 40px;
}
.src__card {
flex-direction: row;
min-height: 480px;
}
.src__media {
flex: 0 0 47.2%;
aspect-ratio: auto;
}
.src__content {
flex: 1 1 0;
min-width: 0;
padding: 20px;
gap: 40px;
}
.src__quote {
padding: 30px 20px 0;
gap: 32px;
}
.src__mark {
width: 52px;
}
.src__text p {
font-size: 26px;
}
.src__author {
padding: 20px;
}
.src__avatar {
width: 54px;
height: 54px;
}
.src__name {
font-size: 24px;
}
.src__role {
font-size: 14px;
line-height: 1;
}
.src__logo {
height: 51px;
max-width: 120px;
}
[data-stack] .src__item {
position: sticky;
top: var(--src-top);
}
}
@media (min-width: 1024px) {
.src {
padding: 120px 30px;
}
.src__inner {
gap: 70px;
}
.src__heading {
font-size: 48px;
}
.src__card {
min-height: 561px;
}
.src__quote {
gap: 40px;
}
.src__mark {
width: 65px;
}
.src__text p {
font-size: 32px;
}
.src__author {
padding: 24px;
}
}
</style>
===== END src/components/StackedReviewCards.astro =====
===== FILE (required): public/stacked-review-cards/mark-harborline.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 76"><path d="M6 70 V22 Q6 6 22 6 H32 Q46 6 50 20 L60 52 L70 20 Q74 6 88 6 H98 Q114 6 114 22 V70 H92 V30 L76 70 H44 L28 30 V70Z" fill="#ff5f00"/></svg>
===== END public/stacked-review-cards/mark-harborline.svg =====
===== FILE (required): public/stacked-review-cards/mark-summit.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M38 4 L72 58 H52 L38 34 L24 58 H4Z" fill="#ff5f00"/><path d="M24 64 H52 L46 72 H30Z" fill="#ff5f00" opacity=".6"/></svg>
===== END public/stacked-review-cards/mark-summit.svg =====
===== FILE (required): public/stacked-review-cards/mark-kestrel.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76"><path d="M4 30 L38 14 L72 30 L56 34 L38 62 L20 34Z" fill="#ff5f00"/><path d="M38 14 L46 34 L38 62 L30 34Z" fill="#0a2540" opacity=".18"/></svg>
===== END public/stacked-review-cards/mark-kestrel.svg =====
===== FILE (required): src/components/QuoteRequestPanel.astro =====
---
/**
* Quote request panel
*
* A rounded navy panel with an optional photograph behind it: an eyebrow, a
* display heading and a glass strip of phone and email on the left, and a
* white form card on the right asking for everything a freight quote needs.
*
* Nothing about the business is assumed: no heading, phone, email or photo is
* shown unless it is passed. With none of them the form still renders under
* the generic eyebrow.
*
* No script. Every control is native, so validation, keyboard behaviour and
* submission are the browser's. Colours and fonts read `--lx-*` custom
* properties with fallbacks, so a page can re-theme it from one ancestor.
*/
export interface QuoteLabels {
name?: string;
email?: string;
phone?: string;
company?: string;
pickup?: string;
delivery?: string;
service?: string;
details?: string;
}
export type QuotePlaceholders = QuoteLabels;
export interface QuotePhoto {
src?: string;
/** Empty marks it decorative, which a background photo usually is. */
alt?: string;
/** What the photo should show. Printed on the placeholder while `src` is unset. */
hint?: string;
}
/* The slot's shape, from the reference: a 1200×828 panel, i.e. about 3:2. */
const PHOTO_SPEC = "~3:2 · 2400 × 1660 px";
interface Props {
/** Small pill above the heading. `""` hides it. */
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** A real phone number, as displayed. The `tel:` link keeps a leading `+` and the digits; no digits, no link. Unset hides it. */
phone?: string;
/** A real email address. Unset hides it. */
email?: string;
/** Where the form posts. Unset by default: the browser then submits to the current URL. */
action?: string;
method?: "get" | "post";
formName?: string;
submitLabel?: string;
labels?: QuoteLabels;
placeholders?: QuotePlaceholders;
/** The freight-type options, submitted as written. */
serviceOptions?: string[];
/** Placeholder option text for the select. */
servicePrompt?: string;
/**
* The photo behind the panel. With no `src` a labelled placeholder fills
* the slot; `false` (the default) leaves a plain navy panel.
*/
photo?: QuotePhoto | false;
/** The hairline above the panel that separates it from the section before. */
divider?: boolean;
/** Google Fonts stylesheet for Bebas Neue + Poppins. `""` if the site loads them already. */
fontStylesheet?: string;
/** Root id. Also seeds every field id. */
id?: string;
}
const {
eyebrow = "Request a quote",
heading = "",
headingLevel = "h2",
phone = "",
email = "",
action,
method = "post",
formName,
submitLabel = "Send my quote request",
labels: labelOverrides = {},
placeholders: placeholderOverrides = {},
/* No default services: which services are offered is a fact about the business. The demo passes them. */
serviceOptions = [],
servicePrompt = "Choose a service…",
photo = false,
divider = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
const labels: Required<QuoteLabels> = {
name: "Full name",
email: "Work email",
phone: "Phone",
company: "Company",
pickup: "Pickup city",
delivery: "Delivery city",
service: "Freight type",
details: "Load details",
...labelOverrides,
};
const placeholders: Required<QuotePlaceholders> = {
name: "Jordan Alvarez",
email: "jordan@company.com",
phone: "(916) 555-0100",
company: "Sierra Outfitters",
pickup: "Sacramento, CA",
delivery: "Denver, CO",
service: "",
details: "Pallet count, weight, ready date…",
...placeholderOverrides,
};
/*
* One id prefix per instance. Two panels on a page must not share field ids,
* or a label in the second would focus a field in the first — so it is never
* derived from position, only from the caller's id or a random suffix.
*/
const uid = id ?? `qrp-${Math.random().toString(36).slice(2, 9)}`;
const fid = (key: string) => `${uid}-${key}`;
const Heading = headingLevel;
const photoHint = (photo && photo.hint) || "Background photo";
const showPhoto = photo !== false;
/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
const trimmed = number.trim();
const digits = trimmed.replace(/\D/g, "");
return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};
/* A call link only with somewhere to dial: words in `phone` are not a number. */
const callHref = phone ? telHref(phone) : "";
type InputField = {
key: keyof QuoteLabels;
type: "text" | "email" | "tel";
autocomplete?: string;
required: boolean;
};
const inputs: InputField[] = [
{ key: "name", type: "text", autocomplete: "name", required: true },
{ key: "email", type: "email", autocomplete: "email", required: true },
{ key: "phone", type: "tel", autocomplete: "tel", required: true },
{ key: "company", type: "text", autocomplete: "organization", required: true },
{ key: "pickup", type: "text", required: true },
{ key: "delivery", type: "text", required: true },
];
---
<section class="qrp" id={id} data-divider={divider ? "" : undefined}>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="qrp-container">
<div class="qrp-panel" data-has-image={showPhoto ? "" : undefined}>
{photo && photo.src && (
<img class="qrp-bg" src={photo.src} alt={photo.alt ?? ""} loading="lazy" decoding="async" />
)}
{photo && !photo.src && (
<>
<div class="qrp-bg qrp-ph" role="img" aria-label={`Image placeholder: ${photoHint}`}></div>
{/* A sibling, not a child, so it can sit above the scrim; it lands
in the open space of the intro column on desktop. */}
<div class="qrp-ph-text" aria-hidden="true">
<span class="qrp-ph-label">Image</span>
<span class="qrp-ph-hint">{photoHint}</span>
<span class="qrp-ph-spec">{PHOTO_SPEC}</span>
</div>
</>
)}
<div class="qrp-grid">
<div class="qrp-intro">
{(eyebrow || heading) && (
<div class="qrp-top">
{eyebrow && <p class="qrp-eyebrow">{eyebrow}</p>}
{heading && <Heading class="qrp-heading" id={fid("heading")}>{heading}</Heading>}
</div>
)}
{(phone || email) && (
<div class="qrp-contact">
{phone && (
<a class="qrp-phone" href={callHref || undefined}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M6.6 10.8a15.2 15.2 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11.4 11.4 0 0 0 3.6.57 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1z"
/>
</svg>
<span>{phone}</span>
</a>
)}
{email && (
<a class="qrp-email" href={`mailto:${email}`}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
d="M3 5.5h18v13H3zM3.5 6l8.5 7 8.5-7"
/>
</svg>
<span>{email}</span>
</a>
)}
</div>
)}
</div>
<form
class="qrp-form"
action={action}
method={method}
name={formName}
aria-labelledby={heading ? fid("heading") : undefined}
>
{inputs.map((field) => (
<div class="qrp-field">
<label class="qrp-label" for={fid(field.key)}>
{labels[field.key]}
{field.required && <span class="qrp-req" aria-hidden="true"> *</span>}
</label>
<input
class="qrp-control"
id={fid(field.key)}
name={field.key}
type={field.type}
autocomplete={field.autocomplete}
placeholder={placeholders[field.key] || undefined}
required={field.required}
/>
</div>
))}
{/* The service select only exists with real services to choose from. */}
{serviceOptions.filter(Boolean).length > 0 && (
<div class="qrp-field qrp-wide">
<label class="qrp-label" for={fid("service")}>
{labels.service}
<span class="qrp-req" aria-hidden="true"> *</span>
</label>
<div class="qrp-select-wrap">
<select class="qrp-control qrp-select" id={fid("service")} name="service" required>
{/* A placeholder label option: first, value "", so `required` rejects it. */}
<option value="" selected>{servicePrompt}</option>
{serviceOptions.filter(Boolean).map((option) => <option value={option}>{option}</option>)}
</select>
<svg class="qrp-caret" viewBox="0 0 16 10" width="16" height="10" aria-hidden="true" focusable="false">
<path d="M0 0h16L8 10z" fill="currentColor" />
</svg>
</div>
</div>
)}
<div class="qrp-field qrp-wide">
<label class="qrp-label" for={fid("details")}>{labels.details}</label>
<textarea
class="qrp-control qrp-textarea"
id={fid("details")}
name="details"
rows="4"
placeholder={placeholders.details || undefined}
></textarea>
</div>
<button class="qrp-submit qrp-wide" type="submit">
<span>{submitLabel}</span>
<span class="qrp-submit-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" focusable="false">
<path
d="M5 12h14M13 6l6 6-6 6"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
</form>
</div>
</div>
</div>
</section>
<style>
.qrp {
--qrp-navy: var(--lx-navy, #0a2540);
--qrp-accent: var(--lx-accent, #ff5f00);
--qrp-ink-soft: var(--lx-ink-soft, #555555);
--qrp-hairline: rgba(10, 37, 64, 0.14);
--qrp-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--qrp-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--qrp-duration: 200ms;
box-sizing: border-box;
padding: 0 20px 60px;
background: #ffffff;
font-family: var(--qrp-body);
color: var(--qrp-navy);
}
.qrp *,
.qrp *::before,
.qrp *::after {
box-sizing: border-box;
}
.qrp-container {
/* Every size inside keys off this width, not the viewport, so the panel
reads the same in a narrow column as it does full-bleed. */
container-type: inline-size;
max-width: 1200px;
margin-inline: auto;
padding-top: 60px;
}
.qrp[data-divider] .qrp-container {
border-top: 1px solid var(--qrp-hairline);
}
/* ---- the panel ---------------------------------------------------- */
.qrp-panel {
position: relative;
isolation: isolate;
overflow: clip;
border-radius: 10px;
background: var(--qrp-navy);
padding: 24px;
}
.qrp-bg {
position: absolute;
inset: 0;
z-index: -2;
width: 100%;
height: 100%;
object-fit: cover;
object-position: left center;
}
/* ---- the photo placeholder ---------------------------------------- */
.qrp-ph {
border: 1px dashed rgba(255, 255, 255, 0.25);
border-radius: 10px;
background: repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.06) 0 12px,
rgba(255, 255, 255, 0.1) 12px 24px
);
}
/* Stacked, the content covers the whole panel, so only the stripes show;
the hint is still in the placeholder's accessible name. */
.qrp-ph-text {
display: none;
}
.qrp-ph-label,
.qrp-ph-hint,
.qrp-ph-spec {
display: block;
font-family: var(--qrp-body);
color: rgba(255, 255, 255, 0.72);
}
.qrp-ph-label {
font-size: 12px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.qrp-ph-hint {
max-width: 28ch;
margin: 8px auto;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
.qrp-ph-spec {
font-size: 12px;
font-weight: 400;
line-height: 1.2;
}
/* The scrim that keeps white type legible over any photo. */
.qrp-panel[data-has-image]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: rgba(4, 16, 28, 0.4);
}
.qrp-grid {
display: flex;
flex-direction: column;
gap: 32px;
}
/* ---- the intro column --------------------------------------------- */
.qrp-intro {
display: flex;
flex-direction: column;
gap: 20px;
}
.qrp-top {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.qrp-eyebrow {
margin: 0;
padding: 8px 16px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 100px;
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: #ffffff;
}
.qrp-heading {
margin: 0;
max-width: 420px;
font-family: var(--qrp-display);
font-weight: 400;
/* 30px on a phone, 48px at the full 1200 — the two measured sizes. */
font-size: clamp(30px, 4cqi, 48px);
line-height: 1;
letter-spacing: -0.02em;
color: #ffffff;
}
.qrp-contact {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 20px;
padding: 16px;
border-radius: 13px;
background: rgba(255, 255, 255, 0.14);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.qrp-phone,
.qrp-email {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
transition:
background-color var(--qrp-duration) ease,
color var(--qrp-duration) ease;
}
.qrp-phone {
padding: 0 16px;
border-radius: 100px;
background: #ffffff;
color: var(--qrp-ink-soft);
text-decoration: none;
/* The number stays on one line; the email moves under it instead. */
white-space: nowrap;
}
.qrp-phone svg {
color: var(--qrp-accent);
}
.qrp-phone:hover {
color: var(--qrp-navy);
}
.qrp-email {
color: #ffffff;
text-decoration: underline;
text-underline-offset: 3px;
/* A long address breaks rather than pushing the panel sideways. */
overflow-wrap: anywhere;
}
.qrp-email:hover {
text-decoration-thickness: 2px;
}
.qrp-phone:focus-visible,
.qrp-email:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
/* ---- the form card ------------------------------------------------ */
.qrp-form {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px 20px;
padding: 20px;
border-radius: 10px;
background: #ffffff;
}
.qrp-field {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.qrp-wide {
grid-column: 1 / -1;
}
.qrp-label {
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: var(--qrp-navy);
}
.qrp-control {
display: block;
width: 100%;
min-height: 50px;
margin: 0;
padding: 12px 14px;
border: 1px solid var(--qrp-hairline);
border-radius: 10px;
background: #ffffff;
font-family: inherit;
/* 16px, not for looks: anything smaller zooms iOS Safari on focus. */
font-size: 16px;
line-height: 1.6;
color: var(--qrp-navy);
transition:
border-color var(--qrp-duration) ease,
outline-color var(--qrp-duration) ease;
}
.qrp-control::placeholder {
color: var(--qrp-ink-soft);
opacity: 0.85;
}
.qrp-control:hover {
border-color: rgba(10, 37, 64, 0.32);
}
/* An outline, not a shadow: a forced palette drops shadows, and the field
would be left with no focus indicator at all. */
.qrp-control:focus {
outline: 2px solid var(--qrp-accent);
outline-offset: 1px;
border-color: var(--qrp-accent);
}
.qrp-textarea {
min-height: 100px;
resize: vertical;
}
.qrp-select-wrap {
position: relative;
}
.qrp-select {
appearance: none;
-webkit-appearance: none;
padding-right: 44px;
cursor: pointer;
}
/* The prompt reads as a placeholder until a real choice is made. */
.qrp-select:has(option[value=""]:checked) {
color: var(--qrp-ink-soft);
}
.qrp-caret {
position: absolute;
top: 50%;
right: 16px;
translate: 0 -50%;
color: var(--qrp-navy);
pointer-events: none;
}
.qrp-submit {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 50px;
margin: 0;
padding: 5px 5px 5px 19px;
border: 0;
border-radius: 50px;
background: var(--qrp-accent);
font-family: inherit;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
text-align: left;
color: #ffffff;
cursor: pointer;
transition: background-color var(--qrp-duration) ease;
}
.qrp-submit:hover {
background: color-mix(in srgb, var(--qrp-accent) 88%, #000000);
}
.qrp-submit:focus-visible {
outline: 2px solid var(--qrp-navy);
outline-offset: 3px;
}
.qrp-submit-icon {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: #ffffff;
color: var(--qrp-accent);
}
.qrp-submit-icon svg {
transition: translate var(--qrp-duration) ease;
}
.qrp-submit:hover .qrp-submit-icon svg {
translate: 2px 0;
}
/* ---- wider containers --------------------------------------------- */
/* Room for two fields side by side: tablets and up, and phones in landscape. */
@container (min-width: 560px) {
.qrp-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 32px;
}
}
/* The measured desktop split: 466 intro + 60 gap + 594 form inside 40px padding. */
@container (min-width: 900px) {
.qrp-panel {
padding: 40px;
}
.qrp-grid {
display: grid;
grid-template-columns: minmax(0, 466fr) minmax(0, 594fr);
gap: 60px;
}
.qrp-intro {
justify-content: space-between;
padding: 20px;
}
/* Centred in the intro column's open middle, between the heading and
the contact strip — the one place nothing covers the photo. */
.qrp-ph-text {
position: absolute;
z-index: -1;
top: 50%;
left: 40px;
display: block;
width: calc((100% - 140px) * 466 / 1060);
padding: 16px;
translate: 0 -50%;
text-align: center;
}
.qrp-contact {
align-self: center;
justify-content: center;
padding: 25px 29px 25px 28px;
border-radius: 20px;
}
.qrp-form {
gap: 30px 20px;
padding: 40px;
}
.qrp-textarea {
min-height: 120px;
}
}
@media (min-width: 810px) {
.qrp {
padding: 0 30px 120px;
}
.qrp-container {
padding-top: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.qrp {
--qrp-duration: 0.01ms;
}
}
@media (forced-colors: active) {
.qrp-control,
.qrp-submit,
.qrp-phone,
.qrp-form,
.qrp-contact {
border: 1px solid CanvasText;
}
.qrp-caret {
color: CanvasText;
}
}
</style>
===== END src/components/QuoteRequestPanel.astro =====
===== FILE (required): src/components/StickyImageFaq.astro =====
---
/**
* Sticky image FAQ
*
* An eyebrow, a display heading and a pill link across the top; below, a tall
* photo card (a labelled placeholder until you pass `photo.src`) that stays pinned while an accordion of questions scrolls past
* it on the right.
*
* Built on `<details>` and `<summary>`, so every row opens, closes, takes
* focus and announces its state before any script runs, and `name` gives the
* one-at-a-time rule natively. The small controller adds only the height
* animation, which the browser cannot do for a `<details>` on its own.
*/
export interface StickyFaqItem {
question: string;
/** Plain text. Blank lines split it into paragraphs. */
answer: string;
}
export interface StickyFaqPhoto {
src?: string;
/** Empty marks it decorative, which it is by default. */
alt?: string;
/** What the photo should show. Printed on the placeholder while `src` is unset. */
hint?: string;
}
/* The slot's shape, from the reference: a 410×480 card, i.e. about 6:7. */
const PHOTO_SPEC = "~6:7 · 820 × 960 px";
interface Props {
/** Pill above the heading. `""` hides it. */
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** Pill link beside the heading. Needs both halves; `""` on either hides it. */
ctaLabel?: string;
ctaHref?: string;
/** `"outline"` is the measured look; `"accent"` fills it orange. */
ctaTone?: "outline" | "accent";
items?: StickyFaqItem[];
/** Which row starts open, counting from 1. `0` opens none. */
initialOpen?: number;
/** One row open at a time, through the `name` attribute on `<details>`. */
exclusive?: boolean;
/**
* The picture beside the list. With no `src` a labelled placeholder fills
* the card (the default); `false` drops the column and the list takes the width.
*/
photo?: StickyFaqPhoto | false;
/** Pin the image while the list scrolls (two-column layout only). */
stickyImage?: boolean;
/** Google Fonts stylesheet for Bebas Neue + Poppins. `""` if the site loads them already. */
fontStylesheet?: string;
/** Root id. Also seeds the heading id and the exclusivity group name. */
id?: string;
}
const {
eyebrow = "FAQs",
heading = "",
headingLevel = "h2",
ctaLabel = "",
ctaHref = "",
ctaTone = "outline",
items = [],
initialOpen = 1,
exclusive = true,
photo = {},
stickyImage = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
const Heading = headingLevel;
/* No default subject: the placeholder names one only when `photo.hint` does. */
const photoHint = (photo && photo.hint) || "";
/*
* One group name per instance. `name` is document-wide, so two of these on a
* page would close each other's rows if they shared one.
*/
const uid = id ?? `sif-${Math.random().toString(36).slice(2, 9)}`;
const openIndex =
Number.isInteger(initialOpen) && initialOpen >= 1 && initialOpen <= items.length ? initialOpen - 1 : -1;
/* Only schemes that navigate: this file is copied into other projects where
the href may come from a CMS. */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeCta = ctaHref && SAFE_SCHEME.test(ctaHref.trim()) ? ctaHref.trim() : "";
const showCta = Boolean(ctaLabel && safeCta);
const paragraphs = (text: string) =>
text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
---
{items.length > 0 && (
<section
class="sif"
id={id}
aria-labelledby={heading ? `${uid}-heading` : undefined}
data-sticky-faq
data-sticky={stickyImage ? "" : undefined}
data-has-image={photo ? "" : undefined}
>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="sif-container">
{(eyebrow || heading || showCta) && (
<div class="sif-top">
{(eyebrow || heading) && (
<div class="sif-title">
{eyebrow && <p class="sif-eyebrow">{eyebrow}</p>}
{heading && <Heading class="sif-heading" id={`${uid}-heading`}>{heading}</Heading>}
</div>
)}
{showCta && (
<a class="sif-cta" href={safeCta} data-tone={ctaTone}>
<span>{ctaLabel}</span>
<span class="sif-cta-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" focusable="false">
<path
d="M5 12h14M13 6l6 6-6 6"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</a>
)}
</div>
)}
<div class="sif-content">
{photo && (
<div class="sif-media">
{photo.src ? (
<img class="sif-img" src={photo.src} alt={photo.alt ?? ""} loading="lazy" decoding="async" />
) : (
<div class="sif-img sif-ph" role="img" aria-label={photoHint ? `Image placeholder: ${photoHint}` : "Image placeholder"}>
<span class="sif-ph-text" aria-hidden="true">
<span class="sif-ph-label">Image</span>
{photoHint && <span class="sif-ph-hint">{photoHint}</span>}
<span class="sif-ph-spec">{PHOTO_SPEC}</span>
</span>
</div>
)}
</div>
)}
<div class="sif-list">
{items.map((item, index) => (
<details
class="sif-row"
data-sif-row
name={exclusive ? uid : undefined}
open={index === openIndex}
>
<summary class="sif-summary" data-sif-summary>
<span class="sif-question">{item.question}</span>
{/* Two bars on pseudo-elements: a plus that turns over into a
minus, so it inherits colour and costs no request. */}
<span class="sif-icon" aria-hidden="true"></span>
</summary>
<div class="sif-panel">
<div class="sif-panel-inner">
<div class="sif-answer">
{paragraphs(item.answer).map((p) => <p>{p}</p>)}
</div>
</div>
</div>
</details>
))}
</div>
</div>
</div>
</section>
)}
<script>
/*
* The disclosure already works without this: <details> opens and shuts,
* and `name` keeps one row open at a time. What the browser cannot do is
* animate it — `open` flips and the content appears or vanishes in the
* same frame.
*
* So the controller takes over the click and uses one attribute,
* `data-sif-shut`, meaning "render as shut whatever `open` says". Every
* open style is written against `[open]:not([data-sif-shut])`, so with no
* script the guard never appears and `[open]` alone is the open state.
*
* - Closing: the guard goes on first, the panel animates to 0fr, and
* `open` is cleared when the animation has run.
* - Opening: the guard goes on for one committed frame so a never-opened
* panel has a 0fr frame to grow from; without it the row jumps.
*/
const OPEN_SEL = "[data-sif-row]";
function initStickyFaq(root: HTMLElement) {
if (root.dataset.sifReady === "") return;
const rows = Array.from(root.querySelectorAll<HTMLDetailsElement>(OPEN_SEL));
if (rows.length === 0) return;
root.dataset.sifReady = "";
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const timers = new Map<HTMLDetailsElement, number>();
const served = rows.map((row) => ({ row, name: row.name, open: row.open }));
/*
* Exclusivity moves from the browser to here while the script runs: left
* on, the browser shuts the sibling instantly and its close animation
* never gets a frame.
*/
const exclusive = rows.some((row) => row.name !== "");
for (const row of rows) row.name = "";
const closeMs = () => {
const raw = getComputedStyle(root).getPropertyValue("--sif-close").trim();
const value = parseFloat(raw);
if (!Number.isFinite(value)) return 300;
return raw.endsWith("ms") ? value : value * 1000;
};
const clearTimer = (row: HTMLDetailsElement) => {
const t = timers.get(row);
if (t !== undefined) window.clearTimeout(t);
timers.delete(row);
};
const showing = (row: HTMLDetailsElement) => row.open && row.dataset.sifShut === undefined;
const openRow = (row: HTMLDetailsElement) => {
clearTimer(row);
if (reduced.matches) {
delete row.dataset.sifShut;
row.open = true;
return;
}
row.dataset.sifShut = "";
row.open = true;
void row.offsetHeight;
delete row.dataset.sifShut;
};
const closeRow = (row: HTMLDetailsElement) => {
clearTimer(row);
if (reduced.matches) {
delete row.dataset.sifShut;
row.open = false;
return;
}
row.dataset.sifShut = "";
timers.set(
row,
window.setTimeout(() => {
timers.delete(row);
delete row.dataset.sifShut;
row.open = false;
}, closeMs()),
);
};
for (const row of rows) {
const summary = row.querySelector<HTMLElement>("[data-sif-summary]");
if (!summary) continue;
/* Enter and Space on a <summary> dispatch click too, so this covers
pointer and keyboard alike. */
summary.addEventListener(
"click",
(event) => {
event.preventDefault();
if (showing(row)) {
closeRow(row);
return;
}
if (exclusive) {
for (const other of rows) if (other !== row && showing(other)) closeRow(other);
}
openRow(row);
},
{ signal },
);
}
document.addEventListener(
"astro:before-swap",
() => {
for (const row of rows) clearTimer(row);
for (const { row, name, open } of served) {
delete row.dataset.sifShut;
row.name = name;
row.open = open;
}
delete root.dataset.sifReady;
controller.abort();
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-sticky-faq]").forEach(initStickyFaq);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
<style>
.sif {
--sif-navy: var(--lx-navy, #0a2540);
--sif-accent: var(--lx-accent, #ff5f00);
--sif-surface: var(--lx-surface, #f6f6f6);
--sif-ink-soft: var(--lx-ink-soft, #555555);
--sif-hairline: rgba(10, 37, 64, 0.14);
--sif-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--sif-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
/* Not measurable from a still capture; chosen to feel like the reference. */
--sif-open: 400ms;
--sif-close: 300ms;
--sif-ease: cubic-bezier(0.4, 0, 0.2, 1);
box-sizing: border-box;
padding: 60px 20px;
background: var(--sif-surface);
font-family: var(--sif-body);
color: var(--sif-navy);
}
.sif *,
.sif *::before,
.sif *::after {
box-sizing: border-box;
}
.sif-container {
container-type: inline-size;
max-width: 1200px;
margin-inline: auto;
}
/* ---- top row ------------------------------------------------------ */
.sif-top {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
margin-bottom: 40px;
}
.sif-title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
max-width: 462px;
}
.sif-eyebrow {
margin: 0;
padding: 7px 15px;
border: 1px solid var(--sif-hairline);
border-radius: 100px;
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: var(--sif-navy);
}
.sif-heading {
margin: 0;
font-family: var(--sif-display);
font-weight: 400;
font-size: clamp(30px, 4cqi, 48px);
line-height: 1;
letter-spacing: -0.02em;
color: var(--sif-navy);
}
.sif-cta {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 24px;
min-height: 50px;
padding: 4px 4px 4px 18px;
border: 1px solid var(--sif-hairline);
border-radius: 100px;
background: var(--sif-surface);
font-size: 14px;
font-weight: 600;
line-height: 14px;
color: var(--sif-navy);
text-decoration: none;
transition:
background-color var(--sif-close) var(--sif-ease),
border-color var(--sif-close) var(--sif-ease);
}
.sif-cta-icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--sif-navy);
color: #ffffff;
}
.sif-cta-icon svg {
transition: translate var(--sif-close) var(--sif-ease);
}
.sif-cta:hover {
border-color: var(--sif-navy);
}
.sif-cta:hover .sif-cta-icon svg {
translate: 2px 0;
}
.sif-cta[data-tone="accent"] {
border-color: transparent;
background: var(--sif-accent);
color: #ffffff;
}
.sif-cta[data-tone="accent"] .sif-cta-icon {
background: #ffffff;
color: var(--sif-accent);
}
.sif-cta[data-tone="accent"]:hover {
background: color-mix(in srgb, var(--sif-accent) 88%, #000000);
}
.sif-cta:focus-visible {
outline: 2px solid var(--sif-accent);
outline-offset: 3px;
}
/* ---- content ------------------------------------------------------ */
.sif-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.sif-media {
position: relative;
/* Without this a flex item's automatic minimum size is the picture's
own height, and the aspect-ratio below never wins. */
min-height: 0;
border-radius: 10px;
overflow: clip;
/* The measured card is 410×480; stacked on a phone it keeps that shape. */
aspect-ratio: 410 / 480;
}
.sif-img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.sif-ph {
display: grid;
place-items: center;
padding: 16px;
border: 1px dashed rgba(10, 37, 64, 0.22);
border-radius: 10px;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
text-align: center;
color: rgba(10, 37, 64, 0.62);
}
.sif-ph-label,
.sif-ph-hint,
.sif-ph-spec {
display: block;
}
.sif-ph-label {
font-size: 12px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sif-ph-hint {
max-width: 28ch;
margin: 8px auto;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
.sif-ph-spec {
font-size: 12px;
line-height: 1.2;
}
.sif-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
border-radius: 10px;
background: #ffffff;
}
.sif-row {
border-radius: 10px;
background: var(--sif-surface);
}
.sif-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 60px;
padding: 10px 10px 10px 18px;
border-radius: 10px;
cursor: pointer;
list-style: none;
}
.sif-summary::-webkit-details-marker {
display: none;
}
.sif-summary:focus-visible {
outline: 2px solid var(--sif-accent);
outline-offset: 2px;
}
.sif-question {
flex: 1 1 auto;
min-width: 0;
font-family: var(--sif-display);
font-weight: 400;
/* 16px on a phone, 24px at 1200 — both measured. */
font-size: clamp(16px, 2cqi, 24px);
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--sif-navy);
}
.sif-icon {
position: relative;
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 6px;
background: var(--sif-navy);
transition: rotate var(--sif-close) var(--sif-ease);
}
.sif-icon::before,
.sif-icon::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 2px;
margin: -1px 0 0 -7px;
border-radius: 1px;
background: var(--sif-surface);
}
.sif-icon::after {
rotate: 90deg;
transition: scale var(--sif-close) var(--sif-ease);
}
.sif-summary:hover .sif-icon {
background: color-mix(in srgb, var(--sif-navy) 85%, var(--sif-accent));
}
/* Open: the whole mark turns over (the reference's scale(-1,-1)) and the
upright bar folds away, leaving a minus. */
.sif-row[open]:not([data-sif-shut]) .sif-icon {
rotate: 180deg;
transition-duration: var(--sif-open);
}
.sif-row[open]:not([data-sif-shut]) .sif-icon::after {
scale: 1 0;
transition-duration: var(--sif-open);
}
.sif-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--sif-close) var(--sif-ease);
}
.sif-row[open]:not([data-sif-shut]) .sif-panel {
grid-template-rows: 1fr;
transition-duration: var(--sif-open);
}
.sif-panel-inner {
overflow: hidden;
min-height: 0;
}
.sif-answer {
margin: 4px 10px 10px;
padding: 20px;
border-radius: 10px;
background: #ffffff;
font-size: 16px;
line-height: 1.6;
color: var(--sif-ink-soft);
opacity: 0;
transition: opacity var(--sif-close) var(--sif-ease);
}
.sif-row[open]:not([data-sif-shut]) .sif-answer {
opacity: 1;
transition: opacity calc(var(--sif-open) * 0.6) var(--sif-ease) calc(var(--sif-open) * 0.3);
}
.sif-answer p {
margin: 0;
}
.sif-answer p + p {
margin-top: 0.8em;
}
/* ---- wider containers --------------------------------------------- */
@container (min-width: 560px) {
.sif-media {
aspect-ratio: 16 / 10;
}
}
@container (min-width: 720px) {
.sif-top {
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 70px;
}
.sif-summary {
gap: 40px;
}
}
/* The measured desktop split: a 410px card (460 with its 50px gutter) and a
590px list, pushed to opposite edges. */
@container (min-width: 900px) {
.sif-content {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 40px;
}
.sif-media {
flex: 0 1 410px;
aspect-ratio: 410 / 480;
}
.sif[data-sticky] .sif-media {
position: sticky;
top: 50px;
}
.sif-list {
flex: 0 1 590px;
}
.sif:not([data-has-image]) .sif-list {
flex-basis: 100%;
}
}
@media (min-width: 810px) {
.sif {
padding: 120px 30px;
}
}
@media (prefers-reduced-motion: reduce) {
.sif {
--sif-open: 0.01ms;
--sif-close: 0.01ms;
}
}
@media (forced-colors: active) {
.sif-row,
.sif-cta,
.sif-answer {
border: 1px solid CanvasText;
}
.sif-icon {
border: 1px solid CanvasText;
}
.sif-icon::before,
.sif-icon::after {
background: CanvasText;
}
}
</style>
===== END src/components/StickyImageFaq.astro =====
===== FILE (required): src/components/ArticleCardRow.astro =====
---
/**
* Article card row
*
* An eyebrow pill and a centred display heading over three article cards —
* a rounded image with the date on a white chip, then the title in the
* display face — and a "View all" pill under them. Each card is one link.
*
* Static: no script. Hover and focus are CSS only.
*
* Theming reads `--lx-*` custom properties with fallbacks, so a page can
* re-theme every logistics section from one ancestor and this one still
* works on its own.
*/
export interface ArticleCard {
title: string;
href: string;
/** Shown as written, uppercased by CSS, e.g. "Apr 14, 2026". */
date: string;
/** Machine-readable date for `<time>`, e.g. "2026-04-14". */
datetime?: string;
/**
* The card photo. With `src` it is an `<img>`; without, a labelled
* striped placeholder carrying `hint` and the size to shoot for.
* `alt` "" (the default) marks it decorative: the title names the link.
*/
image?: { src?: string; alt?: string; hint?: string };
}
interface Props {
/** Small uppercase label in the pill. `""` hides the pill. */
eyebrow?: string;
heading?: string;
/** The card titles take the next level down. */
headingLevel?: "h2" | "h3";
articles?: ArticleCard[];
/** The pill under the cards. `null` hides it. */
viewAll?: { label: string; href: string } | null;
/** `""` when the page already loads Bebas Neue and Poppins. */
fontStylesheet?: string;
id?: string;
}
const {
eyebrow = "Blog & article",
heading = "",
headingLevel = "h2",
articles: articlesProp = [],
viewAll = null,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
/* A card needs a title and a link; anything less is dropped, never padded. */
const articles = articlesProp.filter((article) => article?.title && article.href);
const Heading = headingLevel;
const CardHeading = headingLevel === "h2" ? "h3" : "h4";
const uid = id ?? `acr-${Math.random().toString(36).slice(2, 9)}`;
const SAFE_SCHEME = /^(?:https?:|mailto:|[#/.?])/i;
const safeHref = (href: string) => (SAFE_SCHEME.test(href.trim()) ? href.trim() : "#");
---
{articles.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{articles.length > 0 && (
<section class="acr" id={id} aria-labelledby={heading ? `${uid}-heading` : undefined}>
<div class="acr-inner">
{(eyebrow || heading) && (
<div class="acr-head">
{eyebrow && <p class="acr-eyebrow">{eyebrow}</p>}
{heading && <Heading class="acr-heading" id={`${uid}-heading`}>{heading}</Heading>}
</div>
)}
<div class="acr-body">
<ul class="acr-grid" role="list">
{articles.map((article) => (
<li class="acr-item">
<a class="acr-card" href={safeHref(article.href)}>
<span class="acr-media">
{article.image?.src ? (
<img class="acr-img" src={article.image.src} alt={article.image.alt ?? ""} loading="lazy" decoding="async" />
) : (
<span class="acr-img acr-ph" role="img" aria-label={`Image placeholder: ${article.image?.hint ?? "article photo"}`}>
<span class="acr-ph-text">
<span class="acr-ph-label">Image</span>
{article.image?.hint && <span class="acr-ph-hint">{article.image.hint}</span>}
{/* Measured card image: 380×300. */}
<span class="acr-ph-spec">~5:4 · 1520 × 1200 px</span>
</span>
</span>
)}
{article.date && <time class="acr-date" datetime={article.datetime}>{article.date}</time>}
</span>
<CardHeading class="acr-title">{article.title}</CardHeading>
</a>
</li>
))}
</ul>
{viewAll && viewAll.label && (
<a class="acr-more" href={safeHref(viewAll.href)}>
<span class="acr-more-label">{viewAll.label}</span>
<span class="acr-more-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</a>
)}
</div>
</div>
</section>
)}
<style>
.acr {
container-type: inline-size;
box-sizing: border-box;
background: #ffffff;
color: var(--lx-navy, #0a2540);
font-family: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--acr-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
.acr *,
.acr *::before,
.acr *::after {
box-sizing: border-box;
}
/* Phone first: 60/20 padding, 40px head gap, 32px between cards. */
.acr-inner {
max-width: calc(1200px + 60px);
margin-inline: auto;
padding: 60px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
}
.acr-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
text-align: center;
}
.acr-eyebrow {
margin: 0;
padding: 8px 16px;
border-radius: 100px;
box-shadow: inset 0 0 0 1px var(--lx-hairline, rgba(10, 37, 64, 0.14));
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: var(--lx-navy, #0a2540);
}
.acr-heading {
margin: 0;
max-width: 25rem;
font-family: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
font-size: 30px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.02em;
color: var(--lx-navy, #0a2540);
text-wrap: balance;
}
.acr-body {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
}
.acr-grid {
width: 100%;
max-width: 540px;
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 32px 30px;
}
.acr-card {
display: flex;
flex-direction: column;
gap: 20px;
color: inherit;
text-decoration: none;
border-radius: 10px;
}
.acr-card:focus-visible {
outline: 3px solid var(--lx-accent, #ff5f00);
outline-offset: 6px;
}
.acr-media {
position: relative;
display: block;
aspect-ratio: 380 / 300;
overflow: hidden;
border-radius: 10px;
background: var(--lx-surface, #f6f6f6);
}
.acr-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 600ms var(--acr-ease);
}
.acr-card:hover .acr-img,
.acr-card:focus-visible .acr-img {
transform: scale(1.05);
}
/* ---- photo placeholder (no `src`) ----------------------------------- */
.acr-ph {
display: grid;
place-items: center;
/* Clear the date chip in the top-left corner. */
padding: 52px 16px 16px;
text-align: center;
border-radius: inherit;
--acr-ph-ink: rgba(10, 37, 64, 0.62);
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: var(--acr-ph-ink);
border: 1px dashed color-mix(in srgb, var(--acr-ph-ink) 35%, transparent);
}
.acr-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 28ch;
}
.acr-ph-label {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.acr-ph-hint {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
}
.acr-ph-spec {
font-size: 12px;
font-weight: 400;
line-height: 1.3;
}
.acr-date {
position: absolute;
top: 20px;
left: 20px;
padding: 6px 8px;
border-radius: 4px;
background: #ffffff;
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: var(--lx-navy, #0a2540);
}
.acr-title {
margin: 0;
font-family: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
font-size: 20px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.02em;
color: var(--lx-navy, #0a2540);
}
/* ---- View all pill --------------------------------------------------- */
.acr-more {
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
/* 16, not the measured 24: there the 24px arrow sits inside a 40px
disc that overhangs it, which nets out to the same 135px pill. */
gap: 16px;
min-height: 50px;
padding: 5px 5px 5px 19px;
border-radius: 100px;
background: #ffffff;
box-shadow: inset 0 0 0 1px var(--lx-hairline, rgba(10, 37, 64, 0.14));
color: var(--lx-navy, #0a2540);
font-size: 14px;
font-weight: 600;
line-height: 14px;
text-decoration: none;
}
.acr-more-label {
transition: color 400ms var(--acr-ease);
}
.acr-more-icon {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
color: #ffffff;
}
/* The navy disc behind the arrow; on hover it floods the whole pill. */
.acr-more-icon::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: 50%;
background: var(--lx-navy, #0a2540);
transition: transform 500ms var(--acr-ease);
}
.acr-more-icon svg {
width: 24px;
height: 24px;
}
.acr-more:hover .acr-more-icon::before,
.acr-more:focus-visible .acr-more-icon::before {
transform: scale(8);
}
.acr-more:hover .acr-more-label,
.acr-more:focus-visible .acr-more-label {
color: #ffffff;
}
.acr-more:focus-visible {
outline: 3px solid var(--lx-accent, #ff5f00);
outline-offset: 3px;
}
/* ---- tablet and up: three across ------------------------------------- */
@container (min-width: 795px) {
.acr-inner {
padding: 90px 30px;
gap: 56px;
}
.acr-heading {
font-size: 40px;
}
.acr-body {
gap: 48px;
}
.acr-grid {
max-width: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.acr-card {
gap: 24px;
}
.acr-title {
font-size: 24px;
}
}
@container (min-width: 1180px) {
.acr-inner {
padding: 120px 30px;
gap: 70px;
}
.acr-heading {
font-size: 48px;
}
.acr-body {
gap: 60px;
}
.acr-grid {
gap: 60px 30px;
}
.acr-card {
gap: 30px;
}
.acr-title {
font-size: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.acr-img,
.acr-more-label,
.acr-more-icon::before {
transition: none;
}
.acr-card:hover .acr-img,
.acr-card:focus-visible .acr-img {
transform: none;
}
}
@media (forced-colors: active) {
.acr-more {
border: 1px solid ButtonText;
}
.acr-date {
border: 1px solid CanvasText;
}
}
</style>
===== END src/components/ArticleCardRow.astro =====
===== FILE (required): src/components/ImageCtaBand.astro =====
---
/**
* ImageCtaBand
*
* A rounded accent band that closes a page: a small outlined eyebrow, a big
* two-line display heading, a white pill button with a navy arrow disc, and
* a picture held inside the band on the right. A faint halftone of dots
* drifts across the band behind the copy.
*
* Side by side from 810px, stacked below it with the picture under the
* button. Static: no script.
*
* Reads --lx-* design tokens from any ancestor, with fallbacks.
*/
export interface ImageCtaBandProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3" | "h4";
cta?: { label: string; href: string } | null;
/**
* The picture slot. With no `src` a labelled placeholder fills the same
* box. `null` drops the slot and the copy takes the whole band.
*/
image?: { src?: string; alt?: string; hint?: string } | null;
/** The halftone dot pattern behind the copy. */
pattern?: boolean;
/** The section surface around the band. */
background?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends ImageCtaBandProps {}
const {
eyebrow = "",
heading = "",
headingLevel = "h2",
cta: ctaProp = null,
image = {},
pattern = true,
background = "#ffffff",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
/* The band is its heading: without one it renders nothing, button or not. */
const cta = ctaProp?.label && ctaProp.href ? ctaProp : null;
const Heading = headingLevel;
/* Measured slot: 420 x 400 at desktop, 287 x 273 stacked — both ~21:20. */
const IMAGE_SPEC = "~21:20 · 840 × 800 px";
---
{heading && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{heading && (
<section class="icb" id={id} style={`--icb-bg: ${background};`}>
<div
class:list={["icb-band", { "icb-band--pattern": pattern }]}
data-has-image={image ? "" : undefined}
>
<div class="icb-copy">
<div class="icb-head">
{eyebrow && <p class="icb-eyebrow">{eyebrow}</p>}
<Heading class="icb-heading">{heading}</Heading>
</div>
{
cta && (
<a class="icb-cta" href={cta.href}>
<span>{cta.label}</span>
<span class="icb-cta-disc" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</a>
)
}
</div>
{
image && (
<div class="icb-media">
{image.src ? (
<img src={image.src} alt={image.alt ?? ""} width="420" height="400" loading="lazy" decoding="async" />
) : (
<div class="icb-ph" role="img" aria-label={`Image placeholder: ${image.hint ?? "photo"}`}>
<div class="icb-ph-text" aria-hidden="true">
<span class="icb-ph-label">Image</span>
{image.hint && <span class="icb-ph-hint">{image.hint}</span>}
<span class="icb-ph-spec">{IMAGE_SPEC}</span>
</div>
</div>
)}
</div>
)
}
</div>
</section>
)}
<style>
.icb {
--icb-navy: var(--lx-navy, #0a2540);
--icb-accent: var(--lx-accent, #ff5f00);
--icb-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--icb-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--icb-dur: 220ms;
/*
* Step 39: on a site grid (--lx-grid-max / --lx-grid-gutter) the card breaks out of the grid by exactly its
* copy inset, so the copy starts on the page's content line. The card is the grid's content width plus
* the inset on each side; the fallbacks give the old card (1100 - 2 x 30 + 2 x 80 = 1200px) and margin.
*/
--icb-grid-max: var(--lx-grid-max, 1100px);
--icb-gutter: var(--lx-grid-gutter, 30px);
--icb-inset: 24px;
box-sizing: border-box;
padding: 0 20px 60px;
background: var(--icb-bg);
font-family: var(--icb-body);
}
.icb *,
.icb *::before,
.icb *::after {
box-sizing: border-box;
}
.icb-band {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 40px;
max-width: calc(var(--icb-grid-max) - 2 * var(--icb-gutter) + 2 * var(--icb-inset));
margin: 0 auto;
padding: 24px;
border-radius: 10px;
background: var(--icb-accent);
color: #fff;
overflow: hidden;
isolation: isolate;
}
/* The halftone: a grid of dots, faded out towards the top and the right
so it reads as texture under the copy rather than as a pattern. */
.icb-band--pattern::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-image: radial-gradient(circle, rgb(10 37 64 / 0.08) 0 7px, transparent 7.5px);
background-size: 22px 22px;
background-position: 0 0;
-webkit-mask-image: radial-gradient(ellipse 80% 70% at 30% 100%, #000 20%, transparent 75%);
mask-image: radial-gradient(ellipse 80% 70% at 30% 100%, #000 20%, transparent 75%);
pointer-events: none;
}
.icb-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
min-width: 0;
}
.icb-head {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.icb-eyebrow {
margin: 0;
padding: 8px 16px;
border: 1px solid rgb(255 255 255 / 0.3);
border-radius: 100px;
font: 500 14px/14px var(--icb-body);
text-transform: uppercase;
color: #fff;
}
.icb-heading {
margin: 0;
max-width: 380px;
font-family: var(--icb-display);
font-weight: 400;
font-size: 30px;
line-height: 1;
letter-spacing: -0.02em;
text-transform: uppercase;
color: #fff;
text-wrap: balance;
}
.icb-cta {
display: inline-flex;
align-items: center;
gap: 24px;
height: 50px;
padding: 5px 5px 5px 19px;
border-radius: 100px;
background: #fff;
color: var(--icb-navy);
font: 600 14px/14px var(--icb-body);
text-decoration: none;
white-space: nowrap;
}
.icb-cta-disc {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--icb-navy);
color: #fff;
}
.icb-cta-disc svg {
width: 24px;
height: 24px;
transition: transform var(--icb-dur) ease;
}
.icb-cta:hover .icb-cta-disc svg {
transform: rotate(-45deg);
}
.icb-cta:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
.icb-media {
position: relative;
border-radius: 10px;
overflow: hidden;
aspect-ratio: 287 / 273;
width: 100%;
}
.icb-media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Placeholder for an unset `src`: fills the same box as the picture. */
.icb-ph {
display: grid;
place-items: center;
width: 100%;
height: 100%;
padding: 16px;
text-align: center;
border: 1px dashed rgb(10 37 64 / 0.22);
border-radius: inherit;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgb(10 37 64 / 0.62);
}
.icb-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.icb-ph-label {
font: 500 12px/1 var(--icb-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.icb-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--icb-body);
}
.icb-ph-spec {
font: 400 12px/1.2 var(--icb-body);
}
/* ---- tablet and up: side by side ---- */
@media (min-width: 810px) {
.icb {
--icb-inset: 48px;
padding: 0 30px 120px;
}
.icb-band {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 32px;
min-height: 480px;
padding: 40px 40px 40px var(--icb-inset);
}
.icb-copy {
flex: 1 1 auto;
gap: 60px;
}
.icb-heading {
font-size: 48px;
}
.icb-media {
flex: 0 1 420px;
aspect-ratio: 420 / 400;
}
}
/* The site gutter applies on desktop only, as the site header's does; the default is the same 30px. */
@media (min-width: 960px) {
.icb {
padding-inline: var(--icb-gutter);
}
}
@media (min-width: 1200px) {
.icb {
--icb-inset: 80px;
}
.icb-band {
padding-left: var(--icb-inset);
}
}
@media (prefers-reduced-motion: reduce) {
.icb {
--icb-dur: 1ms;
}
}
@media (forced-colors: active) {
.icb-band {
border: 1px solid CanvasText;
}
.icb-band--pattern::before {
display: none;
}
}
</style>
===== END src/components/ImageCtaBand.astro =====
===== FILE (required): src/components/WordmarkMarqueeFooter.astro =====
---
/**
* WordmarkMarqueeFooter
*
* A site footer built as a rounded navy panel set into the page over a
* full-bleed photograph, which shows round the panel's edges and faintly
* through it. Inside: the brand, a short blurb and a row of icon links,
* link columns, a copyright bar — and along the bottom a huge display-font
* wordmark that scrolls sideways forever, with the brand mark between each
* repeat.
*
* Static: no script. The marquee is a CSS animation over two identical
* groups, translated by exactly one group's width, so the loop has no seam.
* It stops under reduced motion, pauses on hover and focus-within, and
* carries a native pause toggle so it can be stopped without a pointer.
*
* Reads --lx-* design tokens from any ancestor, with fallbacks.
*/
export type WordmarkFooterIcon = "rss" | "play" | "chat" | "mail" | "phone" | "pin" | "link";
export interface WordmarkFooterLink {
label: string;
href: string;
}
export interface WordmarkFooterColumn {
heading: string;
links: WordmarkFooterLink[];
}
export interface WordmarkFooterSocial {
/** The accessible name. Never shown; the icon is the visible part. */
label: string;
href: string;
icon: WordmarkFooterIcon;
}
export interface WordmarkMarqueeFooterProps {
brandName?: string;
brandHref?: string;
blurb?: string;
socialLabel?: string;
social?: WordmarkFooterSocial[];
columns?: WordmarkFooterColumn[];
/** Defaults to the year the site was built. */
year?: number;
legalLinks?: WordmarkFooterLink[];
/** The scrolling wordmark. */
wordmark?: string;
/** The brand mark between wordmark repeats. */
showMark?: boolean;
/** Seconds for one full loop at desktop width. Phone runs at half. */
marqueeDuration?: number;
/** A native pause toggle over the marquee. */
showPauseControl?: boolean;
pauseLabel?: string;
/**
* The photograph under the panel. With no `src` a labelled placeholder
* fills the same full-bleed box. `null` leaves a plain navy page edge.
*/
image?: { src?: string; alt?: string; hint?: string } | null;
/** Opacity of the navy layer over the photograph inside the panel. */
overlayOpacity?: number;
navLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends WordmarkMarqueeFooterProps {}
/*
* Nothing about the business is assumed: brand, blurb, icon links, columns,
* legal links, the scrolling wordmark and the photograph all default to
* nothing, and a block with nothing in it is not drawn. No brand means no
* logo and no copyright line; no wordmark means no marquee. Pass nothing at
* all and the footer renders nothing.
*/
const {
brandName,
brandHref = "/",
blurb,
socialLabel = "Follow us:",
social = [],
columns = [],
year = new Date().getFullYear(),
legalLinks = [],
wordmark,
showMark = true,
marqueeDuration = 40,
showPauseControl = true,
pauseLabel = "Pause the scrolling wordmark",
image = null,
overlayOpacity = 0.9,
navLabel = "Footer",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
/* Generic glyphs on a 24 grid, stroke 2. No third-party logos: this library
does not redistribute other companies' marks. Add your own here. */
const GLYPHS: Record<WordmarkFooterIcon, string> = {
rss: "M5 5a14 14 0 0 1 14 14M5 11a8 8 0 0 1 8 8M6 19h.01",
play: "M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5zM10 9l5 3-5 3z",
chat: "M20 12a8 8 0 0 1-11.6 7.1L4 20l1-4.2A8 8 0 1 1 20 12z",
mail: "M4 6h16v12H4zM4 7l8 6 8-6",
phone: "M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2",
pin: "M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5",
link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1",
};
/* Three repeats per group is enough to overfill the widest panel (1200px)
at every breakpoint; the second group is the seamless copy. */
const REPEATS = 3;
const repeats = Array.from({ length: REPEATS }, (_, i) => i);
const duration = Math.max(4, Number(marqueeDuration) || 40);
const opacity = Math.min(1, Math.max(0, Number(overlayOpacity)));
/* Measured underlay: 1425 x 920 at desktop (~16:10). Cover-cropped hard on a
phone, where the footer is 375 x 1153 — keep the subject central. */
const IMAGE_SPEC = "~16:10 · 2400 × 1500 px";
const hasBrand = Boolean(brandName || blurb || social.length > 0);
const hasRow = hasBrand || columns.length > 0;
const hasBottom = Boolean(brandName || legalLinks.length > 0);
const hasTop = hasRow || hasBottom;
const hasContent = hasTop || Boolean(wordmark);
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<footer
class="wmf"
id={id}
style={`--wmf-duration: ${duration}s; --wmf-overlay: ${opacity};`}
data-has-image={image ? "" : undefined}
>
{
image && (
<div class="wmf-bg">
{image.src ? (
<img src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" width="1440" height="920" />
) : (
<div class="wmf-ph" role="img" aria-label={`Image placeholder: ${image.hint ?? "photo"}`}>
<div class="wmf-ph-text" aria-hidden="true">
<span class="wmf-ph-label">Image</span>
{image.hint && <span class="wmf-ph-hint">{image.hint}</span>}
<span class="wmf-ph-spec">{IMAGE_SPEC}</span>
</div>
</div>
)}
</div>
)
}
<div class="wmf-panel">
<div class="wmf-overlay" aria-hidden="true"></div>
<div class="wmf-content">
{hasTop && (
<div class="wmf-top">
{hasRow && (
<div class="wmf-row">
{hasBrand && (
<div class="wmf-brand">
{brandName && (
<a class="wmf-logo" href={brandHref} aria-label={`${brandName} home`}>
<svg class="wmf-logo-mark" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
<rect width="40" height="40" rx="11" fill="var(--lx-accent, #ff5f00)" />
<path d="M7 17.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4" fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round" />
<path d="M7 25.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4" fill="none" stroke="var(--lx-navy, #0a2540)" stroke-width="3.4" stroke-linecap="round" />
</svg>
<span class="wmf-logo-word" aria-hidden="true">{brandName}</span>
</a>
)}
{blurb && <p class="wmf-blurb">{blurb}</p>}
{
social.length > 0 && (
<div class="wmf-social">
{socialLabel && <p class="wmf-social-label">{socialLabel}</p>}
<ul class="wmf-social-list" role="list">
{social.map((s) => (
<li>
<a class="wmf-social-link" href={s.href}>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d={GLYPHS[s.icon]} />
</svg>
<span class="wmf-sr">{s.label}</span>
</a>
</li>
))}
</ul>
</div>
)
}
</div>
)}
{
columns.length > 0 && (
<nav class="wmf-cols" aria-label={navLabel}>
{columns.map((col) => (
<div class="wmf-col">
<h2 class="wmf-col-heading">{col.heading}</h2>
<ul class="wmf-links" role="list">
{col.links.map((link) => (
<li>
<a class="wmf-link" href={link.href}>{link.label}</a>
</li>
))}
</ul>
</div>
))}
</nav>
)
}
</div>
)}
{hasBottom && (
<div class="wmf-bottom">
{brandName && (
<p class="wmf-copy">
© {year} <span class="wmf-copy-brand">{brandName}.</span> All rights reserved.
</p>
)}
{
legalLinks.length > 0 && (
<ul class="wmf-legal" role="list">
{legalLinks.map((link) => (
<li>
<a class="wmf-link" href={link.href}>{link.label}</a>
</li>
))}
</ul>
)
}
</div>
)}
</div>
)}
{wordmark && (
<div class="wmf-marquee">
<div class="wmf-track">
{
[0, 1].map((group) => (
<ul class="wmf-group" role="list" aria-hidden={group === 1 ? "true" : undefined}>
{repeats.map((i) => (
<li class="wmf-item" aria-hidden={group === 0 && i > 0 ? "true" : undefined}>
<span class="wmf-word">{wordmark}</span>
{showMark && (
<svg class="wmf-mark" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
<rect width="40" height="40" rx="11" fill="var(--lx-accent, #ff5f00)" />
<path d="M7 17.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4" fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round" />
<path d="M7 25.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4" fill="none" stroke="var(--lx-navy, #0a2540)" stroke-width="3.4" stroke-linecap="round" />
</svg>
)}
</li>
))}
</ul>
))
}
</div>
{
showPauseControl && (
<label class="wmf-pause">
<input class="wmf-pause-input" type="checkbox" />
<span class="wmf-pause-face" aria-hidden="true">
<svg class="wmf-pause-icon" viewBox="0 0 24 24" focusable="false">
<path d="M9 6v12M15 6v12" />
</svg>
<svg class="wmf-play-icon" viewBox="0 0 24 24" focusable="false">
<path d="M8 5.5v13l10-6.5z" />
</svg>
</span>
<span class="wmf-sr">{pauseLabel}</span>
</label>
)
}
</div>
)}
</div>
</div>
</footer>
)}
<style>
.wmf {
--wmf-navy: var(--lx-navy, #0a2540);
--wmf-accent: var(--lx-accent, #ff5f00);
--wmf-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--wmf-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--wmf-soft: rgb(255 255 255 / 0.8);
--wmf-dur: 200ms;
/* Marquee geometry, phone first. */
--wmf-word: 80px;
--wmf-mark: 75px;
--wmf-gap: 24px;
--wmf-speed: calc(var(--wmf-duration) / 2);
position: relative;
box-sizing: border-box;
padding: 50px 20px;
background: var(--wmf-navy);
font-family: var(--wmf-body);
color: #fff;
isolation: isolate;
overflow: hidden;
}
.wmf *,
.wmf *::before,
.wmf *::after {
box-sizing: border-box;
}
.wmf ul {
margin: 0;
padding: 0;
list-style: none;
}
.wmf p {
margin: 0;
}
.wmf-bg {
position: absolute;
inset: 0;
z-index: -1;
}
.wmf-bg img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Placeholder for an unset `src`. The panel covers the middle of this
box, so the text sits in the strip above the panel, on one wrapping
row, where nothing covers it. */
.wmf-ph {
display: grid;
place-items: start center;
width: 100%;
height: 100%;
padding: 12px 16px;
text-align: center;
border: 1px dashed rgb(255 255 255 / 0.25);
background: repeating-linear-gradient(135deg, rgb(255 255 255 / 0.06) 0 12px, rgb(255 255 255 / 0.1) 12px 24px);
color: rgb(255 255 255 / 0.72);
}
.wmf-ph-text {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: baseline;
gap: 2px 12px;
}
.wmf-ph-label {
font: 500 11px/1.4 var(--wmf-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* A 50px strip on a phone holds the label and the spec, not the brief. */
.wmf-ph-hint {
display: none;
font: 500 13px/1.4 var(--wmf-body);
}
.wmf-ph-spec {
font: 400 12px/1.4 var(--wmf-body);
}
@media (min-width: 810px) {
.wmf-ph {
padding-top: 20px;
}
.wmf-ph-hint {
display: inline;
}
}
.wmf-panel {
position: relative;
max-width: 1200px;
margin: 0 auto;
padding: 24px 0;
border-radius: 10px;
overflow: hidden;
isolation: isolate;
}
.wmf-overlay {
position: absolute;
inset: 0;
z-index: -1;
background: var(--wmf-navy);
opacity: var(--wmf-overlay);
}
/* Without a photograph there is nothing to see through, so the panel
is simply navy on navy — lift it a shade so its edge still reads. */
.wmf:not([data-has-image]) .wmf-overlay {
opacity: 1;
background: color-mix(in srgb, var(--wmf-navy) 88%, #fff);
}
.wmf-content {
display: flex;
flex-direction: column;
gap: 40px;
}
.wmf-top {
display: flex;
flex-direction: column;
gap: 40px;
padding: 0 24px;
}
.wmf-row {
display: flex;
flex-direction: column;
gap: 40px;
}
/* ---- brand column ---- */
.wmf-brand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.wmf-logo {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
margin: -7px 0;
color: #fff;
text-decoration: none;
border-radius: 8px;
}
.wmf-logo-mark {
width: 30px;
height: 30px;
}
.wmf-logo-word {
font-family: var(--wmf-display);
font-size: 30px;
line-height: 1;
letter-spacing: -0.02em;
text-transform: uppercase;
padding-top: 3px;
}
.wmf-blurb {
max-width: 300px;
font: 400 16px/25.6px var(--wmf-body);
color: var(--wmf-soft);
}
.wmf-social {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 10px;
}
.wmf-social-label {
font: 500 14px/14px var(--wmf-body);
text-transform: uppercase;
color: #fff;
white-space: nowrap;
}
/* 20px glyphs in 44px targets. The pitch is 44 rather than the
reference's 28 — the price of a target a thumb can hit. */
.wmf-social-list {
display: flex;
align-items: center;
}
.wmf-social-link {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
color: var(--wmf-soft);
transition: color var(--wmf-dur) ease;
}
.wmf-social-link svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.wmf-social-link:hover {
color: var(--wmf-accent);
}
/* ---- link columns ---- */
.wmf-cols {
display: flex;
flex-direction: column;
gap: 32px;
}
.wmf-col {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.wmf-col-heading {
margin: 0;
font-family: var(--wmf-display);
font-weight: 400;
font-size: 16px;
line-height: 1;
letter-spacing: -0.02em;
text-transform: uppercase;
color: #fff;
}
.wmf-links {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
/* 25.6px lines in 44px targets; the margin gives the height back. */
.wmf-link {
display: inline-block;
padding: 9px 0;
margin: -9px 0;
font: 400 16px/25.6px var(--wmf-body);
color: var(--wmf-soft);
text-decoration: none;
overflow-wrap: anywhere;
border-radius: 4px;
transition: color var(--wmf-dur) ease;
}
.wmf-link:hover {
color: #fff;
}
/* ---- copyright bar ---- */
.wmf-bottom {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding-top: 18px;
border-top: 1px solid rgb(255 255 255 / 0.1);
}
.wmf-copy {
font: 400 16px/25.6px var(--wmf-body);
color: var(--wmf-soft);
}
.wmf-copy-brand {
font-size: 14px;
font-weight: 500;
text-transform: uppercase;
color: var(--wmf-accent);
}
.wmf-legal {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
}
/* ---- the marquee ---- */
.wmf-marquee {
position: relative;
overflow: hidden;
}
.wmf-track {
display: flex;
width: max-content;
animation: wmf-scroll var(--wmf-speed) linear infinite;
}
.wmf-group {
display: flex;
flex: none;
}
/* Trailing padding rather than a flex gap, so every group is exactly
REPEATS x (item + gap) wide and -50% lands on a seam. */
.wmf-item {
display: flex;
align-items: center;
gap: var(--wmf-gap);
padding-right: var(--wmf-gap);
flex: none;
}
.wmf-word {
font-family: var(--wmf-display);
font-size: var(--wmf-word);
line-height: 1;
letter-spacing: -0.02em;
text-transform: uppercase;
white-space: nowrap;
color: #fff;
}
.wmf-mark {
width: var(--wmf-mark);
height: var(--wmf-mark);
flex: none;
}
@keyframes wmf-scroll {
to {
transform: translateX(-50%);
}
}
.wmf-marquee:hover .wmf-track,
.wmf-marquee:focus-within .wmf-track,
.wmf-marquee:has(.wmf-pause-input:checked) .wmf-track {
animation-play-state: paused;
}
/* The pause toggle: a native checkbox, so it is keyboard-operable and
announces its state with no script behind it. */
.wmf-pause {
position: absolute;
right: 12px;
bottom: 12px;
display: block;
width: 44px;
height: 44px;
cursor: pointer;
}
.wmf-pause-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.wmf-pause-face {
display: grid;
place-items: center;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgb(10 37 64 / 0.7);
border: 1px solid rgb(255 255 255 / 0.25);
color: #fff;
pointer-events: none;
transition: background-color var(--wmf-dur) ease;
}
.wmf-pause:hover .wmf-pause-face {
background: var(--wmf-navy);
}
.wmf-pause-face svg {
grid-area: 1 / 1;
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.wmf-pause-icon {
fill: none;
}
.wmf-play-icon {
fill: currentColor;
display: none;
}
.wmf-pause-input:checked ~ .wmf-pause-face .wmf-pause-icon {
display: none;
}
.wmf-pause-input:checked ~ .wmf-pause-face .wmf-play-icon {
display: block;
}
/* ---- focus ---- */
.wmf a:focus-visible {
outline: 2px solid var(--wmf-accent);
outline-offset: 2px;
}
.wmf-pause-input:focus-visible ~ .wmf-pause-face {
outline: 2px solid var(--wmf-accent);
outline-offset: 2px;
}
.wmf-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.wmf {
--wmf-word: 140px;
--wmf-mark: 118px;
--wmf-gap: 32px;
--wmf-speed: calc(var(--wmf-duration) * 0.75);
padding: 64px 30px;
}
.wmf-panel {
padding: 64px 0 32px;
}
.wmf-content {
gap: 56px;
}
.wmf-top {
gap: 64px;
padding: 0 48px;
}
.wmf-row {
gap: 48px;
}
.wmf-cols {
display: grid;
grid-template-columns: repeat(3, minmax(0, auto));
justify-content: space-between;
column-gap: 40px;
}
.wmf-col {
gap: 40px;
max-width: 240px;
}
.wmf-col-heading {
font-size: 24px;
}
.wmf-links {
gap: 16px;
}
.wmf-bottom {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 24px;
}
}
/* ---- desktop ---- */
@media (min-width: 1200px) {
.wmf {
--wmf-word: 200px;
--wmf-mark: 168px;
--wmf-gap: 40px;
--wmf-speed: var(--wmf-duration);
padding: 80px 30px;
}
.wmf-panel {
padding: 80px 0 40px;
}
.wmf-content {
gap: 60px;
}
.wmf-top {
gap: 80px;
padding: 0 80px;
}
.wmf-row {
flex-direction: row;
justify-content: space-between;
align-items: stretch;
gap: 48px;
}
.wmf-brand {
justify-content: space-between;
padding-bottom: 10px;
max-width: 280px;
}
.wmf-cols {
display: flex;
flex-direction: row;
gap: 64px;
}
}
@media (prefers-reduced-motion: reduce) {
.wmf {
--wmf-dur: 1ms;
}
.wmf-track {
animation: none;
}
/* Nothing moves, so there is nothing to pause. */
.wmf-pause {
display: none;
}
}
@media (forced-colors: active) {
.wmf-panel {
border: 1px solid CanvasText;
}
.wmf-bottom {
border-top-color: CanvasText;
}
}
</style>
===== END src/components/WordmarkMarqueeFooter.astro =====
--- SECTIONS ---
- Overlay pill header (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/overlay-pill-header/
- Service ticker hero (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/service-ticker-hero/
- Bordered logo grid (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/bordered-logo-grid/
- Mission stat about (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/mission-stat-about/
- Stacked service cards (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/stacked-service-cards/
- Image bento features (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/image-bento-features/
- Sticky process steps (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/sticky-process-steps/
- Scroll zoom video (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/scroll-zoom-video/
- Stacked review cards (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/stacked-review-cards/
- Quote request panel (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/quote-request-panel/
- Sticky image FAQ (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/sticky-image-faq/
- Article card row (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/article-card-row/
- Image CTA band (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/image-cta-band/
- Wordmark marquee footer (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/wordmark-marquee-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.