Component library
Back to catalog

Service ticker hero

A full-viewport photo hero with a navy wash, a partner badge and a huge two-line display heading, plus a white card that ticks through your services on a filling progress line, with pause and step controls.

herofull-bleedcarouselautoplayservicesdisplay-type

Open preview in a new tab

Use this component

Three ways to get Service ticker hero into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Service ticker hero from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/service-ticker-hero/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Service ticker hero"
Library page: https://astro.baysixmedia.com/components/service-ticker-hero/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== 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 (supporting): src/components/ServiceTickerHero.md =====
# Service ticker hero

A full-viewport photo hero with a navy wash, a partner badge and a huge
two-line display heading, plus a white card that ticks through your services
on a filling progress line, with pause and step controls.

Catalog page: `/components/service-ticker-hero`

## Purpose

The top of a service business's homepage: one strong line, a trust badge,
and a card that shows the range of what you do without making anyone click.
Built for a logistics site; works for any business with three to six headline
services.

Use something else if the hero needs a form, a video, or more than a sentence
of copy. The layout is built around a short heading and a small card.

## Files required

**`ServiceTickerHero.astro`**: one file, the whole component.

- No imports, no npm packages, no framework.
- One small `<script>` for the ticker. Everything else is CSS.
- Colours and fonts read `--lx-*` custom properties with fallbacks, so it
  works alone and re-themes from one ancestor.
- Loads Bebas Neue and Poppins from Google Fonts unless you pass
  `fontStylesheet=""`.

`ServiceTickerHero.md`: this file. Not needed to run.

No image files. Every picture slot defaults to a labelled placeholder.

**The component ships no copy of its own.** The heading, services, badge and
link all default to empty, so a copied hero never carries the catalog's
fictional freight services or their routes. The catalog demo passes them
through the registry's `props`. Each block is drawn only when its data is
present: no services means no card, no heading or badge means no left column,
and with none of them the hero renders nothing. `cardLabel` keeps its generic
`"Services"` default; it only shows on a card that has services.

## Basic usage

```astro
---
import ServiceTickerHero from "../components/ServiceTickerHero.astro";
---

<ServiceTickerHero
  background={{ src: "/img/hero-truck.jpg", alt: "" }}
  headingLines={["Freight that", "keeps moving"]}
  services={[
    { name: "Road freight", image: { src: "/img/road.jpg", alt: "" }, href: "/services/road" },
    { name: "Air cargo", image: { src: "/img/air.jpg", alt: "" }, href: "/services/air" },
  ]}
  cta={{ label: "Let's talk", href: "/contact" }}
/>
```

It is full bleed and paints its own background. Put an overlay header on top
of it; the hero reserves no space for one, but its top padding keeps the
content clear of a header up to about 98px tall.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `id` | `string?` | none | Root id. |
| `background` | `ImageSlot?` | `{}` — placeholder | Full-bleed photo. Decorative (`alt: ""`) by default. |
| `avatars` | `ImageSlot[]?` | `[]` | Round portraits in the badge. `[]` drops them. |
| `partnerCount` | `string?` | `""` | The orange part of the badge. `""` drops it. |
| `partnerText` | `string?` | `""` | The rest of the badge. No trust claim is shown unless you pass one. |
| `headingLines` | `string[]?` | `[]` | One entry per line from 810px up; they run together on phones. `[]` drops the heading. |
| `headingLevel` | `"h1" \| "h2"?` | `"h1"` | |
| `cardLabel` | `string?` | `"Services"` | Card title, and the carousel's accessible name. |
| `services` | `HeroService[]?` | `[]` | `{ name, image?, href? }`. `href` makes the name a link. `[]` drops the card. |
| `cta` | `{ label, href }?` | none | The navy pill in the card. Drawn only with an `href`. |
| `interval` | `number?` | `3500` | Milliseconds per service while autoplaying. Minimum 1000. |
| `autoplay` | `boolean?` | `true` | Off: the card only moves when the visitor steps it. |
| `fullHeight` | `boolean?` | `true` | At least the viewport tall from 810px up. |
| `fontStylesheet` | `string?` | Google Fonts URL | `""` to skip. |

```ts
interface ImageSlot { src?: string; alt?: string; hint?: string }
interface HeroService { name: string; image?: ImageSlot; href?: string }
```

Leave `image` off a service entirely and its strip is simply empty.

## Images

With no `src`, each slot renders a striped placeholder in the same box,
showing `IMAGE`, the hint, and the size to shoot or buy against.

