The 2 files 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: "Icon stat band"
Library page: https://astro.baysixmedia.com/components/icon-stat-band/
Files: 2 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/IconStatBand.astro =====
---
/**
* IconStatBand
*
* A navy band over a dimmed road photograph with four stats in a row: an
* orange line icon, a big white figure with its suffix ("k+", "+") and a
* short label. The row rises 40px into place the first time it scrolls into
* view, and each figure counts up from zero to its value.
*
* The final figures are in the HTML, so without JavaScript or under reduced
* motion the band simply shows them. Stacks into one centred column on a
* phone, two by two on a tablet.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export type IconStatBandIcon = "transit" | "team" | "experience" | "customers" | "truck" | "warehouse";
export interface IconStatBandItem {
/** One of the built-in orange line icons. */
icon?: IconStatBandIcon;
/** The figure, a whole number. Counted up from 0. */
value: number;
/** Written straight after the figure, e.g. "k+", "+", "%". */
suffix?: string;
label: string;
}
export interface IconStatBandProps {
items?: IconStatBandItem[];
/** Background photo under the navy wash. `src: ""` leaves plain navy. */
image?: { src?: string; alt?: string };
/** Count-up length in ms. `0` shows the figures without counting. */
countDuration?: number;
/** Gives the section an accessible name. Empty by default. */
ariaLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends IconStatBandProps {}
const {
items = [],
image: imageProp,
countDuration = 2000,
ariaLabel,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
/* Background only: always decorative. No photo unless one is passed. */
const image: { src?: string; alt?: string } = imageProp ?? {};
const duration = Math.max(0, Number(countDuration) || 0);
const fmt = (n: number) => Math.round(n).toLocaleString("en-US");
/* A stat only with a real figure and a label: a partial item renders nothing, never "undefined". */
const stats = (items ?? []).filter(
(i) => i && typeof i.value === "number" && Number.isFinite(i.value) && typeof i.label === "string" && i.label.trim(),
);
---
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{stats.length > 0 && (
<section
class:list={["isb", className]}
id={id}
aria-label={ariaLabel || undefined}
data-isb-root
data-isb-duration={duration}
>
<div class="isb-bg" aria-hidden="true">
{image.src && <img src={image.src} alt="" width="2000" height="260" loading="lazy" decoding="async" />}
</div>
<ul class="isb-row" role="list" data-isb-row>
{
stats.map((item) => (
<li class="isb-item">
<span class="isb-icon" aria-hidden="true">
<svg viewBox="0 0 66 60" width="66" height="60" focusable="false">
{item.icon === "team" ? (
<g>
<path d="m33 3 1.9 3.9 4.3.6-3.1 3 .7 4.2-3.8-2-3.8 2 .7-4.2-3.1-3 4.3-.6Z" />
<circle cx="33" cy="25" r="6" />
<path d="M22 50V41a11 11 0 0 1 22 0v9Z" />
<circle cx="15" cy="30" r="5" />
<path d="M6 52v-7a9 9 0 0 1 15-6.7" />
<circle cx="51" cy="30" r="5" />
<path d="M60 52v-7a9 9 0 0 0-15-6.7" />
<path d="M4 56h58" />
</g>
) : item.icon === "experience" ? (
<g>
<circle cx="20" cy="15" r="7" />
<path d="M7 42v-6a13 13 0 0 1 26 0v6Z" />
<path d="M16 29l4 5 4-5" />
<rect x="38" y="4" width="20" height="26" rx="2" />
<path d="M43 11h10M43 16h10M43 21h7" />
<path d="M34 8h-4v26" />
<path d="m9 49 1.2 2.4 2.6.4-1.9 1.8.5 2.6L9 55l-2.4 1.2.5-2.6-1.9-1.8 2.6-.4Z" />
<path d="m21 49 1.2 2.4 2.6.4-1.9 1.8.5 2.6L21 55l-2.4 1.2.5-2.6-1.9-1.8 2.6-.4Z" />
<path d="m33 49 1.2 2.4 2.6.4-1.9 1.8.5 2.6L33 55l-2.4 1.2.5-2.6-1.9-1.8 2.6-.4Z" />
<path d="m45 49 1.2 2.4 2.6.4-1.9 1.8.5 2.6L45 55l-2.4 1.2.5-2.6-1.9-1.8 2.6-.4Z" />
<path d="m57 49 1.2 2.4 2.6.4-1.9 1.8.5 2.6L57 55l-2.4 1.2.5-2.6-1.9-1.8 2.6-.4Z" />
</g>
) : item.icon === "customers" ? (
<g>
<path d="m13 4 1.6 3.3 3.6.5-2.6 2.5.6 3.6L13 12.2l-3.2 1.7.6-3.6-2.6-2.5 3.6-.5Z" />
<path d="m33 2 1.6 3.3 3.6.5-2.6 2.5.6 3.6L33 10.2l-3.2 1.7.6-3.6-2.6-2.5 3.6-.5Z" />
<path d="m53 4 1.6 3.3 3.6.5-2.6 2.5.6 3.6L53 12.2l-3.2 1.7.6-3.6-2.6-2.5 3.6-.5Z" />
<circle cx="33" cy="26" r="6" />
<path d="M22 56v-8a11 11 0 0 1 22 0v8Z" />
<circle cx="14" cy="31" r="5" />
<path d="M4 56v-5a10 10 0 0 1 16-8" />
<circle cx="52" cy="31" r="5" />
<path d="M62 56v-5a10 10 0 0 0-16-8" />
</g>
) : item.icon === "truck" ? (
<g>
<path d="M3 14h36v28H3Z" />
<path d="M39 22h12l10 11v9H39Z" />
<path d="M44 26v8h13" />
<circle cx="14" cy="46" r="5" />
<circle cx="50" cy="46" r="5" />
<path d="M3 52h58" />
</g>
) : item.icon === "warehouse" ? (
<g>
<path d="M4 22 33 6l29 16v32H4Z" />
<path d="M14 54V30h38v24" />
<path d="M20 36h12v9H20ZM34 36h12v9H34ZM27 45h12v9H27Z" />
</g>
) : (
<g>
<path d="M8 13h14l7-9h5l-3 9h11l4-4h4l-2 7 2 7h-4l-4-4H31l3 9h-5l-7-9H8" />
<circle cx="33" cy="40" r="15" />
<path d="M18 40h30M33 25c-5 4-7 9-7 15s2 11 7 15M33 25c5 4 7 9 7 15s-2 11-7 15" />
<path d="M6 58h12M48 58h12M24 58h2M30 58h2M36 58h2M42 58h2" />
</g>
)}
</svg>
</span>
<span class="isb-text">
<span class="isb-figure">
<span class="isb-sr">{fmt(item.value)}{item.suffix ?? ""}</span>
<span class="isb-num" aria-hidden="true" data-isb-num data-value={item.value}>{fmt(item.value)}</span><span class="isb-suffix" aria-hidden="true">{item.suffix ?? ""}</span>
</span>
<span class="isb-label">{item.label}</span>
</span>
</li>
))
}
</ul>
</section>
)}
<style>
.isb {
--isb-navy: var(--tg-navy, #020e28);
--isb-orange: var(--tg-orange, #fd5523);
--isb-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--isb-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--isb-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
box-sizing: border-box;
position: relative;
isolation: isolate;
padding: 60px 20px;
background: var(--isb-navy);
color: #fff;
overflow: hidden;
}
.isb *,
.isb *::before,
.isb *::after {
box-sizing: border-box;
}
/* The photo sits under a navy wash at 90%, as measured. */
.isb-bg {
position: absolute;
inset: 0;
z-index: -1;
background: var(--isb-navy);
}
.isb-bg img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.isb-bg::after {
content: "";
position: absolute;
inset: 0;
background: rgb(2 14 40 / 0.9);
}
.isb .isb-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
max-width: 1253px;
margin: 0 auto;
padding: 0;
list-style: none;
transition:
opacity 0.8s var(--isb-ease),
transform 0.8s var(--isb-ease);
}
.isb[data-isb-ready]:not([data-in]) .isb-row {
opacity: 0;
transform: translateY(40px);
}
.isb-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.isb-icon {
display: block;
flex: none;
width: 40px;
height: 36px;
color: var(--isb-orange);
}
.isb-icon svg {
display: block;
width: 100%;
height: 100%;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.isb-text {
display: flex;
flex-direction: column;
align-items: center;
}
.isb-figure {
font: 600 28px/normal var(--isb-display);
font-variant-numeric: tabular-nums;
color: #fff;
white-space: nowrap;
}
.isb-label {
font: 400 16px/21.04px var(--isb-body);
color: #ecf0f1;
}
.isb-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* ---- tablet: two by two ---- */
@media (min-width: 810px) {
.isb .isb-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, max-content));
justify-content: space-around;
gap: 36px 60px;
}
.isb-item {
flex-direction: row;
gap: 24px;
text-align: left;
}
.isb-icon {
width: 66px;
height: 60px;
}
.isb-icon svg {
stroke-width: 1.6;
}
.isb-text {
align-items: flex-start;
}
.isb-figure {
font-size: 34px;
}
}
/* ---- desktop: one row, space-between, as measured ---- */
@media (min-width: 1200px) {
.isb .isb-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.isb-item {
min-width: 240px;
}
}
@media (prefers-reduced-motion: reduce) {
.isb .isb-row {
transition: none;
}
.isb[data-isb-ready]:not([data-in]) .isb-row {
opacity: 1;
transform: none;
}
}
@media (forced-colors: active) {
.isb-icon {
color: CanvasText;
}
}
</style>
<script>
/*
* Appear-on-scroll and count-up. One IntersectionObserver per instance,
* scoped to its root; it sets `data-in` once, then counts each figure from
* 0 to its data-value with requestAnimationFrame. Everything is torn down
* on astro:before-swap. Under reduced motion nothing hides or counts: the
* final figures, already in the HTML, stay as they are.
*/
function initIconStatBand(root: HTMLElement) {
if (root.dataset.isbReady === "") return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
return;
}
const controller = new AbortController();
const { signal } = controller;
const duration = Number(root.dataset.isbDuration) || 0;
const nums = Array.from(root.querySelectorAll<HTMLElement>("[data-isb-num]"));
const fmt = (n: number) => Math.round(n).toLocaleString("en-US");
let frame = 0;
root.dataset.isbReady = "";
if (duration > 0) nums.forEach((el) => (el.textContent = "0"));
const count = () => {
if (duration <= 0) return;
const start = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
nums.forEach((el) => (el.textContent = fmt(Number(el.dataset.value) * eased)));
if (t < 1 && !signal.aborted) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
};
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
count();
}
},
{ threshold: 0.25 },
);
observer.observe(root);
signal.addEventListener("abort", () => {
observer.disconnect();
cancelAnimationFrame(frame);
});
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.isbReady;
},
{ once: true, signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-isb-root]").forEach(initIconStatBand);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/IconStatBand.astro =====
===== FILE (supporting): src/components/IconStatBand.md =====
# Icon stat band
A navy band over a dimmed road photograph with a row of stats: an orange
line icon, a big white figure with its suffix, and a short label. The row
rises into view and each figure counts up from zero.
Catalog page: `/components/icon-stat-band`
## Files required
**`IconStatBand.astro`** — the whole component, including the icons (inline
SVG) and one small script. No imports, no npm packages.
`IconStatBand.md` — this file.
`public/icon-stat-band/highway-dusk.webp` — the catalog demo's background.
Not a default: without `image` the band is plain navy.
## Basic usage
```astro
---
import IconStatBand from "../components/IconStatBand.astro";
---
<IconStatBand
items={[
{ icon: "transit", value: 68, suffix: "k+", label: "Loads Delivered" },
{ icon: "team", value: 52, suffix: "+", label: "Dispatch Staff" },
{ icon: "experience", value: 27, suffix: "+", label: "Years on the Road" },
{ icon: "customers", value: 42, suffix: "k+", label: "Repeat Shippers" },
]}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `IconStatBandItem[]?` | `[]` | See below. Four fills the desktop row as measured; three or five also work. No items → the component renders nothing. |
| `image` | `{ src?, alt? }?` | none | Background photo, always decorative. Without `src` the band is plain navy. |
| `countDuration` | `number?` | `2000` | Count-up length in ms. `0` shows the figures without counting. |
| `ariaLabel` | `string?` | — | Names the `<section>`. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips it. |
| `class`, `id` | `string?` | — | On the root. |
```ts
interface IconStatBandItem {
icon?: "transit" | "team" | "experience" | "customers" | "truck" | "warehouse";
value: number; // whole number, counted from 0
suffix?: string; // "k+", "+", "%"
label: string;
}
```
## Behaviour
- **Appear** (measured): the row starts at `opacity 0, translateY(40px)` and
settles over 0.8s when a quarter of the band is in view. One
IntersectionObserver per instance; it disconnects after firing.
- **Count-up:** the figures run from 0 to their values over `countDuration`
with an ease-out curve, starting with the appear. Figures are formatted
with thousands separators (`1,200`).
- **No hover states** — nothing in the band is interactive.
- **Without JS / reduced motion:** the final figures are in the HTML and
nothing hides, moves or counts. The hidden start pose applies only after
the script marks the root `data-isb-ready`.
- Lifecycle: initialises on load and on `astro:page-load`, idempotent; the
observer and animation frame are cancelled on `astro:before-swap`.
## Layout
| Width | Layout |
| :--- | :--- |
| ≥ 1200 | One row, `space-between` across 1253px; 66×60 icon, 24px gap, 34px figure, 16px label. 184px tall. |
| 810–1199 | Two by two. |
| < 810 | One centred column, icon above figure, 40×36 icon, 28px figure. |
## Accessibility
- A `<ul>` of stats. Each figure's final value is in a visually hidden span
that screen readers read; the counting digits are `aria-hidden`, so no one
hears "0 … 17 … 43".
- Icons are decorative (`aria-hidden`).
- White on navy ~ 17:1; the `#ecf0f1` labels likewise.
## Images and credits
| File | Size | Unsplash id |
| :--- | :--- | :--- |
| `public/icon-stat-band/highway-dusk.webp` | 2000×260 | P0bVatS8Jdw |
Unsplash License (free commercial use, no attribution required).
## Approximations
- **The count-up was inferred, not timed.** The reference captures show
different figures in the DOM dump and the two screenshots (e.g. 55 / 62 /
49 for the same stat), so the figures animate; their final values,
duration and easing weren't measurable. 2000ms ease-out from 0 is a
judgement call.
- The reference layers the photo inside a 90%-opacity navy overlay; here it
is a photo under a 90% navy wash, which renders the same.
- Icons are original line drawings in the reference's style, not copies.
- Tablet (two by two) was not captured.
===== END src/components/IconStatBand.md =====
===== FILE (required): public/icon-stat-band/highway-dusk.webp =====
[binary asset — download https://astro.baysixmedia.com/icon-stat-band/highway-dusk.webp and save it to public/icon-stat-band/highway-dusk.webp]
--- 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.