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.