| Slot | Hint shown | Ratio · size |
| :--- | :--- | :--- |
| `background` | its `hint`, else "Hero background photo" | 16:9 · 1920 × 1080 px |
| `services[].image` | its `hint`, else the service name | ~5:1 · 800 × 160 px |
| `avatars[]` | none (stripes only at 40px) | 1:1 · 160 × 160 px |

The catalog demo passes freight-specific hints (a semi truck on a city
highway; a box truck, a cargo plane, a container ship, warehouse racking)
through its props; the component itself carries none.

The background's label sits above the navy overlay in the band under the
header, so the content never covers it. The service strip is short, so it
shows the hint only. Avatar placeholders are `aria-hidden`, like the real
avatars, which are decorative.

The reference desaturates its photo. This does not; pick or grade a photo
that sits under a navy wash.

## Behaviour

| Input | What happens |
| :--- | :--- |
| No input | Services advance every `interval` ms as the orange line fills, and loop. |
| Pointer over the card | Pauses. The line holds its place and resumes from there. |
| Focus inside the card | Pauses, same way. |
| Tab hidden, or hero under 15% visible | Pauses. |
| Pause button | Stops autoplay until pressed again. Its label switches between "Pause service rotation" and "Play service rotation". |
| Prev / next buttons, or <kbd>←</kbd> <kbd>→</kbd> on them | Steps one service, wrapping. The new line starts from empty. |

The timing is a CSS animation on the progress line; its `animationend`
advances the card. So pausing is `animation-play-state`, not a timer, and
there is nothing to drift.

## Accessibility

- The card is a `region` with `aria-roledescription="carousel"` and the card
  label as its name. Each service is a `group` labelled "2 of 4".
- Hidden services carry `hidden`, so they are neither read nor tabbable.
- A polite live region announces **only what the visitor did**: the new
  service after prev/next, or "Rotation paused". The automatic advance is
  silent, or a screen reader would be interrupted every few seconds.
- The visible counter is `aria-hidden`; the slide labels say the same thing.
- The controls are 28px discs with a 44px hit area, and every control has a
  visible `:focus-visible` ring in the accent.
- Tab order: prev, pause, next, the service name (when it is a link), the CTA.
- The background is decorative by default.

## Reduced motion

Autoplay never starts and the pause button is not rendered. The progress line
becomes a static position indicator (a quarter for the first of four), the
entrance animations and hover transitions are off, and prev/next still work.

## Without JavaScript

The first service shows with its line at its position, the CTA and the
service link work, and the controls stay hidden (they would do nothing). The
other services are in the HTML but `hidden`.

## Responsive

| Width | Layout | Heading | Card |
| :--- | :--- | :--- | :--- |
| ≥ 1200 | copy bottom-left, card bottom-right; padding 240/30/90 | 120/96 | 416 × 240 with picture strip |
| 810–1199 | stacked, card right-aligned; padding 200/30/70 | 88/72 | 416 × 240 with picture strip |
| < 810 | stacked, card full width; padding 140/20/60; sizes to content | 40/32, one run of text | no picture, just space above the line |

Below 380px the service name drops above the CTA instead of sharing its row.

## Approximations

- **The service picture strip is an addition.** In the reference the card's
  middle is blank white space (a 115px gap); the brief asked for a picture
  area, so the strip fills that gap at 810px and up. Leave `image` off every
  service to get the reference's empty card back.
- **Prev, pause and next controls are an addition.** The reference has none;
  they are what makes the autoplay pausable and steppable from a keyboard.
  They add 14px to the card's top row.
- Card radius is the measured 10px, not the 8px in the brief.
- The badge's border and blur on desktop were estimated from the screenshot
  (the dump reports no border); the phone fill `rgba(10,37,64,0.16)` is
  measured and used at every width.
- Tablet (810–1199) was not captured. The heading size (88/72) and the
  stacked layout with a right-aligned card are interpolated.
- The counter's slide-up and the slide entrance (0.4–0.5s ease-out) are
  estimates; the reference's number change could not be timed from a still
  dump. The 3.5s fill is from the brief.
- The reference applies grayscale and luminosity blending to its photo; this
  does not filter your image.
- The CTA hover (lighter navy, arrow nudges right) is not measured.

===== END src/components/ServiceTickerHero.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.

--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Service ticker hero” from my Astro Component Library:
https://astro.baysixmedia.com/components/service-ticker-hero/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/ServiceTickerHero.astro

one fileNo imports, no companion files, no packages. Copy ServiceTickerHero.astro into any Astro project's components folder and render it.

---
/**
 * 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>